mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-23 02:34:17 +00:00
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
244 lines
7.7 KiB
Go
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
|
|
}
|