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") } }