Files
at-container-registry/deploy/upcloud/config.go
T
Evan JarrettandClaude Fable 5.1 83092d9aee deploy: give the scanner its own server, and stop the tool from undoing production fixes on provision
The scanner shared the hold's 1 GB host and thrashed it twice: 11 hours on
2026-09-12 and again on the 13th (644 MiB resident plus 1.9 GB of swap, 504
on every repo page). It is memory-bound, not CPU-bound, so it now gets a
dedicated STARTER-2xCPU-4GB server: own state entry, own plan flag (pinned
name, shape match if UpCloud renames the tier again, picker last), own
cloud-init, firewall, `update scanner`, `ssh scanner`, status, backup and
teardown. Its config reaches the hold over the private network and its unit
sets MemorySwapMax=0 so an overshoot is an OOM kill and a restart, not a
wedged host. The hold's cloud-init and update paths no longer carry it.

Three defects the first provision run exposed, all fixed here:

- Frontend HTTP/2 defaulted to on and was reconciled onto the LB every run.
  Re-enabling it on the 12th stranded the appview<->hold connections for
  25 minutes. Default is now off and reconciled off, with a guard test.
- The TLS step requested Let's Encrypt bundles for every registry domain,
  re-adding the .cr ones that were removed when those moved behind Bunny.
  It now skips any domain whose DNS does not resolve to the LB.
- Each prompt built its own bufio.Scanner on stdin, so the first swallowed
  every piped answer and the second read EOF and took the default, which
  re-ran cloud-init on the production hold. One shared reader, and no answer
  now means skip.

Also: STARTER- plans take standard storage (maxiops fails with TIER_INVALID),
and the cloud-init wait polls for up to 20 minutes instead of one SSH call
capped at five, which a first boot with npm exceeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hho5da4daoCoPBJ9tCrL7s
2026-09-12 21:49:26 -05:00

244 lines
7.7 KiB
Go

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
}