Files
at-container-registry/deploy/upcloud/provision.go
T
Evan JarrettandClaude Opus 5 788b9e1053 hold/admin: bound the crew tab's identity lookups, and drop backend HTTP/2
Two corrections to the HTTP/2 change, both found by running it.

UpCloud refuses http2_enabled on a backend that is not TLS:

  invalid_params_properties.http2_enabled='Tls must be enabled to enable HTTP2.'

This load balancer reaches both origins over the private network in cleartext,
so the option is not available here at all, and asking for it failed the whole
provision run with a 400 after the frontend had already been changed. Backend
HTTP/2 is removed rather than made conditional: it would need TLS terminated at
the origins, which is a much larger change than it earns. The frontend is the
leg that mattered, and it is enabled and confirmed live — all three domains now
negotiate h2 by ALPN.

The second correction is more interesting: enabling HTTP/2 did not fix the
symptom it was aimed at, and briefly made it look worse. With the browser no
longer rationing itself to ~6 connections, every row of the crew tab now reaches
the server at once, and the 504s went from a flat 10s to a climbing ladder:

  [HTTP/2 504 10026ms] ... [HTTP/2 504 15230ms] ... [HTTP/2 504 25976ms]

HTTP/1.1 had been hiding a server-side limit by throttling the client. The queue
moved; it did not disappear. What is actually slow is resolveHandle, which calls
live identity resolution per row with no bound of its own, so a DID whose
resolution hangs — a did:web on a host that stopped answering, a PDS that
accepts and then stalls — holds its request open until the proxy gives up. The
crew tab issues one request per member, so a hold with hundreds of crew gets
hundreds of chances to hit one.

resolveHandle now bounds each lookup at 2s. A handle is decoration on a row
whose DID is already rendered beside it, so waiting seconds for one and failing
the row when it does not arrive trades something load-bearing for something
cosmetic. Two seconds is far above a warm cached lookup and far below the
proxy's 10s cut, so a slow DID costs its own row a handle and costs the page
nothing.

The quota lookup on the same handler was the other suspect and was measured out:
records(collection, did) is indexed and the aggregation returns immediately on
the production database.

This does not remove the N+1 — the tab still issues one request per member, and
a batch endpoint is the real fix. It removes the failure.

Verified: the guard is covered by a test using a directory that hangs until its
context is cancelled, asserting both that the call returns near the bound and
that it did not return suspiciously early. make lint 0 issues, make test green
across 44 packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AV6Mk2AgghFsNo4HWQEaBV
2026-09-09 09:01:45 -05:00

1526 lines
48 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")
withLabeler, _ := cmd.Flags().GetBool("with-labeler")
withBilling, _ := cmd.Flags().GetBool("with-billing")
return cmdProvision(token, zone, plan, sshKey, s3Secret, withScanner, withLabeler, withBilling)
},
}
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 when creating new servers)")
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.Flags().Bool("with-labeler", false, "Deploy content moderation labeler alongside appview")
provisionCmd.Flags().Bool("with-billing", false, "Compile Stripe billing into the appview (pkg/billing, `billing` build tag). Off by default.")
rootCmd.AddCommand(provisionCmd)
}
func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, withLabeler, withBilling 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)
}
// Labeler setup
if withLabeler {
state.LabelerEnabled = true
_ = 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
// Labeler domain is a fixed subdomain on the base domain (e.g. labeler.seamark.dev)
labelerDomain := "labeler." + 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,
LabelerDomain: labelerDomain,
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, state.LabelerEnabled)
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)
}
if state.LabelerEnabled {
labelerConfigYAML, err := renderConfig(labelerConfigTmpl, vals)
if err != nil {
return fmt.Errorf("render labeler config: %w", err)
}
if err := syncConfigKeys("labeler", state.Appview.PublicIP, naming.LabelerConfigPath(), labelerConfigYAML); err != nil {
return fmt.Errorf("labeler config sync: %w", err)
}
}
} else {
fmt.Println("Creating appview server...")
appviewUserData, err := generateAppviewCloudInit(cfg, vals, state.LabelerEnabled)
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, labelerDomain, state.LabelerEnabled)
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)
}
// Same reason: HTTP/2 and the client timeout must reach an LB that already
// exists, not just one created by this run.
if err := ensureLBHTTP2(ctx, svc, state.LB.UUID); err != nil {
return fmt.Errorf("LB http2: %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)
}
// Ensure labeler backend + route-labeler rule when labeler is enabled
if state.LabelerEnabled {
if err := ensureLBLabelerRoute(ctx, svc, state.LB.UUID, state.Appview.PrivateIP, labelerDomain); err != nil {
return fmt.Errorf("LB labeler route: %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 state.LabelerEnabled {
tlsDomains = append(tlsDomains, labelerDomain)
}
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 := runMakeBuildTrixie(rootDir, withBilling); err != nil {
return fmt.Errorf("build: %w", err)
}
if err := verifyAppviewBilling(filepath.Join(rootDir, "bin", "atcr-appview"), withBilling); err != nil {
return 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 state.LabelerEnabled {
labelerLocal := filepath.Join(rootDir, "bin", "atcr-labeler")
labelerRemote := naming.InstallDir() + "/bin/" + naming.Labeler()
if err := scpFile(labelerLocal, state.Appview.PublicIP, labelerRemote); err != nil {
return fmt.Errorf("upload labeler: %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)
}
}
}
}
// Labeler binary: build and upload when labeler is enabled but appview was
// not freshly created (the appviewCreated branch above already handled it).
if state.LabelerEnabled && !appviewCreated {
rootDir := projectRoot()
labelerLocal := filepath.Join(rootDir, "bin", "atcr-labeler")
if err := runMakeBuildTrixie(rootDir, withBilling); err != nil {
return fmt.Errorf("build labeler: %w", err)
}
labelerRemote := naming.InstallDir() + "/bin/" + naming.Labeler()
fmt.Println("Deploying labeler binary...")
if err := scpFile(labelerLocal, state.Appview.PublicIP, labelerRemote); err != nil {
return fmt.Errorf("upload labeler: %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)
if state.LabelerEnabled {
fmt.Printf(" CNAME %-24s → %s\n", labelerDomain, 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:")
}
services := []string{naming.Appview(), naming.Hold()}
if state.ScannerEnabled {
services = append(services, naming.Scanner())
}
if state.LabelerEnabled {
services = append(services, naming.Labeler())
}
fmt.Printf(" systemctl start %s\n", strings.Join(services, " / "))
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) {
if cfg.SSHPublicKey == "" {
return nil, fmt.Errorf("creating server %s requires --ssh-key (path to SSH public key file)", title)
}
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",
},
},
})
}
// lbClientTimeout is the frontend's client-side timeout, in seconds. It is
// stated explicitly rather than left to the UpCloud default so the value the
// deployment relies on is visible here: the admin panel's slow endpoints are
// cut at this boundary, and the 504 that produces is what the UI surfaces as
// "Server error". Changing it changes that behaviour, so it is a deliberate
// knob, not an incidental one.
const lbClientTimeout = 10
// httpFrontendProperties are the https frontend's properties.
//
// HTTP/2 is the one that matters for the browser: over HTTP/1.1 a browser
// opens at most ~6 connections per origin, so a page that fans out many small
// requests (the admin crew tab issues one per member) queues them six at a
// time and blocks every other request to the same host behind them. h2
// multiplexes them over a single connection and the queue disappears.
//
// Clients that need an HTTP/1.1 upgrade are unaffected: h2 is negotiated per
// connection via ALPN, so a WebSocket client simply selects http/1.1.
func httpFrontendProperties() *upcloud.LoadBalancerFrontendProperties {
return &upcloud.LoadBalancerFrontendProperties{
HTTP2Enabled: new(true),
TimeoutClient: lbClientTimeout,
}
}
// Backends are deliberately left at HTTP/1.1.
//
// UpCloud refuses http2_enabled on a backend that is not TLS:
//
// invalid_params_properties.http2_enabled='Tls must be enabled to enable HTTP2.'
//
// This LB reaches both origins over the private network in cleartext, so the
// option is simply unavailable here; requesting it fails the whole provision
// run with a 400. Backend HTTP/2 would first require terminating TLS at the
// origins, which is a larger change than the frontend win justifies.
//
// The frontend is where it mattered anyway: that is the leg a browser speaks,
// and the ~6-connections-per-origin limit it removes is what made one slow
// endpoint block every other request to the same host.
//
// Both origins still wrap their handler in h2c, so if backend TLS is ever
// added, enabling this becomes a config change rather than a code change. Note
// the hold would still have to opt out even then: it serves
// com.atproto.sync.subscribeRepos over WebSocket to external relays and to the
// scanner, and WebSocket over HTTP/2 needs the RFC 8441 Extended CONNECT that
// Go's http2 server does not implement for Upgrade:.
func createLoadBalancer(ctx context.Context, svc *service.Service, cfg *InfraConfig, naming Naming, networkUUID, appviewIP, holdIP, holdDomain, labelerDomain string, withLabeler bool) (*upcloud.LoadBalancer, error) {
frontendRules := []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",
},
},
},
},
}
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 withLabeler {
frontendRules = append(frontendRules, labelerFrontendRule(labelerDomain))
backends = append(backends, labelerBackend(appviewIP))
}
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",
Properties: httpFrontendProperties(),
Networks: []upcloud.LoadBalancerFrontendNetwork{
{Name: "public"},
},
Rules: frontendRules,
},
{
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: backends,
})
if err != nil {
return nil, err
}
return lb, nil
}
// labelerBackend builds the labeler LB backend pointing at the appview server's
// private IP on the labeler listen port.
func labelerBackend(appviewIP string) request.LoadBalancerBackend {
return request.LoadBalancerBackend{
Name: "labeler",
Members: []request.LoadBalancerBackendMember{
{
Name: "labeler-1",
Type: upcloud.LoadBalancerBackendMemberTypeStatic,
IP: appviewIP,
Port: 5002,
Weight: 100,
MaxSessions: 1000,
Enabled: true,
},
},
Properties: &upcloud.LoadBalancerBackendProperties{
HealthCheckType: upcloud.LoadBalancerHealthCheckTypeHTTP,
HealthCheckURL: "/.well-known/did.json",
},
}
}
// labelerFrontendRule returns a host-match rule routing labelerDomain to the
// labeler backend with forwarded headers.
func labelerFrontendRule(labelerDomain string) request.LoadBalancerFrontendRule {
return request.LoadBalancerFrontendRule{
Name: "route-labeler",
Priority: 20,
Matchers: []upcloud.LoadBalancerMatcher{
{
Type: upcloud.LoadBalancerMatcherTypeHost,
Host: &upcloud.LoadBalancerMatcherHost{
Value: labelerDomain,
},
},
},
Actions: []upcloud.LoadBalancerAction{
request.NewLoadBalancerSetForwardedHeadersAction(),
{
Type: upcloud.LoadBalancerActionTypeUseBackend,
UseBackend: &upcloud.LoadBalancerActionUseBackend{
Backend: "labeler",
},
},
},
}
}
// 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).
// ensureLBHTTP2 reconciles HTTP/2 and the client timeout onto an LB that
// already exists.
//
// createLoadBalancer only runs when there is no LB yet, so without this the
// properties above would reach a fresh deployment and never an existing one.
// This follows ensureLBForwardedHeaders: read what is there, change only what
// differs, and say what it did. Running it twice is a no-op.
func ensureLBHTTP2(ctx context.Context, svc *service.Service, lbUUID string) error {
fe, err := svc.GetLoadBalancerFrontend(ctx, &request.GetLoadBalancerFrontendRequest{
ServiceUUID: lbUUID,
Name: "https",
})
if err != nil {
return fmt.Errorf("get https frontend: %w", err)
}
want := httpFrontendProperties()
haveHTTP2 := fe.Properties != nil && fe.Properties.HTTP2Enabled != nil && *fe.Properties.HTTP2Enabled
haveTimeout := fe.Properties != nil && fe.Properties.TimeoutClient == want.TimeoutClient
if haveHTTP2 && haveTimeout {
fmt.Println(" Frontend HTTP/2 + timeout: already set")
} else {
if _, err := svc.ModifyLoadBalancerFrontend(ctx, &request.ModifyLoadBalancerFrontendRequest{
ServiceUUID: lbUUID,
Name: "https",
Frontend: request.ModifyLoadBalancerFrontend{
Properties: want,
},
}); err != nil {
return fmt.Errorf("modify https frontend: %w", err)
}
fmt.Printf(" Frontend HTTP/2: enabled (timeout_client=%ds)\n", want.TimeoutClient)
}
return nil
}
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
}
// ensureLBLabelerRoute idempotently ensures the LB has a "labeler" backend
// pointing at the appview server's private IP and a "route-labeler" frontend
// rule matching labelerDomain. Used to add labeler routing to a pre-existing LB
// during a re-provision with --with-labeler.
func ensureLBLabelerRoute(ctx context.Context, svc *service.Service, lbUUID, appviewIP, labelerDomain string) error {
// 1. Ensure backend exists
backends, err := svc.GetLoadBalancerBackends(ctx, &request.GetLoadBalancerBackendsRequest{ServiceUUID: lbUUID})
if err != nil {
return fmt.Errorf("get backends: %w", err)
}
hasBackend := false
for _, b := range backends {
if b.Name == "labeler" {
hasBackend = true
break
}
}
if !hasBackend {
_, err := svc.CreateLoadBalancerBackend(ctx, &request.CreateLoadBalancerBackendRequest{
ServiceUUID: lbUUID,
Backend: labelerBackend(appviewIP),
})
if err != nil {
return fmt.Errorf("create labeler backend: %w", err)
}
fmt.Println(" Labeler backend: created")
} else {
fmt.Println(" Labeler backend: exists")
}
// 2. Ensure frontend rule exists with correct host matcher
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-labeler" {
// Verify the host matcher and use_backend action are correct
hostOK := false
for _, m := range r.Matchers {
if m.Host != nil && m.Host.Value == labelerDomain {
hostOK = true
break
}
}
backendOK := false
for _, a := range r.Actions {
if a.UseBackend != nil && a.UseBackend.Backend == "labeler" {
backendOK = true
break
}
}
if hostOK && backendOK {
fmt.Println(" Route-labeler rule: exists and valid")
return nil
}
fmt.Println(" Route-labeler 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 route-labeler rule: %w", err)
}
break
}
}
if _, err := svc.CreateLoadBalancerFrontendRule(ctx, &request.CreateLoadBalancerFrontendRuleRequest{
ServiceUUID: lbUUID,
FrontendName: "https",
Rule: labelerFrontendRule(labelerDomain),
}); err != nil {
return fmt.Errorf("create route-labeler rule: %w", err)
}
fmt.Println(" Route-labeler rule: 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 := range 30 {
_, 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
}