//go:build linux package host import ( "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) const ( leoClaimtrial = "claimtrial.dev.carlosframework.com" leoWakecheck = "wakecheck.dev.carlosframework.com" leoRollcheck = "rollcheck-0261.dev.carlosframework.com" ) // leoRecords are dev's three bucket records at step 8 (bab/claimtrial, // bab/rollcheck-0261, bab/wakecheck), made by the production record // constructor. Only claimtrial is routed on leo. func leoRecords(t *testing.T) []hostops.Record { t.Helper() var out []hostops.Record for _, rec := range []provision.Record{ {Schema: 1, Account: "bab", App: "claimtrial", Host: leoClaimtrial, Slug: "claimtrial", Channel: "edge", Pool: "cohort1", Acquisition: "acq-claimtrial"}, {Schema: 1, Account: "bab", App: "rollcheck-0261", Host: leoRollcheck, Slug: "rollcheck-0261", Channel: "edge", Acquisition: "acq-rollcheck"}, {Schema: 1, Account: "bab", App: "wakecheck", Host: leoWakecheck, Slug: "wakecheck", Channel: "edge", Acquisition: "acq-wakecheck"}, } { r, err := hostops.PrepareInstance(rec, time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC), "ie") if err != nil { t.Fatal(err) } out = append(out, r) } return out } // leoRoute is leo's one registry row (leo-routes.tsv): claimtrial, an // instance with no backing and no unit, hibernating, nested, running the // version its live link names. func leoRoute(dataDir string) registry.Route { return registry.Route{Host: leoClaimtrial, Kind: "instance", App: "claimtrial", Account: "bab", Slug: "claimtrial", Socket: provision.NestedSocket(leoClaimtrial), DBPath: provision.NestedDBPath(dataDir, leoClaimtrial), Version: "wc4", Hibernate: true, Provisioned: true, Pool: "cohort1", Channel: "edge", Status: "ready"} } type leoCensusTree struct { r ClaimRuntimeResources roots map[string]string snap rootMigrationSnapshot } // newLeoCensusTree rebuilds leo's /data and /opt/carlos, and the carlos part // of /etc/systemd/system, from the snapshot with step 8.1's masks applied. // Vendor unit links are left out: the census trusts only root-owned links // into the vendor unit directories, which an unprivileged test cannot make. // The root-runner witness builds them. func newLeoCensusTree(t *testing.T) *leoCensusTree { t.Helper() base := t.TempDir() roots := map[string]string{"data": filepath.Join(base, "data"), "opt": filepath.Join(base, "opt"), "units": filepath.Join(base, "units")} for _, dir := range roots { if err := os.Mkdir(dir, 0o755); err != nil { t.Fatal(err) } } buildLeoTree(t, leoTreeRows(t, true), leoTreeOptions{ Roots: map[string]string{"/data": roots["data"], "/opt/carlos": roots["opt"], "/etc/systemd/system": roots["units"]}, Skip: func(r leoRow) bool { return r.Root == "/etc/systemd/system" && r.Type == "l" && (strings.HasPrefix(r.Target, "/usr/lib/") || strings.HasPrefix(r.Target, "/lib/")) }, }) reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { reg.Close() }) if err := reg.Upsert(leoRoute(roots["data"])); err != nil { t.Fatal(err) } c := &leoCensusTree{roots: roots, r: ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: roots["data"], OptDir: roots["opt"], UnitsDir: roots["units"]}} for _, r := range leoRecords(t) { var rec provision.Record if err := json.Unmarshal(r.Body, &rec); err != nil { t.Fatal(err) } c.snap.Records = append(c.snap.Records, rec) if r.Holder.Host == leoClaimtrial { inc, _ := r.Incarnation() c.snap.Report.Installations = append(c.snap.Report.Installations, hostops.CensusInstallation{Host: leoClaimtrial, Account: "bab", App: "claimtrial", Family: "instance", Key: r.Holder.Key, Incarnation: inc, Route: true}) } } return c } func allWindowUnits() map[string]bool { m := map[string]bool{} for _, u := range claimWindowUnits { m[u] = true } return m } func (c *leoCensusTree) check(t *testing.T, masked map[string]bool) error { t.Helper() shared, err := claimMigrationSharedPaths(c.r, masked, c.snap) if err != nil { return err } return hostbox.CheckMigrationRoots(t.Context(), c.roots, shared, nil) } // R2: the migration census accepts leo's real tree under the window's masks, // and only with every class in place. Each class covers these snapshot rows: // // (a) the window's masks: the twelve /etc/systemd/system rows of // leo-units-post-8.1.tsv (carlos-adopt.timer ... carlos-edge.service); // (b) /data/acme: 17 certificate and key files (platform, tunnel, proof, // proof2, website, helloworld, appsdomain-check, rollcheck-0261, // claimtrial, wakecheck, the ACME account key); // (c) box files: stage-requested, system-update-requested, // system/router/requested.json, restart-marks.json and its .lock, // adopt-pass.lock, swap-failures.json, channel-first-seen.json, // zdd/generations.json and .seq, carlos/geoip/current, // monitor/alerts.json; // (d) claimtrial's route installation: instances/ and .instances/ // claimtrial.dev.carlosframework.com (6 files each), // programs/claimtrial…json and /opt/carlos/live/claimtrial… (into // versions/wc4/claimtrial, the version its row names); // (e) wakecheck's retained database (route removed, record frozen): // instances/ and .instances/wakecheck.dev.carlosframework.com (8 files // each), programs/wakecheck…json and /opt/carlos/live/wakecheck… (into // versions/wc4/wakecheck). func TestMigrationCensusClassifiesLeosTree(t *testing.T) { c := newLeoCensusTree(t) if err := c.check(t, allWindowUnits()); err != nil { t.Fatalf("leo's tree under the window's masks: %v", err) } // Without the window, the masks refuse, as they always have. if err := c.check(t, nil); !errors.Is(err, hostbox.ErrTypedInventory) || !strings.Contains(err.Error(), "units entry") { t.Fatalf("masks without --window = %v", err) } // Without claimtrial's reported route, its files refuse. reported := c.snap.Report.Installations c.snap.Report.Installations = nil if err := c.check(t, allWindowUnits()); !errors.Is(err, errRootMigrationBusy) && !errors.Is(err, hostbox.ErrTypedInventory) { t.Fatalf("claimtrial unreported = %v", err) } c.snap.Report.Installations = reported // Without wakecheck's frozen record, its leftovers refuse. records := c.snap.Records c.snap.Records = nil for _, rec := range records { if rec.Host != leoWakecheck { c.snap.Records = append(c.snap.Records, rec) } } if err := c.check(t, allWindowUnits()); !errors.Is(err, hostbox.ErrTypedInventory) || !strings.Contains(err.Error(), "wakecheck") { t.Fatalf("wakecheck without its record = %v", err) } } // The harness's 8.2 stops (2026-10-20): both pointer-digest ratchets and // install-control's lock, which 8.2 leaves in the unit directory before // journal-migrate runs, are box state the census keeps. func TestMigrationCensusKeepsRatchetsAndActivationLock(t *testing.T) { c := newLeoCensusTree(t) leoRatchetMarks(t, c) if err := c.check(t, allWindowUnits()); err != nil { t.Fatalf("ratchets and activation lock: %v", err) } } // leoRatchetMarks adds claimtrial's marks to root's and the edge's // ratchets, and install-control's lock to the unit directory. func leoRatchetMarks(t *testing.T, c *leoCensusTree) { t.Helper() for _, dir := range []string{release.RootRatchetDir(c.roots["opt"]), release.EdgeRatchetDir(c.roots["data"])} { app := filepath.Join(dir, "bab", "claimtrial") if err := os.MkdirAll(app, 0o755); err != nil { t.Fatal(err) } leoWrite(t, filepath.Join(app, "edge.digests")) } leoWrite(t, filepath.Join(c.roots["units"], activationLockName)) } // A normal update leaves the agent's router cache until its next hourly // observe (harness G10 re-run, 8.2, 2026-10-20): the census keeps it. func TestMigrationCensusKeepsRouterCache(t *testing.T) { c := newLeoCensusTree(t) cache := systemCachePath(c.roots["data"], "v0.28.15") if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil { t.Fatal(err) } leoWrite(t, cache) if err := c.check(t, allWindowUnits()); err != nil { t.Fatalf("router cache: %v", err) } leoReplaceWithLink(t, cache, "/etc/passwd") if err := c.check(t, allWindowUnits()); !errors.Is(err, hostbox.ErrTypedInventory) { t.Fatalf("a symlinked router cache = %v, want a refusal", err) } } // Each class is exact: one entry outside it refuses, naming the entry. func TestMigrationCensusRefusesOutsideLeosClasses(t *testing.T) { for name, mutate := range map[string]func(*testing.T, *leoCensusTree){ "a mask on a unit the window does not mask": func(t *testing.T, c *leoCensusTree) { leoReplaceWithLink(t, filepath.Join(c.roots["units"], "carlos-relay.service"), "/dev/null") }, "a window unit linked elsewhere": func(t *testing.T, c *leoCensusTree) { leoReplaceWithLink(t, filepath.Join(c.roots["units"], "carlos-edge.service"), "/etc/systemd/system/carlos-relay.service") }, "a symlink in claimtrial's instance directory": func(t *testing.T, c *leoCensusTree) { leoLink(t, "/etc/passwd", filepath.Join(c.roots["data"], ".instances", leoClaimtrial, "escape")) }, "a symlink in the certificate cache": func(t *testing.T, c *leoCensusTree) { leoLink(t, "/etc/passwd", filepath.Join(c.roots["data"], "acme", "escape")) }, "a symlink in root's ratchet": func(t *testing.T, c *leoCensusTree) { leoRatchetMarks(t, c) leoReplaceWithLink(t, filepath.Join(release.RootRatchetDir(c.roots["opt"]), "bab", "claimtrial", "edge.digests"), "/etc/passwd") }, "a symlink in the edge's ratchet": func(t *testing.T, c *leoCensusTree) { leoRatchetMarks(t, c) leoReplaceWithLink(t, filepath.Join(release.EdgeRatchetDir(c.roots["data"]), "bab", "claimtrial", "edge.digests"), "/etc/passwd") }, "the activation lock replaced by a link": func(t *testing.T, c *leoCensusTree) { leoRatchetMarks(t, c) leoReplaceWithLink(t, filepath.Join(c.roots["units"], activationLockName), "/etc/passwd") }, "an activation lock in a drop-in directory": func(t *testing.T, c *leoCensusTree) { dir := filepath.Join(c.roots["units"], "carlos-edge.service.d") if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } leoWrite(t, filepath.Join(dir, activationLockName)) }, "an unknown box file": func(t *testing.T, c *leoCensusTree) { leoWrite(t, filepath.Join(c.roots["data"], "new-marker")) }, "a box file replaced by a link": func(t *testing.T, c *leoCensusTree) { leoReplaceWithLink(t, filepath.Join(c.roots["data"], "adopt-pass.lock"), "/etc/passwd") }, "claimtrial's live link on another version": func(t *testing.T, c *leoCensusTree) { leoReplaceWithLink(t, filepath.Join(c.roots["opt"], "live", leoClaimtrial), filepath.Join(c.roots["opt"], "versions", "wc5", "claimtrial")) }, "wakecheck's live link into another app": func(t *testing.T, c *leoCensusTree) { leoReplaceWithLink(t, filepath.Join(c.roots["opt"], "live", leoWakecheck), filepath.Join(c.roots["opt"], "versions", "wc4", "claimtrial")) }, "a live link for a host with no record": func(t *testing.T, c *leoCensusTree) { leoLink(t, filepath.Join(c.roots["opt"], "versions", "wc4", "claimtrial"), filepath.Join(c.roots["opt"], "live", "other.example.com")) }, "a program file for a host with no record": func(t *testing.T, c *leoCensusTree) { leoWrite(t, filepath.Join(c.roots["data"], "programs", "other.example.com.json")) }, "an instance directory for a host with no record": func(t *testing.T, c *leoCensusTree) { dir := filepath.Join(c.roots["data"], ".instances", "other.example.com") if err := os.Mkdir(dir, 0o700); err != nil { t.Fatal(err) } leoWrite(t, filepath.Join(dir, "other.example.com.db")) }, } { t.Run(name, func(t *testing.T) { c := newLeoCensusTree(t) mutate(t, c) err := c.check(t, allWindowUnits()) if !errors.Is(err, hostbox.ErrTypedInventory) && !errors.Is(err, errRootMigrationBusy) { t.Fatalf("census = %v, want a refusal", err) } t.Logf("refused: %v", err) }) } } // A route row the census cannot report refuses before any walk: claimtrial // pointing at another version, a row whose record is not frozen. func TestMigrationCensusRefusesAMovedRoute(t *testing.T) { c := newLeoCensusTree(t) row := leoRoute(c.r.DataDir) row.Version = "" if err := c.r.Registry.Upsert(row); err != nil { t.Fatal(err) } if err := c.check(t, allWindowUnits()); !errors.Is(err, errRootMigrationBusy) { t.Fatalf("row with no version = %v", err) } c = newLeoCensusTree(t) other := leoRoute(c.r.DataDir) other.Host, other.DBPath = leoWakecheck, provision.NestedDBPath(c.r.DataDir, leoWakecheck) other.App = "wakecheck" if err := c.r.Registry.Upsert(other); err != nil { t.Fatal(err) } if err := c.check(t, allWindowUnits()); !errors.Is(err, errRootMigrationBusy) { t.Fatalf("an unreported row beside retained data = %v", err) } } // --window refuses anything but root's own private directory outside the // unit and data paths. (The accepting path needs root-owned files: the // root-runner witness runs it.) func TestMigrationWindowRefusals(t *testing.T) { c := newLeoCensusTree(t) if m, err := verifyMigrationWindow("", c.r); err != nil || len(m) != 0 { t.Fatalf("no window = %v, %v", m, err) } own := filepath.Join(t.TempDir(), "window") if err := os.MkdirAll(filepath.Join(own, "held"), 0o700); err != nil { t.Fatal(err) } for _, w := range []string{"relative/window", own + "/", "/etc/systemd/system/window", filepath.Join(c.r.UnitsDir, "w"), filepath.Join(c.r.DataDir, "w"), c.r.OptDir, own} { if _, err := verifyMigrationWindow(w, c.r); !errors.Is(err, errMigrationWindow) { t.Fatalf("window %q = %v", w, err) } } } // Ruling 6's other half: the claims-on per-host census, which root runs // before a claim's first inventory, still refuses claimtrial on leo once it // is adopted. Its legacy route row and files are not an empty installation, // and the window's masks are not unit files. Nothing on main installs an // adopted legacy host (populated backfill), so the files seed cannot admit // it: step 8 STOP S4. func TestPerHostCensusRefusesAdoptedLegacyHost(t *testing.T) { c := newLeoCensusTree(t) journal, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "journals.db")) if err != nil { t.Fatal(err) } t.Cleanup(func() { journal.Close() }) service, err := hostbox.NewJournalService(journal) if err != nil { t.Fatal(err) } census := hostbox.EmptyInstallationCensus{Inventory: service.Inventory, Registry: c.r.Registry, Launches: journal, Box: "leo-climbing", Enrollment: strings.Repeat("a", 32), DirectoryRevision: "9", Roots: c.roots, SharedPaths: claimGlobalSharedPaths(c.r)} var record hostops.Record for _, r := range leoRecords(t) { if r.Holder.Host == leoClaimtrial { record = r } } n := record.Holder claim := &hostclaim.Claim{Host: leoClaimtrial} if err := hostclaim.Adopt(n, "leo-climbing", nil, []string{"sophie-knitting"}, "op", func() string { return "id" })(claim); err != nil { t.Fatal(err) } obs := hostclaim.Observed{Host: leoClaimtrial, Claim: claim, ETag: "etag"} inc, _ := record.Incarnation() err = census.CheckLocal(t.Context(), obs, hostbox.RecordSelection{Host: leoClaimtrial, Key: record.Holder.Key, Incarnation: inc, Family: "instance"}) if !errors.Is(err, hostbox.ErrTypedInventory) { t.Fatalf("per-host census for the adopted legacy host = %v, want ErrTypedInventory", err) } // With the legacy row gone, it still refuses claimtrial's own files. if err := c.r.Registry.Delete(leoClaimtrial); err != nil { t.Fatal(err) } if err = census.CheckLocal(t.Context(), obs, hostbox.RecordSelection{Host: leoClaimtrial, Key: record.Holder.Key, Incarnation: inc, Family: "instance"}); !errors.Is(err, hostbox.ErrTypedInventory) { t.Fatalf("per-host census over claimtrial's files = %v", err) } } func leoReplaceWithLink(t *testing.T, path, target string) { t.Helper() if err := os.Remove(path); err != nil { t.Fatal(err) } leoLink(t, target, path) } func leoLink(t *testing.T, target, path string) { t.Helper() if err := os.Symlink(target, path); err != nil { t.Fatal(err) } } func leoWrite(t *testing.T, path string) { t.Helper() if err := os.WriteFile(path, nil, 0o644); err != nil { t.Fatal(err) } }