mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-24 11:14:14 +00:00
The admin crew tab renders one row per crew member and gives each row its own hx-get, so opening it on a hold with 551 crew issues 551 requests. Over HTTP/1.1 a browser runs at most ~6 per origin, so they queue six at a time and every other request to the same host queues behind them — which is why loading the relay page stalls while the crew rows are still resolving, and why the rows that lose the race come back as "Server error" toasts. The 504 behind that toast is the load balancer's, not the hold's: the hold logs those requests as 200. HTTP/2 multiplexes them over one connection and the queue disappears. It does not make the slow rows fast — that is a separate fix to the per-row identity lookup — but it stops one slow surface from blocking the rest of the panel. Two halves, because neither works alone. The load balancer terminates TLS and speaks cleartext to the origin, so ALPN never runs on the backend leg and net/http can only answer HTTP/1.1 there. Both servers now wrap their handler in h2c. The wrapper is opt-in per connection: it upgrades only for a client sending the h2c preface or "Upgrade: h2c", and passes everything else through untouched, so an HTTP/1.1 WebSocket upgrade is unaffected. Verified both directions against this wiring — HTTP/1.1 for a plain client, HTTP/2.0 with --http2-prior-knowledge. The frontend's http2_enabled was never set, so it sat at the UpCloud default of off. That is the half the browser actually sees. timeout_client is now stated explicitly at its current 10s rather than left implicit: it is the boundary that produces the 504s above, so it belongs somewhere visible. It is deliberately unchanged — raising it without fixing the slow lookup would only make a stalled row stall longer. The hold's *backend* stays on HTTP/1.1. It serves 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:. Routing that backend over h2 would break the firehose. The appview accepts no inbound WebSocket and has no such constraint. Both origins carry h2c regardless, so enabling it for the hold later is a config change, not a code change. createLoadBalancer only runs when there is no LB yet, so properties set there would reach a new deployment and never an existing one. ensureLBHTTP2 reconciles them onto an LB that already exists, following ensureLBForwardedHeaders: read what is there, change only what differs, report what it did, and no-op on a second run. Backend modifies carry the existing health check back, since Properties replaces the object wholesale. Also gofmt: provision.go was not gofmt-clean at HEAD, unrelated to this change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TA9D4DjaLZTvzQ7dJbu4eg
1544 lines
48 KiB
Go
1544 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")
|
|
return cmdProvision(token, zone, plan, sshKey, s3Secret, withScanner, withLabeler)
|
|
},
|
|
}
|
|
|
|
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")
|
|
rootCmd.AddCommand(provisionCmd)
|
|
}
|
|
|
|
func cmdProvision(token, zone, plan, sshKeyPath, s3Secret string, withScanner, withLabeler 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); err != nil {
|
|
return fmt.Errorf("build: %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 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); 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,
|
|
}
|
|
}
|
|
|
|
// backendHTTP2 reports whether a backend may be spoken to over HTTP/2.
|
|
//
|
|
// False for the hold, and that is not an oversight. The hold serves
|
|
// com.atproto.sync.subscribeRepos over WebSocket to external relays and to the
|
|
// scanner, and WebSocket over HTTP/2 requires the RFC 8441 Extended CONNECT
|
|
// that Go's http2 server does not implement for Upgrade:. Routing that backend
|
|
// over h2 would break the firehose. The appview accepts no inbound WebSocket,
|
|
// so it has no such constraint.
|
|
//
|
|
// Both origins wrap their handler in h2c regardless, so enabling this later is
|
|
// a config change rather than a code change.
|
|
func backendHTTP2(name string) *bool {
|
|
return new(name != "hold")
|
|
}
|
|
|
|
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",
|
|
HTTP2Enabled: backendHTTP2("appview"),
|
|
},
|
|
},
|
|
{
|
|
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",
|
|
HTTP2Enabled: backendHTTP2("hold"),
|
|
},
|
|
},
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
backends, err := svc.GetLoadBalancerBackends(ctx, &request.GetLoadBalancerBackendsRequest{ServiceUUID: lbUUID})
|
|
if err != nil {
|
|
return fmt.Errorf("get backends: %w", err)
|
|
}
|
|
for _, b := range backends {
|
|
wantH2 := backendHTTP2(b.Name)
|
|
have := b.Properties != nil && b.Properties.HTTP2Enabled != nil && *b.Properties.HTTP2Enabled
|
|
if have == *wantH2 {
|
|
continue
|
|
}
|
|
// Send the health check back with it: Properties replaces the object
|
|
// wholesale, so omitting these would drop the backend's health check.
|
|
props := &upcloud.LoadBalancerBackendProperties{HTTP2Enabled: wantH2}
|
|
if b.Properties != nil {
|
|
p := *b.Properties
|
|
p.HTTP2Enabled = wantH2
|
|
props = &p
|
|
}
|
|
if _, err := svc.ModifyLoadBalancerBackend(ctx, &request.ModifyLoadBalancerBackendRequest{
|
|
ServiceUUID: lbUUID,
|
|
Name: b.Name,
|
|
Backend: request.ModifyLoadBalancerBackend{Properties: props},
|
|
}); err != nil {
|
|
return fmt.Errorf("modify backend %s: %w", b.Name, err)
|
|
}
|
|
fmt.Printf(" Backend %s HTTP/2: %t\n", b.Name, *wantH2)
|
|
}
|
|
|
|
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 := 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
|
|
}
|