mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-22 18:24:21 +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
135 lines
5.3 KiB
Go
135 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/UpCloudLtd/upcloud-go-api/v8/upcloud"
|
|
)
|
|
|
|
// The scanner's server is chosen by shape, not by name, because UpCloud
|
|
// renames tiers. Developer plans win a tie: same shape, cheapest tier.
|
|
func TestMatchPlanPrefersDeveloperTier(t *testing.T) {
|
|
plans := []upcloud.Plan{
|
|
{Name: "2xCPU-4GB", CoreNumber: 2, MemoryAmount: 4096, StorageSize: 80},
|
|
{Name: "GPU-2xCPU-4GB-30GB", CoreNumber: 2, MemoryAmount: 4096, StorageSize: 30, GPUAmount: 1},
|
|
{Name: "HICPU-2xCPU-4GB-30GB", CoreNumber: 2, MemoryAmount: 4096, StorageSize: 30},
|
|
{Name: "STARTER-2xCPU-4GB", CoreNumber: 2, MemoryAmount: 4096, StorageSize: 30},
|
|
{Name: "STARTER-1xCPU-1GB", CoreNumber: 1, MemoryAmount: 1024, StorageSize: 10},
|
|
}
|
|
got := matchPlan(plans, scannerPlanSpec)
|
|
if strings.Join(got, ",") != "STARTER-2xCPU-4GB,HICPU-2xCPU-4GB-30GB" {
|
|
t.Fatalf("matchPlan = %v", got)
|
|
}
|
|
// Both the current and the pre-2026 tier names take standard storage;
|
|
// asking for maxiops on them fails with TIER_INVALID (seen 2026-09-13).
|
|
for _, n := range []string{"STARTER-2xCPU-4GB", "DEV-2xCPU-4GB-30GB", "dev-1xcpu-1gb-10gb"} {
|
|
if !isStarterPlan(n) {
|
|
t.Errorf("%s should be a starter-tier plan", n)
|
|
}
|
|
}
|
|
if isStarterPlan("2xCPU-4GB") || isStarterPlan("HICPU-2xCPU-4GB-30GB") {
|
|
t.Error("general purpose and high-CPU plans are not starter tier")
|
|
}
|
|
if len(matchPlan(plans, planSpec{Cores: 8, MemoryMB: 65536, DiskGB: 1000})) != 0 {
|
|
t.Fatal("a shape nobody sells must match nothing, so the picker takes over")
|
|
}
|
|
if !hasPlan(plans, defaultScannerPlan) || hasPlan(plans, "STARTER-2xCPU-4GB-80GB") {
|
|
t.Fatal("hasPlan must match the exact plan name")
|
|
}
|
|
// The pinned default must be the shape the spec describes, or the two
|
|
// paths in resolveScannerPlan would buy different servers.
|
|
if got := matchPlan(plans, scannerPlanSpec); got[0] != defaultScannerPlan {
|
|
t.Fatalf("defaultScannerPlan %s is not the DEV plan of shape %+v (matcher chose %s)", defaultScannerPlan, scannerPlanSpec, got[0])
|
|
}
|
|
}
|
|
|
|
// Limits step down from the plan's RAM: MemoryMax leaves the OS ~600 MiB,
|
|
// MemoryHigh sits below that, and the Go soft limit below both so the
|
|
// collector runs before the kernel reclaims or kills.
|
|
func TestScannerMemoryLimits(t *testing.T) {
|
|
goLimit, high, maxMem := scannerMemoryLimits(4096)
|
|
if goLimit != "2560MiB" || high != "3072M" || maxMem != "3500M" {
|
|
t.Fatalf("4 GB plan: got %s / %s / %s", goLimit, high, maxMem)
|
|
}
|
|
goLimit, _, _ = scannerMemoryLimits(1024)
|
|
if goLimit != "512MiB" {
|
|
t.Fatalf("the Go limit must never drop below the scanner's own 512 MiB default, got %s", goLimit)
|
|
}
|
|
}
|
|
|
|
func TestScannerCloudInitTargetsHoldPrivateIP(t *testing.T) {
|
|
cfg := &InfraConfig{ClientName: "seamark"}
|
|
vals := &ConfigValues{
|
|
BasePath: "/var/lib/seamark",
|
|
ScannerSecret: "abc",
|
|
ScannerHoldURL: scannerHoldURL("10.0.1.3"),
|
|
}
|
|
script, err := generateScannerCloudInit(cfg, vals)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, want := range []string{
|
|
`url: "ws://10.0.1.3:8080"`,
|
|
"mkdir -p /var/lib/seamark/scanner/vulndb /var/lib/seamark/scanner/tmp",
|
|
"Environment=GOMEMLIMIT=2560MiB",
|
|
"MemorySwapMax=0",
|
|
"systemctl enable seamark-scanner",
|
|
} {
|
|
if !strings.Contains(script, want) {
|
|
t.Errorf("scanner cloud-init missing %q", want)
|
|
}
|
|
}
|
|
if strings.Contains(script, "seamark-hold.service") {
|
|
t.Error("a standalone scanner must not order itself after a hold unit that is not on its host")
|
|
}
|
|
|
|
if _, err := generateScannerCloudInit(cfg, &ConfigValues{BasePath: "/var/lib/seamark"}); err == nil {
|
|
t.Error("rendering a scanner config with no hold address must fail, not ship ws://:8080")
|
|
}
|
|
if scannerHoldURL("") != "" {
|
|
t.Error("no hold IP must render as no URL so the guard above fires")
|
|
}
|
|
}
|
|
|
|
// The hold's cloud-init no longer carries the scanner: its unit, config and
|
|
// directories belong to the scanner server.
|
|
func TestHoldCloudInitHasNoScanner(t *testing.T) {
|
|
cfg := &InfraConfig{ClientName: "seamark"}
|
|
script, err := generateHoldCloudInit(cfg, &ConfigValues{BasePath: "/var/lib/seamark", HoldDomain: "x.cove.seamark.dev"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(script, "seamark-scanner") || strings.Contains(script, "vulndb") {
|
|
t.Error("hold cloud-init still installs the scanner")
|
|
}
|
|
}
|
|
|
|
// Re-enabling frontend HTTP/2 on 2026-09-12 stranded the appview<->hold
|
|
// connections through the LB for 25 minutes. Every provision run reconciles
|
|
// this property, so the default is the outage switch.
|
|
func TestLBFrontendHTTP2StaysOff(t *testing.T) {
|
|
p := httpFrontendProperties()
|
|
if p.HTTP2Enabled == nil || *p.HTTP2Enabled {
|
|
t.Fatal("LB frontend HTTP/2 must be off (see httpFrontendProperties)")
|
|
}
|
|
if p.TimeoutClient != lbClientTimeout {
|
|
t.Fatalf("timeout_client = %d, want %d", p.TimeoutClient, lbClientTimeout)
|
|
}
|
|
}
|
|
|
|
// A domain fronted by a CDN never resolves to the LB, so the tool must not
|
|
// request a certificate for it (it re-added the .cr bundles on 2026-09-13).
|
|
func TestPointsAtRequiresOverlap(t *testing.T) {
|
|
lb := map[string]bool{"203.0.113.10": true}
|
|
if pointsAt("localhost", lb) {
|
|
t.Fatal("localhost does not resolve to the LB")
|
|
}
|
|
if pointsAt("localhost", map[string]bool{}) {
|
|
t.Fatal("an unresolved LB must match nothing")
|
|
}
|
|
if !pointsAt("localhost", map[string]bool{"127.0.0.1": true, "::1": true}) {
|
|
t.Fatal("a domain resolving to an LB address must match")
|
|
}
|
|
}
|