package main import ( "context" "fmt" "os" "sort" "strings" "time" "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud" "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/client" "github.com/UpCloudLtd/upcloud-go-api/v8/upcloud/service" "go.yaml.in/yaml/v3" ) const ( repoURL = "https://tangled.org/evan.jarrett.net/at-container-registry" repoBranch = "main" privateNetworkCIDR = "10.0.1.0/24" ) // InfraConfig holds infrastructure configuration. type InfraConfig struct { Zone string Plan string ScannerPlan string // plan for the scanner server; resolved by resolveScannerPlan when empty SSHPublicKey string S3SecretKey string // Infrastructure naming — derived from configs/appview.yaml.tmpl. // Edit that template to rebrand. ClientName string BaseDomain string RegistryDomains []string RepoURL string RepoBranch string } // Naming returns a Naming helper derived from ClientName. func (c *InfraConfig) Naming() Naming { return Naming{ClientName: c.ClientName} } func loadConfig(zone, plan, scannerPlan, sshKeyPath, s3Secret string) (*InfraConfig, error) { sshKey, err := readSSHPublicKey(sshKeyPath) if err != nil { return nil, err } clientName, baseDomain, registryDomains, err := extractFromAppviewTemplate() if err != nil { return nil, fmt.Errorf("extract config from template: %w", err) } return &InfraConfig{ Zone: zone, Plan: plan, ScannerPlan: scannerPlan, SSHPublicKey: sshKey, S3SecretKey: s3Secret, ClientName: clientName, BaseDomain: baseDomain, RegistryDomains: registryDomains, RepoURL: repoURL, RepoBranch: repoBranch, }, nil } // extractFromAppviewTemplate renders the appview config template with // zero-value ConfigValues and parses the resulting YAML to extract // deployment-specific values. The template is the single source of truth. func extractFromAppviewTemplate() (clientName, baseDomain string, registryDomains []string, err error) { rendered, err := renderConfig(appviewConfigTmpl, &ConfigValues{}) if err != nil { return "", "", nil, fmt.Errorf("render appview template: %w", err) } var cfg struct { Server struct { BaseURL string `yaml:"base_url"` ClientName string `yaml:"client_name"` RegistryDomains []string `yaml:"registry_domains"` } `yaml:"server"` } if err := yaml.Unmarshal([]byte(rendered), &cfg); err != nil { return "", "", nil, fmt.Errorf("parse appview template YAML: %w", err) } clientName = strings.ToLower(cfg.Server.ClientName) baseDomain = strings.TrimPrefix(cfg.Server.BaseURL, "https://") registryDomains = cfg.Server.RegistryDomains return clientName, baseDomain, registryDomains, nil } // readSSHPublicKey reads an SSH public key from a file path. An empty path // returns an empty key without error — callers that need the key (e.g. when // creating new servers) must check for empty before use. func readSSHPublicKey(path string) (string, error) { if path == "" { return "", nil } data, err := os.ReadFile(path) if err != nil { return "", fmt.Errorf("read SSH public key %s: %w", path, err) } key := strings.TrimSpace(string(data)) if key == "" { return "", fmt.Errorf("SSH public key file %s is empty", path) } return key, nil } // resolveInteractive fills in any empty Zone/Plan fields by launching // interactive TUI pickers that query the UpCloud API. func resolveInteractive(ctx context.Context, svc *service.Service, cfg *InfraConfig) error { if cfg.Zone == "" { z, err := pickZone(ctx, svc) if err != nil { return fmt.Errorf("zone picker: %w", err) } cfg.Zone = z } if cfg.Plan == "" { p, err := pickPlan(ctx, svc) if err != nil { return fmt.Errorf("plan picker: %w", err) } cfg.Plan = p } return nil } // scannerPlanSpec is the shape of the scanner's own server. The scanner is // memory-bound, not CPU-bound: Grype keeps a large working set of its 2 GB // vulnerability database resident (400-600 MiB idle) and Syft catalogs an // extracted image in memory on top of that. On the 1 GB hold host the two // together peaked at 644 MiB resident plus 1.9 GB of swap and thrashed the // box for eleven hours (2026-09-12). 4 GB fits the worst scan seen so far with // headroom; 30 GB of disk holds the database plus the ~3.8x extraction // amplification of a 1 GiB image. type planSpec struct { Cores int MemoryMB int DiskGB int } var scannerPlanSpec = planSpec{Cores: 2, MemoryMB: 4096, DiskGB: 30} // defaultScannerPlan is the UpCloud plan of that shape at the time of // writing (the Starter tier, which UpCloud called Developer / DEV- until // 2026). resolveScannerPlan checks it still exists and falls back to a shape // match if UpCloud renames the tier again. const defaultScannerPlan = "STARTER-2xCPU-4GB" // isStarterPlan reports whether a plan is in UpCloud's entry tier, which only // accepts "standard" storage: creating one with maxiops fails with // TIER_INVALID. The tier was named DEV- before it became STARTER-. func isStarterPlan(name string) bool { upper := strings.ToUpper(name) return strings.HasPrefix(upper, "STARTER-") || strings.HasPrefix(upper, "DEV-") } // hasPlan reports whether name is one of the plans UpCloud offers. func hasPlan(plans []upcloud.Plan, name string) bool { for _, p := range plans { if p.Name == name { return true } } return false } // matchPlan returns the plan names whose shape equals spec. Starter-tier // plans sort first because they are the cheapest way to buy that shape. func matchPlan(plans []upcloud.Plan, spec planSpec) []string { var names []string for _, p := range plans { if p.GPUAmount > 0 { continue } if p.CoreNumber == spec.Cores && p.MemoryAmount == spec.MemoryMB && p.StorageSize == spec.DiskGB { names = append(names, p.Name) } } sort.SliceStable(names, func(i, j int) bool { di, dj := isStarterPlan(names[i]), isStarterPlan(names[j]) if di != dj { return di } return names[i] < names[j] }) return names } // resolveScannerPlan fills cfg.ScannerPlan: the --scanner-plan flag wins, // then defaultScannerPlan if UpCloud still offers it, then the first plan // matching scannerPlanSpec, then the interactive picker. func resolveScannerPlan(ctx context.Context, svc *service.Service, cfg *InfraConfig) error { if cfg.ScannerPlan != "" { return nil } resp, err := svc.GetPlans(ctx) if err != nil { return fmt.Errorf("fetch plans: %w", err) } if hasPlan(resp.Plans, defaultScannerPlan) { cfg.ScannerPlan = defaultScannerPlan return nil } fmt.Printf("Plan %s is no longer offered; matching by shape.\n", defaultScannerPlan) if names := matchPlan(resp.Plans, scannerPlanSpec); len(names) > 0 { cfg.ScannerPlan = names[0] fmt.Printf("Scanner plan: %s (%d CPU, %d GB RAM, %d GB disk)\n", names[0], scannerPlanSpec.Cores, scannerPlanSpec.MemoryMB/1024, scannerPlanSpec.DiskGB) return nil } fmt.Printf("No plan offers %d CPU / %d GB RAM / %d GB disk; pick one for the scanner.\n", scannerPlanSpec.Cores, scannerPlanSpec.MemoryMB/1024, scannerPlanSpec.DiskGB) p, err := pickPlan(ctx, svc) if err != nil { return fmt.Errorf("scanner plan picker: %w", err) } cfg.ScannerPlan = p return nil } // newService creates an UpCloud API client. If token is non-empty it's used // directly; otherwise credentials are read from UPCLOUD_TOKEN env var. func newService(token string) (*service.Service, error) { var c *client.Client var err error if token != "" { c = client.New("", "", client.WithBearerAuth(token), client.WithTimeout(120*time.Second)) } else { c, err = client.NewFromEnv(client.WithTimeout(120 * time.Second)) if err != nil { return nil, fmt.Errorf("create UpCloud client: %w\n\nPass --token or set UPCLOUD_TOKEN", err) } } return service.New(c), nil }