package host import ( "bytes" "context" "crypto/ed25519" "encoding/base64" "encoding/json" "fmt" "log" "net" "os" "os/exec" "path/filepath" "reflect" "runtime" "slices" "strings" "testing" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/metrics" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/status" ) // expectedUnits is the contract for what bootstrap installs — a new unit file // must be added here (and a removed one taken out) or TestUnitsEmbedComplete // fails, so the embed directive and the fleet's unit set can't drift apart. var expectedUnits = []string{ "carlos-adopt.path", "carlos-adopt.service", "carlos-adopt.timer", "carlos-app@.service", "carlos-app@.socket", "carlos-config-sync.service", "carlos-dbread.socket", "carlos-dbread@.service", "carlos-edge.service", "carlos-hibernate-verify.service", "carlos-hibernate-verify.timer", "carlos-instance-cmd@.service", "carlos-instance-control.service", "carlos-instance-control.socket", "carlos-instance-gen@.service", "carlos-instance-probe.service", "carlos-instance-seed@.service", "carlos-instance-sidecar@.service", "carlos-instance@.service", "carlos-instances.slice", "carlos-litestream.service", "carlos-relay.service", "carlos-restart.path", "carlos-restart.service", "carlos-restore-verify.service", "carlos-restore-verify.timer", "carlos-stage.path", "carlos-stage.service", "carlos-system-join.path", "carlos-system-join.service", "carlos-system-recover.service", "carlos-system-update.path", "carlos-system-update.service", "carlos.slice", "data-instances.mount", "run-carlos-instances.mount", "run-carlos-logs.mount", } // requireUnixSockets skips a test when the sandbox forbids unix-socket // creation (probe: net.Listen("unix") in a temp dir). None of the current // tests need one; the helper is here for the host tests that will (agent // serving loops), matching the pattern used across the repo. func requireUnixSockets(t *testing.T) { t.Helper() dir, err := os.MkdirTemp("", "unix-probe-") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) l, err := net.Listen("unix", filepath.Join(dir, "probe.sock")) if err != nil { if strings.Contains(err.Error(), "operation not permitted") { t.Skipf("sandbox forbids unix sockets: %v", err) } t.Fatalf("unix socket probe: %v", err) } l.Close() } func TestUnitsEmbedComplete(t *testing.T) { names, err := unitNames() if err != nil { t.Fatal(err) } got := map[string]bool{} for _, n := range names { got[n] = true } for _, want := range expectedUnits { if !got[want] { t.Errorf("unit %s missing from the embed FS", want) } } if len(names) != len(expectedUnits) { t.Errorf("embed FS has %d units %v, expected list has %d — keep them in step", len(names), names, len(expectedUnits)) } } // TestBootstrapStartsRelayUnit pins the one service bootstrap starts and the // one it must not. The relay is safe to start fleet-wide because its // ConditionPathExists gates it on a masters file; the edge is not, and a // change that quietly promoted every service into the start line would arm // half-staged boxes. func TestBootstrapStartsRelayUnit(t *testing.T) { plan := systemctlPlan(enabledUnits) var started []string for _, step := range plan { if step[0] == "start" { started = step[1:] } } if !slices.Contains(started, "carlos-relay.service") { t.Fatalf("relay not started by plan: %v", plan) } if slices.Contains(started, "carlos-edge.service") { t.Fatalf("edge must stay start-by-hand: %v", plan) } } // TestRelayUnitIsGatedAndLosesFirst pins the three lines that make it safe // to enable and start carlos-relay.service on every box in the fleet. The // condition is what keeps an unarmed box from ever listening; the OOM // adjustment and the absence of the bind capability are what keep the relay // from costing the edge anything when they share a box (relay spec §3). func TestRelayUnitIsGatedAndLosesFirst(t *testing.T) { b, err := unitsFS.ReadFile("units/" + relayUnitName) if err != nil { t.Fatal(err) } unit := string(b) for _, want := range []string{ "ConditionPathExists=/etc/carlos/relay-masters", "OOMScoreAdjust=1000", "MemoryMax=256M", "ExecStart=/opt/carlos/carlos relay serve", "ReadWritePaths=/data/relay /run/carlos", } { if !strings.Contains(unit, want) { t.Errorf("%s: missing %q", relayUnitName, want) } } // 3478 is unprivileged; only the edge may bind below 1024. if strings.Contains(unit, "CAP_NET_BIND_SERVICE") { t.Errorf("%s: must not carry CAP_NET_BIND_SERVICE — 3478 needs no capability", relayUnitName) } } func TestBootstrapWritesUnitsToStagingDir(t *testing.T) { root := t.TempDir() if err := bootstrap(root, nil); err != nil { t.Fatalf("bootstrap: %v", err) } unitDir := filepath.Join(root, "etc", "systemd", "system") for _, name := range expectedUnits { b, err := os.ReadFile(filepath.Join(unitDir, name)) if err != nil { t.Errorf("unit not installed: %v", err) continue } content := string(b) // Only the edge binds privileged ports; every other unit must not // carry the capability. if name == "carlos-edge.service" { if !strings.Contains(content, "AmbientCapabilities=CAP_NET_BIND_SERVICE") { t.Errorf("%s: missing AmbientCapabilities=CAP_NET_BIND_SERVICE", name) } } else if strings.Contains(content, "CAP_NET_BIND_SERVICE") { t.Errorf("%s: must not carry CAP_NET_BIND_SERVICE", name) } } // The edge unit is the agent role. edge, err := os.ReadFile(filepath.Join(unitDir, "carlos-edge.service")) if err != nil { t.Fatal(err) } for _, want := range []string{ "ExecStart=/opt/carlos/carlos agent", "EnvironmentFile=-/etc/carlos/host.env", "ProtectSystem=strict", "ReadWritePaths=/data /run/carlos", "NoNewPrivileges=yes", "MemoryDenyWriteExecute=yes", "SystemCallFilter=@system-service", "User=carlos", "Restart=always", // Delegation is the SOLE mechanism behind exec-instance readings // fleet-wide (exec-child accounting spec): without it the agent's // cgroup manager degrades to nil and every exec-backed tenant goes // blind on the Instances page. "Delegate=cpu memory io pids", // The outer wall (memory-pressure spec §2.2) is now on carlos.slice // (isolated-exec design §4.3). The edge is in the slice, so it still // participates in the ceiling. OOMPolicy=continue is the line that // keeps one tenant's OOM kill from becoming a whole-edge restart // (systemd's default is stop). "Slice=carlos.slice", "OOMPolicy=continue", } { if !strings.Contains(string(edge), want) { t.Errorf("carlos-edge.service: missing %q", want) } } // The outer wall moved from the edge to carlos.slice (isolated-exec // design §4.3): the slice bounds the edge and every awake instance // as before, but instances are now sibling units instead of children. slice, err := os.ReadFile(filepath.Join(unitDir, "carlos.slice")) if err != nil { t.Fatal(err) } for _, want := range []string{ "MemoryHigh=70%", "MemoryMax=80%", } { if !strings.Contains(string(slice), want) { t.Errorf("carlos.slice: missing %q", want) } } // The counterpart of Delegate=: ProtectControlGroups would mount the // cgroup tree read-only and silently un-arm delegation. Its hardening is // retained by ownership (DAC) instead — see the unit file's comment. A // future "hardening cleanup" that re-adds it must fail here, not on the // fleet. if strings.Contains(string(edge), "ProtectControlGroups=") { t.Error("carlos-edge.service: the ProtectControlGroups= directive is deliberately absent (Delegate= needs the subtree writable); do not re-add it") } // The tenant socket unit: systemd owns the listener as carlos so the // edge can connect and tenants can't reach each other (the whole point). sockUnit, err := os.ReadFile(filepath.Join(unitDir, "carlos-app@.socket")) if err != nil { t.Fatal(err) } for _, want := range []string{ "ListenStream=/run/carlos/apps/%i.sock", "SocketUser=carlos", "SocketMode=0600", "WantedBy=sockets.target", } { if !strings.Contains(string(sockUnit), want) { t.Errorf("carlos-app@.socket: missing %q", want) } } // The config fast-convergence relay (issue #44). The invariant worth a // test is WHICH unit the path starts: carlos-adopt.service also runs // release.Pass and the canary-TTL nag, so aiming a per-config-write // trigger at it would let one app's `carlos env set` cut over another // app's pending release seconds later and nag on every write instead of // hourly. The relay must reach the config half alone. pathUnit, err := os.ReadFile(filepath.Join(unitDir, "carlos-adopt.path")) if err != nil { t.Fatal(err) } for _, want := range []string{ "PathChanged=/data/config-sync-requested", "Unit=carlos-config-sync.service", "WantedBy=multi-user.target", } { if !strings.Contains(string(pathUnit), want) { t.Errorf("carlos-adopt.path: missing %q", want) } } if strings.Contains(string(pathUnit), "Unit=carlos-adopt.service") { t.Error("carlos-adopt.path must not start carlos-adopt.service — that unit adopts releases and nags on expired canaries") } // The release fast-convergence relay, and the same invariant: which unit // the path starts. carlos-adopt.service prints the canary-TTL nag, an // HOURLY visibility feature; this trigger fires on agent request, so // aiming it there would repeat the same expired-canary lines every time a // promotion could not be staged. stagePath, err := os.ReadFile(filepath.Join(unitDir, "carlos-stage.path")) if err != nil { t.Fatal(err) } for _, want := range []string{ "PathChanged=/data/stage-requested", "Unit=carlos-stage.service", "WantedBy=multi-user.target", } { if !strings.Contains(string(stagePath), want) { t.Errorf("carlos-stage.path: missing %q", want) } } if strings.Contains(string(stagePath), "Unit=carlos-adopt.service") { t.Error("carlos-stage.path must not start carlos-adopt.service — that unit nags on expired canaries, and this trigger is per-request") } // The staging pass must run -stage-only, and must NOT be startable by // anything but its path unit. stageSvc, err := os.ReadFile(filepath.Join(unitDir, "carlos-stage.service")) if err != nil { t.Fatal(err) } if !strings.Contains(string(stageSvc), "carlos adopt --stage-only") { t.Error("carlos-stage.service must run `carlos adopt --stage-only` — the full pass would nag per request") } // A SECTION header, not any mention: the unit's own comment explains why // it has no [Install], and a substring match would fail on the // explanation. Same shape as carlos-config-sync.service, which documents // the same choice the same way. for _, line := range strings.Split(string(stageSvc), "\n") { if strings.TrimSpace(line) == "[Install]" { t.Error("carlos-stage.service must have no [Install] section: only carlos-stage.path should ever start it") } } syncUnit, err := os.ReadFile(filepath.Join(unitDir, "carlos-config-sync.service")) if err != nil { t.Fatal(err) } for _, want := range []string{ "ExecStart=/opt/carlos/carlos adopt --config-only", "Type=oneshot", "EnvironmentFile=-/etc/carlos/host.env", // A path-triggered unit must never latch into start-limit-hit after a // burst of config writes and then ignore every later trigger. "StartLimitIntervalSec=0", } { if !strings.Contains(string(syncUnit), want) { t.Errorf("carlos-config-sync.service: missing %q", want) } } // Started only by the path unit: an [Install] section would invite an // operator to enable a oneshot that has nothing to do at boot. Matched // as a whole line, since the unit's own comment names the section. for _, line := range strings.Split(string(syncUnit), "\n") { if strings.TrimSpace(line) == "[Install]" { t.Error("carlos-config-sync.service must have no [Install] section — carlos-adopt.path is its only starter") } } // The standard dirs. for _, d := range []string{ filepath.Join(root, "data"), filepath.Join(root, "opt", "carlos", "versions"), filepath.Join(root, "opt", "carlos", "live"), filepath.Join(root, "etc", "carlos"), filepath.Join(root, "run", "carlos"), filepath.Join(root, "run", "carlos", "apps"), } { fi, err := os.Stat(d) if err != nil { t.Errorf("dir not created: %v", err) continue } if !fi.IsDir() { t.Errorf("%s: not a directory", d) } } // The tmpfiles.d entry that keeps /run/carlos across reboots. tf, err := os.ReadFile(filepath.Join(root, "etc", "tmpfiles.d", "carlos.conf")) if err != nil { t.Fatalf("tmpfiles conf: %v", err) } if !strings.Contains(string(tf), "/run/carlos") { t.Errorf("tmpfiles conf %q does not mention /run/carlos", tf) } if !strings.Contains(string(tf), "/run/carlos/apps") { t.Errorf("tmpfiles conf %q does not keep the tenant socket dir", tf) } } // TestResolveStatusBucketDefaultsToDeploymentBucket pins the I2 fix: the // status loop and the deployment (release-adoption) loop must agree on which // bucket to serve status out of by default, since the edge/console read // status/ from the deployment bucket — an explicit CARLOS_STATUS_BUCKET // still wins when set, and a CARLOS_DEPLOYMENT_DIR-only deployment (no S3 // bucket to fall back to) gets no default at all. func TestResolveStatusBucketDefaultsToDeploymentBucket(t *testing.T) { env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } cases := []struct { name string env map[string]string want string }{ {"explicit status bucket wins", map[string]string{ "CARLOS_STATUS_BUCKET": "status-bucket", "CARLOS_DEPLOYMENT_BUCKET": "deploy-bucket"}, "status-bucket"}, {"falls back to deployment bucket when status unset", map[string]string{ "CARLOS_DEPLOYMENT_BUCKET": "deploy-bucket"}, "deploy-bucket"}, {"neither set is still neither set", map[string]string{}, ""}, {"deployment dir gives no fallback (S3-only)", map[string]string{ "CARLOS_DEPLOYMENT_DIR": "/data/deploy"}, ""}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := resolveStatusBucket(env(c.env)); got != c.want { t.Errorf("resolveStatusBucket(%v) = %q, want %q", c.env, got, c.want) } }) } } // ---- Task 8: statusTick — the loop's pure part ----------------------------- // TestStatusTickWritesBoxAndPublishesOnlyWhenAsked is the plan's Step-1 test: // statusTick against a DirStore + fixture registry must always land the // box's own snapshot under status/boxes/, but only land the merged // status/fleet.json (+ the embedded page assets PublishFleet ships beside // it) when publish=true — publish=false is the "another box holds the // lease" tick, which must still self-report without touching the fleet-wide // objects. func TestStatusTickWritesBoxAndPublishesOnlyWhenAsked(t *testing.T) { reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } if err := reg.Upsert(registry.Route{Host: "a.example.com", Kind: "static"}); err != nil { t.Fatal(err) } store := release.NewDirStore(t.TempDir()) probes := status.Probes{MachineID: "test-machine"} ctx := context.Background() if err := statusTick(ctx, store, reg, probes, false, ""); err != nil { t.Fatalf("statusTick (publish=false): %v", err) } boxes, err := store.List(ctx, "status/boxes/") if err != nil { t.Fatalf("List status/boxes/: %v", err) } if len(boxes) != 1 { t.Fatalf("status/boxes/ = %v, want exactly one snapshot", boxes) } if _, _, err := store.Get(ctx, "status/fleet.json"); err == nil { t.Error("publish=false must not write status/fleet.json") } if err := statusTick(ctx, store, reg, probes, true, ""); err != nil { t.Fatalf("statusTick (publish=true): %v", err) } for _, key := range []string{"status/fleet.json", "status/index.html", "status/status.css"} { if _, _, err := store.Get(ctx, key); err != nil { t.Errorf("publish=true must write %s: %v", key, err) } } // The box snapshot count must stay at one: the second tick republishes // the SAME box's key (WriteBox is last-writer-wins on the label), not a // second file. boxes, err = store.List(ctx, "status/boxes/") if err != nil { t.Fatalf("List status/boxes/ after publish tick: %v", err) } if len(boxes) != 1 { t.Errorf("status/boxes/ = %v after second tick, want still exactly one snapshot", boxes) } } // ---- Task 5 (customer-fleets plan): publisher guard + fleet-prefixed store - // TestShouldSkipPublisherElection pins the CARLOS_FLEET contract: a fleet // box (CARLOS_FLEET set to anything non-empty) must skip the publisher // election, and a platform box (unset, the every-deployment-today case) // must not. statusLoop checks this BEFORE calling lease.Acquire — see the // call site — so this also documents that the decision needs no lease, no // bucket, no network at all: it's a pure function of one env var. func TestShouldSkipPublisherElection(t *testing.T) { env := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } } cases := []struct { name string env map[string]string want bool }{ {"fleet box skips the election", map[string]string{"CARLOS_FLEET": "pi"}, true}, {"platform box still elects", map[string]string{}, false}, {"empty CARLOS_FLEET is not set", map[string]string{"CARLOS_FLEET": ""}, false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { if got := shouldSkipPublisherElection(env(c.env)); got != c.want { t.Errorf("shouldSkipPublisherElection(%v) = %v, want %v", c.env, got, c.want) } }) } } // TestFleetBoxHeartbeatLandsUnderFleetPrefixNeverPublishes is the end-to-end // pin for Task 5's whole contract, composing release.NewPrefixStore (whose // own contract is pinned in isolation by internal/release's // TestNewPrefixStoreRebasesKeys) with statusTick: a fleet box's tick (store // wrapped in CARLOS_STATUS_PREFIX="fleets/pi/", publish forced false the way // shouldSkipPublisherElection drives statusLoop) writes ITS OWN Box // snapshot at "fleets/pi/status/boxes/