1250 lines
38 KiB
Go
1250 lines
38 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
crypto_rand "crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
|
|
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/request"
|
|
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/service"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
var provisionCmd = &cobra.Command{
|
|
Use: "provision",
|
|
Short: "Create all infrastructure (servers, network, LB, firewall)",
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
token, _ := cmd.Root().PersistentFlags().GetString("token")
|
|
zone, _ := cmd.Flags().GetString("zone")
|
|
plan, _ := cmd.Flags().GetString("plan")
|
|
sshKey, _ := cmd.Flags().GetString("ssh-key")
|
|
s3Secret, _ := cmd.Flags().GetString("s3-secret")
|
|
withScanner, _ := cmd.Flags().GetBool("with-scanner")
|
|
return cmdProvision(token, zone, plan, sshKey, s3Secret, withScanner)
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
provisionCmd.Flags().String("zone", "", "UpCloud zone (interactive picker if omitted)")
|
|
provisionCmd.Flags().String("plan", "", "Server plan (interactive picker if omitted)")
|
|
provisionCmd.Flags().String("ssh-key", "", "Path to SSH public key file (required)")
|
|
provisionCmd.Flags().String("s3-secret", "", "S3 secret access key (for existing object storage)")
|
|
provisionCmd.Flags().Bool("with-scanner", false, "Deploy vulnerability scanner alongside hold")
|
|
_ = provisionCmd.MarkFlagRequired("ssh-key")
|
|
rootCmd.AddCommand(provisionCmd)
|
|
}
|
|
|
|
func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner bool) error {
|
|
cfg, err := loadConfig(zone, plan, sshKeyPath, s3Secret)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
naming := cfg.Naming()
|
|
|
|
svc, err := newService(token)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
|
defer cancel()
|
|
|
|
// Load existing state or start fresh
|
|
state, err := loadState()
|
|
if err != nil {
|
|
state = &InfraState{}
|
|
}
|
|
|
|
// Use zone from state if not provided via flags
|
|
if cfg.Zone == "" && state.Zone != "" {
|
|
cfg.Zone = state.Zone
|
|
}
|
|
|
|
// Only need interactive picker if we still need to create resources
|
|
needsServers := state.Appview.UUID == "" || state.Hold.UUID == ""
|
|
if cfg.Zone == "" || (needsServers && cfg.Plan == "") {
|
|
if err := resolveInteractive(ctx, svc, cfg); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if state.Zone == "" {
|
|
state.Zone = cfg.Zone
|
|
}
|
|
state.ClientName = cfg.ClientName
|
|
state.RepoBranch = cfg.RepoBranch
|
|
|
|
// Scanner setup
|
|
if withScanner {
|
|
state.ScannerEnabled = true
|
|
if state.ScannerSecret == "" {
|
|
secret, err := generateScannerSecret()
|
|
if err != nil {
|
|
return fmt.Errorf("generate scanner secret: %w", err)
|
|
}
|
|
state.ScannerSecret = secret
|
|
fmt.Printf("Generated scanner shared secret\n")
|
|
}
|
|
_ = saveState(state)
|
|
}
|
|
|
|
fmt.Printf("Provisioning %s infrastructure in zone %s...\n", naming.DisplayName(), cfg.Zone)
|
|
if needsServers {
|
|
fmt.Printf("Server plan: %s\n", cfg.Plan)
|
|
}
|
|
fmt.Println()
|
|
|
|
// S3 secret key — from flag for existing storage, from API for new
|
|
s3SecretKey := cfg.S3SecretKey
|
|
|
|
// 1. Object storage
|
|
if state.ObjectStorage.UUID != "" {
|
|
fmt.Printf("Object storage: %s (exists)\n", state.ObjectStorage.UUID)
|
|
// Refresh discoverable fields if missing (e.g. pre-seeded UUID only)
|
|
if state.ObjectStorage.Endpoint == "" || state.ObjectStorage.Bucket == "" {
|
|
fmt.Println(" Discovering endpoint, bucket, access key...")
|
|
discovered, err := lookupObjectStorage(ctx, svc, state.ObjectStorage.UUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
state.ObjectStorage.Endpoint = discovered.Endpoint
|
|
state.ObjectStorage.Region = discovered.Region
|
|
if discovered.Bucket != "" {
|
|
state.ObjectStorage.Bucket = discovered.Bucket
|
|
}
|
|
if discovered.AccessKeyID != "" {
|
|
state.ObjectStorage.AccessKeyID = discovered.AccessKeyID
|
|
}
|
|
_ = saveState(state)
|
|
}
|
|
} else {
|
|
fmt.Println("Creating object storage...")
|
|
objState, secretKey, err := provisionObjectStorage(ctx, svc, cfg.Zone, naming.S3Name())
|
|
if err != nil {
|
|
return fmt.Errorf("object storage: %w", err)
|
|
}
|
|
state.ObjectStorage = objState
|
|
s3SecretKey = secretKey
|
|
_ = saveState(state)
|
|
fmt.Printf(" S3 Secret Key: %s\n", secretKey)
|
|
}
|
|
|
|
fmt.Printf(" Endpoint: %s\n", state.ObjectStorage.Endpoint)
|
|
fmt.Printf(" Region: %s\n", state.ObjectStorage.Region)
|
|
fmt.Printf(" Bucket: %s\n", state.ObjectStorage.Bucket)
|
|
fmt.Printf(" Access Key: %s\n\n", state.ObjectStorage.AccessKeyID)
|
|
|
|
// Hold domain is zone-based (e.g. us-chi1.cove.seamark.dev)
|
|
holdDomain := cfg.Zone + ".cove." + cfg.BaseDomain
|
|
|
|
// Build config template values
|
|
vals := &ConfigValues{
|
|
S3Endpoint: state.ObjectStorage.Endpoint,
|
|
S3Region: state.ObjectStorage.Region,
|
|
S3Bucket: state.ObjectStorage.Bucket,
|
|
S3AccessKey: state.ObjectStorage.AccessKeyID,
|
|
S3SecretKey: s3SecretKey,
|
|
Zone: cfg.Zone,
|
|
HoldDomain: holdDomain,
|
|
HoldDid: "did:web:" + holdDomain,
|
|
BasePath: naming.BasePath(),
|
|
ScannerSecret: state.ScannerSecret,
|
|
}
|
|
|
|
// 2. Private network
|
|
if state.Network.UUID != "" {
|
|
fmt.Printf("Network: %s (exists)\n", state.Network.UUID)
|
|
} else {
|
|
fmt.Println("Creating private network...")
|
|
network, err := svc.CreateNetwork(ctx, &request.CreateNetworkRequest{
|
|
Name: naming.NetworkName(),
|
|
Zone: cfg.Zone,
|
|
IPNetworks: upcloud.IPNetworkSlice{
|
|
{
|
|
Address: privateNetworkCIDR,
|
|
DHCP: upcloud.True,
|
|
DHCPDefaultRoute: upcloud.False,
|
|
DHCPDns: []string{"8.8.8.8", "1.1.1.1"},
|
|
Family: upcloud.IPAddressFamilyIPv4,
|
|
Gateway: "",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create network: %w", err)
|
|
}
|
|
state.Network = StateRef{UUID: network.UUID}
|
|
_ = saveState(state)
|
|
fmt.Printf(" Network: %s (%s)\n", network.UUID, privateNetworkCIDR)
|
|
}
|
|
|
|
// Find Debian template (needed for server creation)
|
|
templateUUID, err := findDebianTemplate(ctx, svc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// 3. Appview server
|
|
appviewCreated := false
|
|
if state.Appview.UUID != "" {
|
|
fmt.Printf("Appview: %s (exists)\n", state.Appview.UUID)
|
|
appviewScript, err := generateAppviewCloudInit(cfg, vals)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := syncCloudInit("appview", state.Appview.PublicIP, appviewScript); err != nil {
|
|
return err
|
|
}
|
|
appviewConfigYAML, err := renderConfig(appviewConfigTmpl, vals)
|
|
if err != nil {
|
|
return fmt.Errorf("render appview config: %w", err)
|
|
}
|
|
if err := syncConfigKeys("appview", state.Appview.PublicIP, naming.AppviewConfigPath(), appviewConfigYAML); err != nil {
|
|
return fmt.Errorf("appview config sync: %w", err)
|
|
}
|
|
} else {
|
|
fmt.Println("Creating appview server...")
|
|
appviewUserData, err := generateAppviewCloudInit(cfg, vals)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
appview, err := createServer(ctx, svc, cfg, templateUUID, state.Network.UUID, naming.Appview(), appviewUserData)
|
|
if err != nil {
|
|
return fmt.Errorf("create appview: %w", err)
|
|
}
|
|
state.Appview = *appview
|
|
_ = saveState(state)
|
|
appviewCreated = true
|
|
fmt.Printf(" Appview: %s (public: %s, private: %s)\n", appview.UUID, appview.PublicIP, appview.PrivateIP)
|
|
}
|
|
|
|
// 4. Hold server
|
|
holdCreated := false
|
|
if state.Hold.UUID != "" {
|
|
fmt.Printf("Hold: %s (exists)\n", state.Hold.UUID)
|
|
holdScript, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := syncCloudInit("hold", state.Hold.PublicIP, holdScript); err != nil {
|
|
return err
|
|
}
|
|
holdConfigYAML, err := renderConfig(holdConfigTmpl, vals)
|
|
if err != nil {
|
|
return fmt.Errorf("render hold config: %w", err)
|
|
}
|
|
if err := syncConfigKeys("hold", state.Hold.PublicIP, naming.HoldConfigPath(), holdConfigYAML); err != nil {
|
|
return fmt.Errorf("hold config sync: %w", err)
|
|
}
|
|
if state.ScannerEnabled {
|
|
scannerConfigYAML, err := renderConfig(scannerConfigTmpl, vals)
|
|
if err != nil {
|
|
return fmt.Errorf("render scanner config: %w", err)
|
|
}
|
|
if err := syncConfigKeys("scanner", state.Hold.PublicIP, naming.ScannerConfigPath(), scannerConfigYAML); err != nil {
|
|
return fmt.Errorf("scanner config sync: %w", err)
|
|
}
|
|
}
|
|
} else {
|
|
fmt.Println("Creating hold server...")
|
|
holdUserData, err := generateHoldCloudInit(cfg, vals, state.ScannerEnabled)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hold, err := createServer(ctx, svc, cfg, templateUUID, state.Network.UUID, naming.Hold(), holdUserData)
|
|
if err != nil {
|
|
return fmt.Errorf("create hold: %w", err)
|
|
}
|
|
state.Hold = *hold
|
|
_ = saveState(state)
|
|
holdCreated = true
|
|
fmt.Printf(" Hold: %s (public: %s, private: %s)\n", hold.UUID, hold.PublicIP, hold.PrivateIP)
|
|
}
|
|
|
|
// 5. Firewall rules (idempotent — replaces all rules)
|
|
fmt.Println("Configuring firewall rules...")
|
|
for _, s := range []struct {
|
|
name string
|
|
uuid string
|
|
}{
|
|
{"appview", state.Appview.UUID},
|
|
{"hold", state.Hold.UUID},
|
|
} {
|
|
if err := createFirewallRules(ctx, svc, s.uuid, privateNetworkCIDR); err != nil {
|
|
return fmt.Errorf("firewall %s: %w", s.name, err)
|
|
}
|
|
}
|
|
|
|
// 6. Load balancer
|
|
if state.LB.UUID != "" {
|
|
fmt.Printf("Load balancer: %s (exists)\n", state.LB.UUID)
|
|
} else {
|
|
fmt.Println("Creating load balancer (Essentials tier)...")
|
|
lb, err := createLoadBalancer(ctx, svc, cfg, naming, state.Network.UUID, state.Appview.PrivateIP, state.Hold.PrivateIP, holdDomain)
|
|
if err != nil {
|
|
return fmt.Errorf("create LB: %w", err)
|
|
}
|
|
state.LB = StateRef{UUID: lb.UUID}
|
|
_ = saveState(state)
|
|
}
|
|
|
|
// Always reconcile forwarded headers rule (handles existing LBs)
|
|
if err := ensureLBForwardedHeaders(ctx, svc, state.LB.UUID); err != nil {
|
|
return fmt.Errorf("LB forwarded headers: %w", err)
|
|
}
|
|
|
|
// Ensure route-hold rule includes forwarded headers action
|
|
if err := ensureLBHoldForwardedHeaders(ctx, svc, state.LB.UUID, holdDomain); err != nil {
|
|
return fmt.Errorf("LB hold forwarded headers: %w", err)
|
|
}
|
|
|
|
// Always reconcile scanner block rule
|
|
if err := ensureLBScannerBlock(ctx, svc, state.LB.UUID); err != nil {
|
|
return fmt.Errorf("LB scanner block: %w", err)
|
|
}
|
|
|
|
// Always reconcile TLS certs (handles partial failures and re-runs)
|
|
tlsDomains := []string{cfg.BaseDomain}
|
|
tlsDomains = append(tlsDomains, cfg.RegistryDomains...)
|
|
tlsDomains = append(tlsDomains, holdDomain)
|
|
if err := ensureLBCertificates(ctx, svc, state.LB.UUID, tlsDomains); err != nil {
|
|
return fmt.Errorf("LB certificates: %w", err)
|
|
}
|
|
|
|
// Fetch LB DNS name for output
|
|
lbDNS := ""
|
|
if state.LB.UUID != "" {
|
|
lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{UUID: state.LB.UUID})
|
|
if err == nil {
|
|
for _, n := range lb.Networks {
|
|
if n.Type == upcloud.LoadBalancerNetworkTypePublic {
|
|
lbDNS = n.DNSName
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 7. Build locally and deploy binaries to new servers
|
|
if appviewCreated || holdCreated {
|
|
rootDir := projectRoot()
|
|
|
|
if err := runGenerate(rootDir); err != nil {
|
|
return fmt.Errorf("go generate: %w", err)
|
|
}
|
|
|
|
fmt.Println("\nBuilding locally (GOOS=linux GOARCH=amd64)...")
|
|
if appviewCreated {
|
|
outputPath := filepath.Join(rootDir, "bin", "atcr-appview")
|
|
if err := buildLocal(rootDir, outputPath, "./cmd/appview"); err != nil {
|
|
return fmt.Errorf("build appview: %w", err)
|
|
}
|
|
}
|
|
if holdCreated {
|
|
outputPath := filepath.Join(rootDir, "bin", "atcr-hold")
|
|
if err := buildLocal(rootDir, outputPath, "./cmd/hold"); err != nil {
|
|
return fmt.Errorf("build hold: %w", err)
|
|
}
|
|
if state.ScannerEnabled {
|
|
outputPath := filepath.Join(rootDir, "bin", "atcr-scanner")
|
|
if err := buildLocal(filepath.Join(rootDir, "scanner"), outputPath, "./cmd/scanner"); err != nil {
|
|
return fmt.Errorf("build scanner: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Println("\nWaiting for cloud-init to complete on new servers...")
|
|
if appviewCreated {
|
|
if err := waitForSetup(state.Appview.PublicIP, "appview"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if holdCreated {
|
|
if err := waitForSetup(state.Hold.PublicIP, "hold"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
fmt.Println("\nDeploying binaries...")
|
|
if appviewCreated {
|
|
localPath := filepath.Join(rootDir, "bin", "atcr-appview")
|
|
remotePath := naming.InstallDir() + "/bin/" + naming.Appview()
|
|
if err := scpFile(localPath, state.Appview.PublicIP, remotePath); err != nil {
|
|
return fmt.Errorf("upload appview: %w", err)
|
|
}
|
|
}
|
|
if holdCreated {
|
|
localPath := filepath.Join(rootDir, "bin", "atcr-hold")
|
|
remotePath := naming.InstallDir() + "/bin/" + naming.Hold()
|
|
if err := scpFile(localPath, state.Hold.PublicIP, remotePath); err != nil {
|
|
return fmt.Errorf("upload hold: %w", err)
|
|
}
|
|
if state.ScannerEnabled {
|
|
scannerLocal := filepath.Join(rootDir, "bin", "atcr-scanner")
|
|
scannerRemote := naming.InstallDir() + "/bin/" + naming.Scanner()
|
|
if err := scpFile(scannerLocal, state.Hold.PublicIP, scannerRemote); err != nil {
|
|
return fmt.Errorf("upload scanner: %w", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fmt.Println("\n=== Provisioning Complete ===")
|
|
fmt.Println()
|
|
fmt.Println("DNS records needed:")
|
|
if lbDNS != "" {
|
|
fmt.Printf(" CNAME %-24s → %s\n", cfg.BaseDomain, lbDNS)
|
|
for _, rd := range cfg.RegistryDomains {
|
|
fmt.Printf(" CNAME %-24s → %s\n", rd, lbDNS)
|
|
}
|
|
fmt.Printf(" CNAME %-24s → %s\n", holdDomain, lbDNS)
|
|
} else {
|
|
fmt.Println(" (LB DNS name not yet available — check 'status' in a few minutes)")
|
|
}
|
|
fmt.Println()
|
|
fmt.Println("SSH access:")
|
|
fmt.Printf(" ssh root@%s # appview\n", state.Appview.PublicIP)
|
|
fmt.Printf(" ssh root@%s # hold\n", state.Hold.PublicIP)
|
|
fmt.Println()
|
|
fmt.Println("Next steps:")
|
|
if appviewCreated || holdCreated {
|
|
fmt.Println(" 1. Edit configs if needed, then start services:")
|
|
} else {
|
|
fmt.Println(" 1. Start services:")
|
|
}
|
|
if state.ScannerEnabled {
|
|
fmt.Printf(" systemctl start %s / %s / %s\n", naming.Appview(), naming.Hold(), naming.Scanner())
|
|
} else {
|
|
fmt.Printf(" systemctl start %s / %s\n", naming.Appview(), naming.Hold())
|
|
}
|
|
fmt.Println(" 2. Configure DNS records above")
|
|
|
|
return nil
|
|
}
|
|
|
|
// provisionObjectStorage creates a new Managed Object Storage with a user, access key, and bucket.
|
|
// Returns the state and the secret key separately (only available at creation time).
|
|
func provisionObjectStorage(ctx context.Context, svc *service.Service, zone, s3Name string) (ObjectStorageState, string, error) {
|
|
// Map compute zone to object storage region (e.g. us-chi1 → us-east-1)
|
|
region := objectStorageRegion(zone)
|
|
|
|
storage, err := svc.CreateManagedObjectStorage(ctx, &request.CreateManagedObjectStorageRequest{
|
|
Name: s3Name,
|
|
Region: region,
|
|
ConfiguredStatus: upcloud.ManagedObjectStorageConfiguredStatusStarted,
|
|
Networks: []upcloud.ManagedObjectStorageNetwork{
|
|
{
|
|
Family: upcloud.IPAddressFamilyIPv4,
|
|
Name: "public",
|
|
Type: "public",
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return ObjectStorageState{}, "", fmt.Errorf("create storage: %w", err)
|
|
}
|
|
fmt.Printf(" Created: %s (region: %s)\n", storage.UUID, region)
|
|
|
|
// Find endpoint
|
|
var endpoint string
|
|
for _, ep := range storage.Endpoints {
|
|
if ep.DomainName != "" {
|
|
endpoint = "https://" + ep.DomainName
|
|
break
|
|
}
|
|
}
|
|
|
|
// Create user
|
|
_, err = svc.CreateManagedObjectStorageUser(ctx, &request.CreateManagedObjectStorageUserRequest{
|
|
ServiceUUID: storage.UUID,
|
|
Username: s3Name,
|
|
})
|
|
if err != nil {
|
|
return ObjectStorageState{}, "", fmt.Errorf("create user: %w", err)
|
|
}
|
|
|
|
// Attach admin policy
|
|
err = svc.AttachManagedObjectStorageUserPolicy(ctx, &request.AttachManagedObjectStorageUserPolicyRequest{
|
|
ServiceUUID: storage.UUID,
|
|
Username: s3Name,
|
|
Name: "admin",
|
|
})
|
|
if err != nil {
|
|
return ObjectStorageState{}, "", fmt.Errorf("attach policy: %w", err)
|
|
}
|
|
|
|
// Create access key (secret is only returned here)
|
|
accessKey, err := svc.CreateManagedObjectStorageUserAccessKey(ctx, &request.CreateManagedObjectStorageUserAccessKeyRequest{
|
|
ServiceUUID: storage.UUID,
|
|
Username: s3Name,
|
|
})
|
|
if err != nil {
|
|
return ObjectStorageState{}, "", fmt.Errorf("create access key: %w", err)
|
|
}
|
|
|
|
secretKey := ""
|
|
if accessKey.SecretAccessKey != nil {
|
|
secretKey = *accessKey.SecretAccessKey
|
|
}
|
|
|
|
// Create bucket
|
|
_, err = svc.CreateManagedObjectStorageBucket(ctx, &request.CreateManagedObjectStorageBucketRequest{
|
|
ServiceUUID: storage.UUID,
|
|
Name: s3Name,
|
|
})
|
|
if err != nil {
|
|
return ObjectStorageState{}, "", fmt.Errorf("create bucket: %w", err)
|
|
}
|
|
|
|
return ObjectStorageState{
|
|
UUID: storage.UUID,
|
|
Endpoint: endpoint,
|
|
Region: region,
|
|
Bucket: s3Name,
|
|
AccessKeyID: accessKey.AccessKeyID,
|
|
}, secretKey, nil
|
|
}
|
|
|
|
// objectStorageRegion maps a compute zone to the nearest object storage region.
|
|
func objectStorageRegion(zone string) string {
|
|
switch {
|
|
case strings.HasPrefix(zone, "us-"):
|
|
return "us-east-1"
|
|
case strings.HasPrefix(zone, "de-"):
|
|
return "europe-1"
|
|
case strings.HasPrefix(zone, "fi-"):
|
|
return "europe-1"
|
|
case strings.HasPrefix(zone, "nl-"):
|
|
return "europe-1"
|
|
case strings.HasPrefix(zone, "es-"):
|
|
return "europe-1"
|
|
case strings.HasPrefix(zone, "pl-"):
|
|
return "europe-1"
|
|
case strings.HasPrefix(zone, "se-"):
|
|
return "europe-1"
|
|
case strings.HasPrefix(zone, "au-"):
|
|
return "australia-1"
|
|
case strings.HasPrefix(zone, "sg-"):
|
|
return "singapore-1"
|
|
default:
|
|
return "us-east-1"
|
|
}
|
|
}
|
|
|
|
func createServer(ctx context.Context, svc *service.Service, cfg *InfraConfig, templateUUID, networkUUID, title, userData string) (*ServerState, error) {
|
|
storageTier := "maxiops"
|
|
if strings.HasPrefix(strings.ToUpper(cfg.Plan), "DEV-") {
|
|
storageTier = "standard"
|
|
}
|
|
|
|
// Look up the plan's storage size from the API instead of hardcoding.
|
|
diskSize := 25 // fallback
|
|
plans, err := svc.GetPlans(ctx)
|
|
if err == nil {
|
|
for _, p := range plans.Plans {
|
|
if p.Name == cfg.Plan {
|
|
diskSize = p.StorageSize
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
details, err := svc.CreateServer(ctx, &request.CreateServerRequest{
|
|
Zone: cfg.Zone,
|
|
Title: title,
|
|
Hostname: title,
|
|
Plan: cfg.Plan,
|
|
Metadata: upcloud.True,
|
|
UserData: userData,
|
|
Firewall: "on",
|
|
PasswordDelivery: "none",
|
|
StorageDevices: request.CreateServerStorageDeviceSlice{
|
|
{
|
|
Action: "clone",
|
|
Storage: templateUUID,
|
|
Title: title + "-disk",
|
|
Size: diskSize,
|
|
Tier: storageTier,
|
|
},
|
|
},
|
|
Networking: &request.CreateServerNetworking{
|
|
Interfaces: request.CreateServerInterfaceSlice{
|
|
{
|
|
Index: 1,
|
|
Type: upcloud.IPAddressAccessPublic,
|
|
IPAddresses: request.CreateServerIPAddressSlice{
|
|
{Family: upcloud.IPAddressFamilyIPv4},
|
|
},
|
|
},
|
|
{
|
|
Index: 2,
|
|
Type: upcloud.IPAddressAccessPrivate,
|
|
Network: networkUUID,
|
|
IPAddresses: request.CreateServerIPAddressSlice{
|
|
{Family: upcloud.IPAddressFamilyIPv4},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
LoginUser: &request.LoginUser{
|
|
CreatePassword: "no",
|
|
SSHKeys: request.SSHKeySlice{cfg.SSHPublicKey},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
fmt.Printf(" Waiting for server %s to start...\n", details.UUID)
|
|
details, err = svc.WaitForServerState(ctx, &request.WaitForServerStateRequest{
|
|
UUID: details.UUID,
|
|
DesiredState: upcloud.ServerStateStarted,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("wait for server: %w", err)
|
|
}
|
|
|
|
s := &ServerState{UUID: details.UUID}
|
|
for _, iface := range details.Networking.Interfaces {
|
|
for _, addr := range iface.IPAddresses {
|
|
if addr.Family == upcloud.IPAddressFamilyIPv4 {
|
|
switch iface.Type {
|
|
case upcloud.IPAddressAccessPublic:
|
|
s.PublicIP = addr.Address
|
|
case upcloud.IPAddressAccessPrivate:
|
|
s.PrivateIP = addr.Address
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return s, nil
|
|
}
|
|
|
|
func createFirewallRules(ctx context.Context, svc *service.Service, serverUUID, privateCIDR string) error {
|
|
networkBase := strings.TrimSuffix(privateCIDR, "/24")
|
|
networkBase = strings.TrimSuffix(networkBase, ".0")
|
|
|
|
return svc.CreateFirewallRules(ctx, &request.CreateFirewallRulesRequest{
|
|
ServerUUID: serverUUID,
|
|
FirewallRules: request.FirewallRuleSlice{
|
|
{
|
|
Direction: upcloud.FirewallRuleDirectionIn,
|
|
Action: upcloud.FirewallRuleActionAccept,
|
|
Family: upcloud.IPAddressFamilyIPv4,
|
|
Protocol: upcloud.FirewallRuleProtocolTCP,
|
|
DestinationPortStart: "22",
|
|
DestinationPortEnd: "22",
|
|
Position: 1,
|
|
Comment: "Allow SSH",
|
|
},
|
|
{
|
|
Direction: upcloud.FirewallRuleDirectionIn,
|
|
Action: upcloud.FirewallRuleActionAccept,
|
|
Family: upcloud.IPAddressFamilyIPv4,
|
|
SourceAddressStart: networkBase + ".0",
|
|
SourceAddressEnd: networkBase + ".255",
|
|
Position: 2,
|
|
Comment: "Allow private network",
|
|
},
|
|
{
|
|
Direction: upcloud.FirewallRuleDirectionIn,
|
|
Action: upcloud.FirewallRuleActionAccept,
|
|
Family: upcloud.IPAddressFamilyIPv4,
|
|
Protocol: upcloud.FirewallRuleProtocolUDP,
|
|
SourcePortStart: "123",
|
|
SourcePortEnd: "123",
|
|
Position: 3,
|
|
Comment: "Allow NTP replies",
|
|
},
|
|
{
|
|
Direction: upcloud.FirewallRuleDirectionIn,
|
|
Action: upcloud.FirewallRuleActionDrop,
|
|
Position: 4,
|
|
Comment: "Drop all other inbound",
|
|
},
|
|
},
|
|
})
|
|
}
|
|
|
|
func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain string) (*upcloud.LoadBalancer, error) {
|
|
lb, err := svc.CreateLoadBalancer(ctx, &request.CreateLoadBalancerRequest{
|
|
Name: naming.LBName(),
|
|
Plan: "essentials",
|
|
Zone: cfg.Zone,
|
|
ConfiguredStatus: upcloud.LoadBalancerConfiguredStatusStarted,
|
|
Networks: []request.LoadBalancerNetwork{
|
|
{
|
|
Name: "public",
|
|
Type: upcloud.LoadBalancerNetworkTypePublic,
|
|
Family: upcloud.LoadBalancerAddressFamilyIPv4,
|
|
},
|
|
{
|
|
Name: "private",
|
|
Type: upcloud.LoadBalancerNetworkTypePrivate,
|
|
Family: upcloud.LoadBalancerAddressFamilyIPv4,
|
|
UUID: networkUUID,
|
|
},
|
|
},
|
|
Frontends: []request.LoadBalancerFrontend{
|
|
{
|
|
Name: "https",
|
|
Mode: upcloud.LoadBalancerModeHTTP,
|
|
Port: 443,
|
|
DefaultBackend: "appview",
|
|
Networks: []upcloud.LoadBalancerFrontendNetwork{
|
|
{Name: "public"},
|
|
},
|
|
Rules: []request.LoadBalancerFrontendRule{
|
|
{
|
|
Name: "set-forwarded-headers",
|
|
Priority: 1,
|
|
Matchers: []upcloud.LoadBalancerMatcher{},
|
|
Actions: []upcloud.LoadBalancerAction{
|
|
request.NewLoadBalancerSetForwardedHeadersAction(),
|
|
},
|
|
},
|
|
{
|
|
Name: "route-hold",
|
|
Priority: 10,
|
|
Matchers: []upcloud.LoadBalancerMatcher{
|
|
{
|
|
Type: upcloud.LoadBalancerMatcherTypeHost,
|
|
Host: &upcloud.LoadBalancerMatcherHost{
|
|
Value: holdDomain,
|
|
},
|
|
},
|
|
},
|
|
Actions: []upcloud.LoadBalancerAction{
|
|
request.NewLoadBalancerSetForwardedHeadersAction(),
|
|
{
|
|
Type: upcloud.LoadBalancerActionTypeUseBackend,
|
|
UseBackend: &upcloud.LoadBalancerActionUseBackend{
|
|
Backend: "hold",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Name: "http-redirect",
|
|
Mode: upcloud.LoadBalancerModeHTTP,
|
|
Port: 80,
|
|
DefaultBackend: "appview",
|
|
Networks: []upcloud.LoadBalancerFrontendNetwork{
|
|
{Name: "public"},
|
|
},
|
|
Rules: []request.LoadBalancerFrontendRule{
|
|
{
|
|
Name: "redirect-https",
|
|
Priority: 10,
|
|
Matchers: []upcloud.LoadBalancerMatcher{
|
|
{
|
|
Type: upcloud.LoadBalancerMatcherTypeSrcPort,
|
|
SrcPort: &upcloud.LoadBalancerMatcherInteger{
|
|
Method: upcloud.LoadBalancerIntegerMatcherMethodEqual,
|
|
Value: 80,
|
|
},
|
|
},
|
|
},
|
|
Actions: []upcloud.LoadBalancerAction{
|
|
{
|
|
Type: upcloud.LoadBalancerActionTypeHTTPRedirect,
|
|
HTTPRedirect: &upcloud.LoadBalancerActionHTTPRedirect{
|
|
Scheme: upcloud.LoadBalancerActionHTTPRedirectSchemeHTTPS,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
Resolvers: []request.LoadBalancerResolver{},
|
|
Backends: []request.LoadBalancerBackend{
|
|
{
|
|
Name: "appview",
|
|
Members: []request.LoadBalancerBackendMember{
|
|
{
|
|
Name: "appview-1",
|
|
Type: upcloud.LoadBalancerBackendMemberTypeStatic,
|
|
IP: appviewIP,
|
|
Port: 5000,
|
|
Weight: 100,
|
|
MaxSessions: 1000,
|
|
Enabled: true,
|
|
},
|
|
},
|
|
Properties: &upcloud.LoadBalancerBackendProperties{
|
|
HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP,
|
|
HealthCheckURL: "/health",
|
|
},
|
|
},
|
|
{
|
|
Name: "hold",
|
|
Members: []request.LoadBalancerBackendMember{
|
|
{
|
|
Name: "hold-1",
|
|
Type: upcloud.LoadBalancerBackendMemberTypeStatic,
|
|
IP: holdIP,
|
|
Port: 8080,
|
|
Weight: 100,
|
|
MaxSessions: 1000,
|
|
Enabled: true,
|
|
},
|
|
},
|
|
Properties: &upcloud.LoadBalancerBackendProperties{
|
|
HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP,
|
|
HealthCheckURL: "/xrpc/_health",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return lb, nil
|
|
}
|
|
|
|
// ensureLBCertificates reconciles TLS certificate bundles on the load balancer.
|
|
// It skips domains that already have a TLS config attached and creates missing ones.
|
|
func ensureLBCertificates(ctx context.Context, svc *service.Service, lbUUID string, tlsDomains []string) error {
|
|
lb, err := svc.GetLoadBalancer(ctx, &request.GetLoadBalancerRequest{UUID: lbUUID})
|
|
if err != nil {
|
|
return fmt.Errorf("get load balancer: %w", err)
|
|
}
|
|
|
|
// Build set of existing TLS config names on the "https" frontend
|
|
existing := make(map[string]bool)
|
|
for _, fe := range lb.Frontends {
|
|
if fe.Name == "https" {
|
|
for _, tc := range fe.TLSConfigs {
|
|
existing[tc.Name] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, domain := range tlsDomains {
|
|
certName := "tls-" + strings.ReplaceAll(domain, ".", "-")
|
|
if existing[certName] {
|
|
fmt.Printf(" TLS certificate: %s (exists)\n", domain)
|
|
continue
|
|
}
|
|
|
|
bundle, err := svc.CreateLoadBalancerCertificateBundle(ctx, &request.CreateLoadBalancerCertificateBundleRequest{
|
|
Type: upcloud.LoadBalancerCertificateBundleTypeDynamic,
|
|
Name: certName,
|
|
KeyType: "ecdsa",
|
|
Hostnames: []string{domain},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create TLS cert for %s: %w", domain, err)
|
|
}
|
|
|
|
_, err = svc.CreateLoadBalancerFrontendTLSConfig(ctx, &request.CreateLoadBalancerFrontendTLSConfigRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
Config: request.LoadBalancerFrontendTLSConfig{
|
|
Name: certName,
|
|
CertificateBundleUUID: bundle.UUID,
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("attach TLS cert %s to frontend: %w", domain, err)
|
|
}
|
|
fmt.Printf(" TLS certificate: %s\n", domain)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureLBForwardedHeaders ensures the "https" frontend has a set_forwarded_headers rule.
|
|
// This makes the LB set X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Port headers,
|
|
// overwriting any pre-existing values (prevents spoofing).
|
|
func ensureLBForwardedHeaders(ctx context.Context, svc *service.Service, lbUUID string) error {
|
|
rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("get frontend rules: %w", err)
|
|
}
|
|
|
|
for _, r := range rules {
|
|
if r.Name == "set-forwarded-headers" {
|
|
// Verify it has the set_forwarded_headers action
|
|
for _, a := range r.Actions {
|
|
if a.SetForwardedHeaders != nil {
|
|
fmt.Println(" Forwarded headers rule: exists and valid")
|
|
return nil
|
|
}
|
|
}
|
|
// Rule exists but is misconfigured — delete and recreate
|
|
fmt.Println(" Forwarded headers rule: exists but misconfigured, recreating")
|
|
if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
Name: r.Name,
|
|
}); err != nil {
|
|
return fmt.Errorf("delete misconfigured forwarded headers rule: %w", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
_, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
Rule: request.LoadBalancerFrontendRule{
|
|
Name: "set-forwarded-headers",
|
|
Priority: 1,
|
|
Matchers: []upcloud.LoadBalancerMatcher{},
|
|
Actions: []upcloud.LoadBalancerAction{
|
|
request.NewLoadBalancerSetForwardedHeadersAction(),
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create forwarded headers rule: %w", err)
|
|
}
|
|
fmt.Println(" Forwarded headers rule: created")
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureLBHoldForwardedHeaders ensures the "route-hold" rule includes a
|
|
// set_forwarded_headers action alongside use_backend. Without this, the LB
|
|
// doesn't set X-Forwarded-For on hold-routed traffic.
|
|
func ensureLBHoldForwardedHeaders(ctx context.Context, svc *service.Service, lbUUID, holdDomain string) error {
|
|
rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("get frontend rules: %w", err)
|
|
}
|
|
|
|
for _, r := range rules {
|
|
if r.Name == "route-hold" {
|
|
hasForwarded := false
|
|
for _, a := range r.Actions {
|
|
if a.SetForwardedHeaders != nil {
|
|
hasForwarded = true
|
|
break
|
|
}
|
|
}
|
|
if hasForwarded {
|
|
fmt.Println(" Route-hold forwarded headers: exists")
|
|
return nil
|
|
}
|
|
// Delete and recreate with both actions
|
|
fmt.Println(" Route-hold forwarded headers: missing, recreating rule")
|
|
if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
Name: r.Name,
|
|
}); err != nil {
|
|
return fmt.Errorf("delete route-hold rule: %w", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
_, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
Rule: request.LoadBalancerFrontendRule{
|
|
Name: "route-hold",
|
|
Priority: 10,
|
|
Matchers: []upcloud.LoadBalancerMatcher{
|
|
{
|
|
Type: upcloud.LoadBalancerMatcherTypeHost,
|
|
Host: &upcloud.LoadBalancerMatcherHost{
|
|
Value: holdDomain,
|
|
},
|
|
},
|
|
},
|
|
Actions: []upcloud.LoadBalancerAction{
|
|
request.NewLoadBalancerSetForwardedHeadersAction(),
|
|
{
|
|
Type: upcloud.LoadBalancerActionTypeUseBackend,
|
|
UseBackend: &upcloud.LoadBalancerActionUseBackend{
|
|
Backend: "hold",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create route-hold rule: %w", err)
|
|
}
|
|
fmt.Println(" Route-hold forwarded headers: created")
|
|
|
|
return nil
|
|
}
|
|
|
|
// ensureLBScannerBlock ensures the "https" frontend has a rule that returns 403
|
|
// for common scanner paths (.php, .asp, .aspx, .jsp, .cgi, .env).
|
|
func ensureLBScannerBlock(ctx context.Context, svc *service.Service, lbUUID string) error {
|
|
rules, err := svc.GetLoadBalancerFrontendRules(ctx, &request.GetLoadBalancerFrontendRulesRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("get frontend rules: %w", err)
|
|
}
|
|
|
|
for _, r := range rules {
|
|
if r.Name == "block-scanners" {
|
|
for _, a := range r.Actions {
|
|
if a.HTTPReturn != nil {
|
|
fmt.Println(" Scanner block rule: exists and valid")
|
|
return nil
|
|
}
|
|
}
|
|
fmt.Println(" Scanner block rule: exists but misconfigured, recreating")
|
|
if err := svc.DeleteLoadBalancerFrontendRule(ctx, &request.DeleteLoadBalancerFrontendRuleRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
Name: r.Name,
|
|
}); err != nil {
|
|
return fmt.Errorf("delete misconfigured scanner block rule: %w", err)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
ignoreCase := true
|
|
_, err = svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{
|
|
ServiceUUID: lbUUID,
|
|
FrontendName: "https",
|
|
Rule: request.LoadBalancerFrontendRule{
|
|
Name: "block-scanners",
|
|
Priority: 2,
|
|
Matchers: []upcloud.LoadBalancerMatcher{
|
|
request.NewLoadBalancerPathMatcher(
|
|
upcloud.LoadBalancerStringMatcherMethodRegexp,
|
|
`\.(php|asp|aspx|jsp|cgi|env)$`,
|
|
&ignoreCase,
|
|
),
|
|
},
|
|
Actions: []upcloud.LoadBalancerAction{
|
|
{
|
|
Type: upcloud.LoadBalancerActionTypeHTTPReturn,
|
|
HTTPReturn: &upcloud.LoadBalancerActionHTTPReturn{
|
|
Status: 403,
|
|
ContentType: "text/plain",
|
|
Payload: base64.StdEncoding.EncodeToString([]byte("Forbidden")),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("create scanner block rule: %w", err)
|
|
}
|
|
fmt.Println(" Scanner block rule: created")
|
|
|
|
return nil
|
|
}
|
|
|
|
// lookupObjectStorage discovers details of an existing Managed Object Storage.
|
|
func lookupObjectStorage(ctx context.Context, svc *service.Service, uuid string) (ObjectStorageState, error) {
|
|
storage, err := svc.GetManagedObjectStorage(ctx, &request.GetManagedObjectStorageRequest{
|
|
UUID: uuid,
|
|
})
|
|
if err != nil {
|
|
return ObjectStorageState{}, fmt.Errorf("get object storage %s: %w", uuid, err)
|
|
}
|
|
|
|
var endpoint string
|
|
for _, ep := range storage.Endpoints {
|
|
if ep.DomainName != "" {
|
|
endpoint = "https://" + ep.DomainName
|
|
break
|
|
}
|
|
}
|
|
|
|
var bucket string
|
|
buckets, err := svc.GetManagedObjectStorageBucketMetrics(ctx, &request.GetManagedObjectStorageBucketMetricsRequest{
|
|
ServiceUUID: uuid,
|
|
})
|
|
if err == nil {
|
|
for _, b := range buckets {
|
|
if !b.Deleted {
|
|
bucket = b.Name
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
var accessKeyID string
|
|
users, err := svc.GetManagedObjectStorageUsers(ctx, &request.GetManagedObjectStorageUsersRequest{
|
|
ServiceUUID: uuid,
|
|
})
|
|
if err == nil {
|
|
for _, u := range users {
|
|
for _, k := range u.AccessKeys {
|
|
if k.Status == "Active" {
|
|
accessKeyID = k.AccessKeyID
|
|
break
|
|
}
|
|
}
|
|
if accessKeyID != "" {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return ObjectStorageState{
|
|
UUID: uuid,
|
|
Endpoint: endpoint,
|
|
Region: storage.Region,
|
|
Bucket: bucket,
|
|
AccessKeyID: accessKeyID,
|
|
}, nil
|
|
}
|
|
|
|
func findDebianTemplate(ctx context.Context, svc *service.Service) (string, error) {
|
|
storages, err := svc.GetStorages(ctx, &request.GetStoragesRequest{
|
|
Type: "template",
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("list templates: %w", err)
|
|
}
|
|
|
|
var debian13, debian12 string
|
|
for _, s := range storages.Storages {
|
|
title := strings.ToLower(s.Title)
|
|
if strings.Contains(title, "debian") {
|
|
if strings.Contains(title, "13") || strings.Contains(title, "trixie") {
|
|
debian13 = s.UUID
|
|
} else if strings.Contains(title, "12") || strings.Contains(title, "bookworm") {
|
|
debian12 = s.UUID
|
|
}
|
|
}
|
|
}
|
|
|
|
if debian13 != "" {
|
|
return debian13, nil
|
|
}
|
|
if debian12 != "" {
|
|
fmt.Println(" Debian 13 not available, using Debian 12")
|
|
return debian12, nil
|
|
}
|
|
|
|
return "", fmt.Errorf("no Debian template found — check UpCloud template list")
|
|
}
|
|
|
|
const cloudInitPath = "/var/lib/cloud/instance/scripts/part-001"
|
|
|
|
// syncCloudInit compares a locally-generated cloud-init script against what's
|
|
// on the server. If they differ (or the remote is missing), it prompts the
|
|
// user and re-runs the script over SSH.
|
|
func syncCloudInit(name, ip, localScript string) error {
|
|
// Fetch the remote script
|
|
remoteScript, err := runSSH(ip, fmt.Sprintf("cat %s 2>/dev/null || echo '__MISSING__'", cloudInitPath), false)
|
|
if err != nil {
|
|
fmt.Printf(" cloud-init: could not reach %s (%v)\n", name, err)
|
|
return nil
|
|
}
|
|
remoteScript = strings.TrimSpace(remoteScript)
|
|
|
|
if remoteScript == "__MISSING__" {
|
|
fmt.Printf(" cloud-init: not found on %s (server may need initial setup)\n", name)
|
|
} else {
|
|
localHash := fmt.Sprintf("%x", sha256.Sum256([]byte(strings.TrimSpace(localScript))))
|
|
remoteHash := fmt.Sprintf("%x", sha256.Sum256([]byte(remoteScript)))
|
|
if localHash == remoteHash {
|
|
fmt.Printf(" cloud-init: up to date\n")
|
|
return nil
|
|
}
|
|
fmt.Printf(" cloud-init: differs from local\n")
|
|
}
|
|
|
|
fmt.Printf(" Re-run cloud-init on %s? [Y/n] ", name)
|
|
scanner := bufio.NewScanner(os.Stdin)
|
|
scanner.Scan()
|
|
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
|
|
if answer != "" && answer != "y" && answer != "yes" {
|
|
fmt.Printf(" Skipped\n")
|
|
// Still update the remote reference so next provision sees an accurate diff
|
|
if err := writeRemoteCloudInit(ip, localScript); err != nil {
|
|
fmt.Printf(" WARNING: could not update remote cloud-init reference: %v\n", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Write the reference file first so next provision can detect real diffs,
|
|
// regardless of whether the script execution succeeds or fails.
|
|
if err := writeRemoteCloudInit(ip, localScript); err != nil {
|
|
fmt.Printf(" WARNING: could not update remote cloud-init reference: %v\n", err)
|
|
}
|
|
|
|
fmt.Printf(" Running cloud-init on %s (%s)... (this may take several minutes)\n", name, ip)
|
|
output, err := runSSH(ip, localScript, true)
|
|
if err != nil {
|
|
fmt.Printf(" ERROR: %v\n", err)
|
|
fmt.Printf(" Output:\n%s\n", output)
|
|
return fmt.Errorf("cloud-init %s failed", name)
|
|
}
|
|
|
|
fmt.Printf(" %s: cloud-init complete\n", name)
|
|
return nil
|
|
}
|
|
|
|
// generateScannerSecret generates a random 32-byte hex-encoded shared secret
|
|
// for authenticating scanner-to-hold WebSocket connections.
|
|
func generateScannerSecret() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := crypto_rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|
|
|
|
// writeRemoteCloudInit writes the local cloud-init script to the remote server
|
|
// so that subsequent provision runs can accurately detect real changes.
|
|
// Uses base64 encoding to avoid heredoc nesting issues (the cloud-init script
|
|
// itself contains heredocs like CFGEOF and SVCEOF).
|
|
func writeRemoteCloudInit(ip, script string) error {
|
|
encoded := base64.StdEncoding.EncodeToString([]byte(script))
|
|
cmd := fmt.Sprintf("mkdir -p $(dirname %s) && echo '%s' | base64 -d > %s", cloudInitPath, encoded, cloudInitPath)
|
|
_, err := runSSH(ip, cmd, false)
|
|
return err
|
|
}
|
|
|
|
// waitForSetup polls SSH availability on a newly created server, then waits
|
|
// for cloud-init to complete before returning.
|
|
func waitForSetup(ip, name string) error {
|
|
fmt.Printf(" %s (%s): waiting for SSH...\n", name, ip)
|
|
for i := 0; i < 30; i++ {
|
|
_, err := runSSH(ip, "echo ssh_ready", false)
|
|
if err == nil {
|
|
break
|
|
}
|
|
if i == 29 {
|
|
return fmt.Errorf("SSH not available after 5 minutes on %s (%s)", name, ip)
|
|
}
|
|
time.Sleep(10 * time.Second)
|
|
}
|
|
|
|
fmt.Printf(" %s: waiting for cloud-init...\n", name)
|
|
_, err := runSSH(ip, "cloud-init status --wait 2>/dev/null || true", false)
|
|
if err != nil {
|
|
return fmt.Errorf("cloud-init wait on %s: %w", name, err)
|
|
}
|
|
fmt.Printf(" %s: ready\n", name)
|
|
return nil
|
|
}
|