| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "os" |
| 9 | "path/filepath" |
| 10 | "strings" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostbox" |
| 15 | "github.com/carlosframework/platform/internal/hostclaim" |
| 16 | "github.com/carlosframework/platform/internal/hostops" |
| 17 | "github.com/carlosframework/platform/internal/provision" |
| 18 | "github.com/carlosframework/platform/internal/registry" |
| 19 | "github.com/carlosframework/platform/internal/release" |
| 20 | ) |
| 21 | |
| 22 | const ( |
| 23 | leoClaimtrial = "claimtrial.dev.carlosframework.com" |
| 24 | leoWakecheck = "wakecheck.dev.carlosframework.com" |
| 25 | leoRollcheck = "rollcheck-0261.dev.carlosframework.com" |
| 26 | ) |
| 27 | |
| 28 | // leoRecords are dev's three bucket records at step 8 (bab/claimtrial, |
| 29 | // bab/rollcheck-0261, bab/wakecheck), made by the production record |
| 30 | // constructor. Only claimtrial is routed on leo. |
| 31 | func leoRecords(t *testing.T) []hostops.Record { |
| 32 | t.Helper() |
| 33 | var out []hostops.Record |
| 34 | for _, rec := range []provision.Record{ |
| 35 | {Schema: 1, Account: "bab", App: "claimtrial", Host: leoClaimtrial, Slug: "claimtrial", Channel: "edge", Pool: "cohort1", Acquisition: "acq-claimtrial"}, |
| 36 | {Schema: 1, Account: "bab", App: "rollcheck-0261", Host: leoRollcheck, Slug: "rollcheck-0261", Channel: "edge", Acquisition: "acq-rollcheck"}, |
| 37 | {Schema: 1, Account: "bab", App: "wakecheck", Host: leoWakecheck, Slug: "wakecheck", Channel: "edge", Acquisition: "acq-wakecheck"}, |
| 38 | } { |
| 39 | r, err := hostops.PrepareInstance(rec, time.Date(2026, 10, 18, 9, 0, 0, 0, time.UTC), "ie") |
| 40 | if err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | out = append(out, r) |
| 44 | } |
| 45 | return out |
| 46 | } |
| 47 | |
| 48 | // leoRoute is leo's one registry row (leo-routes.tsv): claimtrial, an |
| 49 | // instance with no backing and no unit, hibernating, nested, running the |
| 50 | // version its live link names. |
| 51 | func leoRoute(dataDir string) registry.Route { |
| 52 | return registry.Route{Host: leoClaimtrial, Kind: "instance", App: "claimtrial", Account: "bab", Slug: "claimtrial", Socket: provision.NestedSocket(leoClaimtrial), |
| 53 | DBPath: provision.NestedDBPath(dataDir, leoClaimtrial), Version: "wc4", Hibernate: true, Provisioned: true, Pool: "cohort1", Channel: "edge", Status: "ready"} |
| 54 | } |
| 55 | |
| 56 | type leoCensusTree struct { |
| 57 | r ClaimRuntimeResources |
| 58 | roots map[string]string |
| 59 | snap rootMigrationSnapshot |
| 60 | } |
| 61 | |
| 62 | // newLeoCensusTree rebuilds leo's /data and /opt/carlos, and the carlos part |
| 63 | // of /etc/systemd/system, from the snapshot with step 8.1's masks applied. |
| 64 | // Vendor unit links are left out: the census trusts only root-owned links |
| 65 | // into the vendor unit directories, which an unprivileged test cannot make. |
| 66 | // The root-runner witness builds them. |
| 67 | func newLeoCensusTree(t *testing.T) *leoCensusTree { |
| 68 | t.Helper() |
| 69 | base := t.TempDir() |
| 70 | roots := map[string]string{"data": filepath.Join(base, "data"), "opt": filepath.Join(base, "opt"), "units": filepath.Join(base, "units")} |
| 71 | for _, dir := range roots { |
| 72 | if err := os.Mkdir(dir, 0o755); err != nil { |
| 73 | t.Fatal(err) |
| 74 | } |
| 75 | } |
| 76 | buildLeoTree(t, leoTreeRows(t, true), leoTreeOptions{ |
| 77 | Roots: map[string]string{"/data": roots["data"], "/opt/carlos": roots["opt"], "/etc/systemd/system": roots["units"]}, |
| 78 | Skip: func(r leoRow) bool { |
| 79 | return r.Root == "/etc/systemd/system" && r.Type == "l" && (strings.HasPrefix(r.Target, "/usr/lib/") || strings.HasPrefix(r.Target, "/lib/")) |
| 80 | }, |
| 81 | }) |
| 82 | reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| 83 | if err != nil { |
| 84 | t.Fatal(err) |
| 85 | } |
| 86 | t.Cleanup(func() { reg.Close() }) |
| 87 | if err := reg.Upsert(leoRoute(roots["data"])); err != nil { |
| 88 | t.Fatal(err) |
| 89 | } |
| 90 | c := &leoCensusTree{roots: roots, r: ClaimRuntimeResources{Mode: "instance-control", Registry: reg, DataDir: roots["data"], OptDir: roots["opt"], UnitsDir: roots["units"]}} |
| 91 | for _, r := range leoRecords(t) { |
| 92 | var rec provision.Record |
| 93 | if err := json.Unmarshal(r.Body, &rec); err != nil { |
| 94 | t.Fatal(err) |
| 95 | } |
| 96 | c.snap.Records = append(c.snap.Records, rec) |
| 97 | if r.Holder.Host == leoClaimtrial { |
| 98 | inc, _ := r.Incarnation() |
| 99 | 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}) |
| 100 | } |
| 101 | } |
| 102 | return c |
| 103 | } |
| 104 | |
| 105 | func allWindowUnits() map[string]bool { |
| 106 | m := map[string]bool{} |
| 107 | for _, u := range claimWindowUnits { |
| 108 | m[u] = true |
| 109 | } |
| 110 | return m |
| 111 | } |
| 112 | |
| 113 | func (c *leoCensusTree) check(t *testing.T, masked map[string]bool) error { |
| 114 | t.Helper() |
| 115 | shared, err := claimMigrationSharedPaths(c.r, masked, c.snap) |
| 116 | if err != nil { |
| 117 | return err |
| 118 | } |
| 119 | return hostbox.CheckMigrationRoots(t.Context(), c.roots, shared, nil) |
| 120 | } |
| 121 | |
| 122 | // R2: the migration census accepts leo's real tree under the window's masks, |
| 123 | // and only with every class in place. Each class covers these snapshot rows: |
| 124 | // |
| 125 | // (a) the window's masks: the twelve /etc/systemd/system rows of |
| 126 | // leo-units-post-8.1.tsv (carlos-adopt.timer ... carlos-edge.service); |
| 127 | // (b) /data/acme: 17 certificate and key files (platform, tunnel, proof, |
| 128 | // proof2, website, helloworld, appsdomain-check, rollcheck-0261, |
| 129 | // claimtrial, wakecheck, the ACME account key); |
| 130 | // (c) box files: stage-requested, system-update-requested, |
| 131 | // system/router/requested.json, restart-marks.json and its .lock, |
| 132 | // adopt-pass.lock, swap-failures.json, channel-first-seen.json, |
| 133 | // zdd/generations.json and .seq, carlos/geoip/current, |
| 134 | // monitor/alerts.json; |
| 135 | // (d) claimtrial's route installation: instances/ and .instances/ |
| 136 | // claimtrial.dev.carlosframework.com (6 files each), |
| 137 | // programs/claimtrial…json and /opt/carlos/live/claimtrial… (into |
| 138 | // versions/wc4/claimtrial, the version its row names); |
| 139 | // (e) wakecheck's retained database (route removed, record frozen): |
| 140 | // instances/ and .instances/wakecheck.dev.carlosframework.com (8 files |
| 141 | // each), programs/wakecheck…json and /opt/carlos/live/wakecheck… (into |
| 142 | // versions/wc4/wakecheck). |
| 143 | func TestMigrationCensusClassifiesLeosTree(t *testing.T) { |
| 144 | c := newLeoCensusTree(t) |
| 145 | if err := c.check(t, allWindowUnits()); err != nil { |
| 146 | t.Fatalf("leo's tree under the window's masks: %v", err) |
| 147 | } |
| 148 | // Without the window, the masks refuse, as they always have. |
| 149 | if err := c.check(t, nil); !errors.Is(err, hostbox.ErrTypedInventory) || !strings.Contains(err.Error(), "units entry") { |
| 150 | t.Fatalf("masks without --window = %v", err) |
| 151 | } |
| 152 | // Without claimtrial's reported route, its files refuse. |
| 153 | reported := c.snap.Report.Installations |
| 154 | c.snap.Report.Installations = nil |
| 155 | if err := c.check(t, allWindowUnits()); !errors.Is(err, errRootMigrationBusy) && !errors.Is(err, hostbox.ErrTypedInventory) { |
| 156 | t.Fatalf("claimtrial unreported = %v", err) |
| 157 | } |
| 158 | c.snap.Report.Installations = reported |
| 159 | // Without wakecheck's frozen record, its leftovers refuse. |
| 160 | records := c.snap.Records |
| 161 | c.snap.Records = nil |
| 162 | for _, rec := range records { |
| 163 | if rec.Host != leoWakecheck { |
| 164 | c.snap.Records = append(c.snap.Records, rec) |
| 165 | } |
| 166 | } |
| 167 | if err := c.check(t, allWindowUnits()); !errors.Is(err, hostbox.ErrTypedInventory) || !strings.Contains(err.Error(), "wakecheck") { |
| 168 | t.Fatalf("wakecheck without its record = %v", err) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // The harness's 8.2 stops (2026-10-20): both pointer-digest ratchets and |
| 173 | // install-control's lock, which 8.2 leaves in the unit directory before |
| 174 | // journal-migrate runs, are box state the census keeps. |
| 175 | func TestMigrationCensusKeepsRatchetsAndActivationLock(t *testing.T) { |
| 176 | c := newLeoCensusTree(t) |
| 177 | leoRatchetMarks(t, c) |
| 178 | if err := c.check(t, allWindowUnits()); err != nil { |
| 179 | t.Fatalf("ratchets and activation lock: %v", err) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | // leoRatchetMarks adds claimtrial's marks to root's and the edge's |
| 184 | // ratchets, and install-control's lock to the unit directory. |
| 185 | func leoRatchetMarks(t *testing.T, c *leoCensusTree) { |
| 186 | t.Helper() |
| 187 | for _, dir := range []string{release.RootRatchetDir(c.roots["opt"]), release.EdgeRatchetDir(c.roots["data"])} { |
| 188 | app := filepath.Join(dir, "bab", "claimtrial") |
| 189 | if err := os.MkdirAll(app, 0o755); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | leoWrite(t, filepath.Join(app, "edge.digests")) |
| 193 | } |
| 194 | leoWrite(t, filepath.Join(c.roots["units"], activationLockName)) |
| 195 | } |
| 196 | |
| 197 | // A normal update leaves the agent's router cache until its next hourly |
| 198 | // observe (harness G10 re-run, 8.2, 2026-10-20): the census keeps it. |
| 199 | func TestMigrationCensusKeepsRouterCache(t *testing.T) { |
| 200 | c := newLeoCensusTree(t) |
| 201 | cache := systemCachePath(c.roots["data"], "v0.28.15") |
| 202 | if err := os.MkdirAll(filepath.Dir(cache), 0o755); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | leoWrite(t, cache) |
| 206 | if err := c.check(t, allWindowUnits()); err != nil { |
| 207 | t.Fatalf("router cache: %v", err) |
| 208 | } |
| 209 | leoReplaceWithLink(t, cache, "/etc/passwd") |
| 210 | if err := c.check(t, allWindowUnits()); !errors.Is(err, hostbox.ErrTypedInventory) { |
| 211 | t.Fatalf("a symlinked router cache = %v, want a refusal", err) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | // Each class is exact: one entry outside it refuses, naming the entry. |
| 216 | func TestMigrationCensusRefusesOutsideLeosClasses(t *testing.T) { |
| 217 | for name, mutate := range map[string]func(*testing.T, *leoCensusTree){ |
| 218 | "a mask on a unit the window does not mask": func(t *testing.T, c *leoCensusTree) { |
| 219 | leoReplaceWithLink(t, filepath.Join(c.roots["units"], "carlos-relay.service"), "/dev/null") |
| 220 | }, |
| 221 | "a window unit linked elsewhere": func(t *testing.T, c *leoCensusTree) { |
| 222 | leoReplaceWithLink(t, filepath.Join(c.roots["units"], "carlos-edge.service"), "/etc/systemd/system/carlos-relay.service") |
| 223 | }, |
| 224 | "a symlink in claimtrial's instance directory": func(t *testing.T, c *leoCensusTree) { |
| 225 | leoLink(t, "/etc/passwd", filepath.Join(c.roots["data"], ".instances", leoClaimtrial, "escape")) |
| 226 | }, |
| 227 | "a symlink in the certificate cache": func(t *testing.T, c *leoCensusTree) { |
| 228 | leoLink(t, "/etc/passwd", filepath.Join(c.roots["data"], "acme", "escape")) |
| 229 | }, |
| 230 | "a symlink in root's ratchet": func(t *testing.T, c *leoCensusTree) { |
| 231 | leoRatchetMarks(t, c) |
| 232 | leoReplaceWithLink(t, filepath.Join(release.RootRatchetDir(c.roots["opt"]), "bab", "claimtrial", "edge.digests"), "/etc/passwd") |
| 233 | }, |
| 234 | "a symlink in the edge's ratchet": func(t *testing.T, c *leoCensusTree) { |
| 235 | leoRatchetMarks(t, c) |
| 236 | leoReplaceWithLink(t, filepath.Join(release.EdgeRatchetDir(c.roots["data"]), "bab", "claimtrial", "edge.digests"), "/etc/passwd") |
| 237 | }, |
| 238 | "the activation lock replaced by a link": func(t *testing.T, c *leoCensusTree) { |
| 239 | leoRatchetMarks(t, c) |
| 240 | leoReplaceWithLink(t, filepath.Join(c.roots["units"], activationLockName), "/etc/passwd") |
| 241 | }, |
| 242 | "an activation lock in a drop-in directory": func(t *testing.T, c *leoCensusTree) { |
| 243 | dir := filepath.Join(c.roots["units"], "carlos-edge.service.d") |
| 244 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | leoWrite(t, filepath.Join(dir, activationLockName)) |
| 248 | }, |
| 249 | "an unknown box file": func(t *testing.T, c *leoCensusTree) { |
| 250 | leoWrite(t, filepath.Join(c.roots["data"], "new-marker")) |
| 251 | }, |
| 252 | "a box file replaced by a link": func(t *testing.T, c *leoCensusTree) { |
| 253 | leoReplaceWithLink(t, filepath.Join(c.roots["data"], "adopt-pass.lock"), "/etc/passwd") |
| 254 | }, |
| 255 | "claimtrial's live link on another version": func(t *testing.T, c *leoCensusTree) { |
| 256 | leoReplaceWithLink(t, filepath.Join(c.roots["opt"], "live", leoClaimtrial), filepath.Join(c.roots["opt"], "versions", "wc5", "claimtrial")) |
| 257 | }, |
| 258 | "wakecheck's live link into another app": func(t *testing.T, c *leoCensusTree) { |
| 259 | leoReplaceWithLink(t, filepath.Join(c.roots["opt"], "live", leoWakecheck), filepath.Join(c.roots["opt"], "versions", "wc4", "claimtrial")) |
| 260 | }, |
| 261 | "a live link for a host with no record": func(t *testing.T, c *leoCensusTree) { |
| 262 | leoLink(t, filepath.Join(c.roots["opt"], "versions", "wc4", "claimtrial"), filepath.Join(c.roots["opt"], "live", "other.example.com")) |
| 263 | }, |
| 264 | "a program file for a host with no record": func(t *testing.T, c *leoCensusTree) { |
| 265 | leoWrite(t, filepath.Join(c.roots["data"], "programs", "other.example.com.json")) |
| 266 | }, |
| 267 | "an instance directory for a host with no record": func(t *testing.T, c *leoCensusTree) { |
| 268 | dir := filepath.Join(c.roots["data"], ".instances", "other.example.com") |
| 269 | if err := os.Mkdir(dir, 0o700); err != nil { |
| 270 | t.Fatal(err) |
| 271 | } |
| 272 | leoWrite(t, filepath.Join(dir, "other.example.com.db")) |
| 273 | }, |
| 274 | } { |
| 275 | t.Run(name, func(t *testing.T) { |
| 276 | c := newLeoCensusTree(t) |
| 277 | mutate(t, c) |
| 278 | err := c.check(t, allWindowUnits()) |
| 279 | if !errors.Is(err, hostbox.ErrTypedInventory) && !errors.Is(err, errRootMigrationBusy) { |
| 280 | t.Fatalf("census = %v, want a refusal", err) |
| 281 | } |
| 282 | t.Logf("refused: %v", err) |
| 283 | }) |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | // A route row the census cannot report refuses before any walk: claimtrial |
| 288 | // pointing at another version, a row whose record is not frozen. |
| 289 | func TestMigrationCensusRefusesAMovedRoute(t *testing.T) { |
| 290 | c := newLeoCensusTree(t) |
| 291 | row := leoRoute(c.r.DataDir) |
| 292 | row.Version = "" |
| 293 | if err := c.r.Registry.Upsert(row); err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | if err := c.check(t, allWindowUnits()); !errors.Is(err, errRootMigrationBusy) { |
| 297 | t.Fatalf("row with no version = %v", err) |
| 298 | } |
| 299 | c = newLeoCensusTree(t) |
| 300 | other := leoRoute(c.r.DataDir) |
| 301 | other.Host, other.DBPath = leoWakecheck, provision.NestedDBPath(c.r.DataDir, leoWakecheck) |
| 302 | other.App = "wakecheck" |
| 303 | if err := c.r.Registry.Upsert(other); err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | if err := c.check(t, allWindowUnits()); !errors.Is(err, errRootMigrationBusy) { |
| 307 | t.Fatalf("an unreported row beside retained data = %v", err) |
| 308 | } |
| 309 | } |
| 310 | |
| 311 | // --window refuses anything but root's own private directory outside the |
| 312 | // unit and data paths. (The accepting path needs root-owned files: the |
| 313 | // root-runner witness runs it.) |
| 314 | func TestMigrationWindowRefusals(t *testing.T) { |
| 315 | c := newLeoCensusTree(t) |
| 316 | if m, err := verifyMigrationWindow("", c.r); err != nil || len(m) != 0 { |
| 317 | t.Fatalf("no window = %v, %v", m, err) |
| 318 | } |
| 319 | own := filepath.Join(t.TempDir(), "window") |
| 320 | if err := os.MkdirAll(filepath.Join(own, "held"), 0o700); err != nil { |
| 321 | t.Fatal(err) |
| 322 | } |
| 323 | 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} { |
| 324 | if _, err := verifyMigrationWindow(w, c.r); !errors.Is(err, errMigrationWindow) { |
| 325 | t.Fatalf("window %q = %v", w, err) |
| 326 | } |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | // Ruling 6's other half: the claims-on per-host census, which root runs |
| 331 | // before a claim's first inventory, still refuses claimtrial on leo once it |
| 332 | // is adopted. Its legacy route row and files are not an empty installation, |
| 333 | // and the window's masks are not unit files. Nothing on main installs an |
| 334 | // adopted legacy host (populated backfill), so the files seed cannot admit |
| 335 | // it: step 8 STOP S4. |
| 336 | func TestPerHostCensusRefusesAdoptedLegacyHost(t *testing.T) { |
| 337 | c := newLeoCensusTree(t) |
| 338 | journal, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "journals.db")) |
| 339 | if err != nil { |
| 340 | t.Fatal(err) |
| 341 | } |
| 342 | t.Cleanup(func() { journal.Close() }) |
| 343 | service, err := hostbox.NewJournalService(journal) |
| 344 | if err != nil { |
| 345 | t.Fatal(err) |
| 346 | } |
| 347 | census := hostbox.EmptyInstallationCensus{Inventory: service.Inventory, Registry: c.r.Registry, Launches: journal, Box: "leo-climbing", Enrollment: strings.Repeat("a", 32), DirectoryRevision: "9", |
| 348 | Roots: c.roots, SharedPaths: claimGlobalSharedPaths(c.r)} |
| 349 | var record hostops.Record |
| 350 | for _, r := range leoRecords(t) { |
| 351 | if r.Holder.Host == leoClaimtrial { |
| 352 | record = r |
| 353 | } |
| 354 | } |
| 355 | n := record.Holder |
| 356 | claim := &hostclaim.Claim{Host: leoClaimtrial} |
| 357 | if err := hostclaim.Adopt(n, "leo-climbing", nil, []string{"sophie-knitting"}, "op", func() string { return "id" })(claim); err != nil { |
| 358 | t.Fatal(err) |
| 359 | } |
| 360 | obs := hostclaim.Observed{Host: leoClaimtrial, Claim: claim, ETag: "etag"} |
| 361 | inc, _ := record.Incarnation() |
| 362 | err = census.CheckLocal(t.Context(), obs, hostbox.RecordSelection{Host: leoClaimtrial, Key: record.Holder.Key, Incarnation: inc, Family: "instance"}) |
| 363 | if !errors.Is(err, hostbox.ErrTypedInventory) { |
| 364 | t.Fatalf("per-host census for the adopted legacy host = %v, want ErrTypedInventory", err) |
| 365 | } |
| 366 | // With the legacy row gone, it still refuses claimtrial's own files. |
| 367 | if err := c.r.Registry.Delete(leoClaimtrial); err != nil { |
| 368 | t.Fatal(err) |
| 369 | } |
| 370 | if err = census.CheckLocal(t.Context(), obs, hostbox.RecordSelection{Host: leoClaimtrial, Key: record.Holder.Key, Incarnation: inc, Family: "instance"}); !errors.Is(err, hostbox.ErrTypedInventory) { |
| 371 | t.Fatalf("per-host census over claimtrial's files = %v", err) |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | func leoReplaceWithLink(t *testing.T, path, target string) { |
| 376 | t.Helper() |
| 377 | if err := os.Remove(path); err != nil { |
| 378 | t.Fatal(err) |
| 379 | } |
| 380 | leoLink(t, target, path) |
| 381 | } |
| 382 | |
| 383 | func leoLink(t *testing.T, target, path string) { |
| 384 | t.Helper() |
| 385 | if err := os.Symlink(target, path); err != nil { |
| 386 | t.Fatal(err) |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | func leoWrite(t *testing.T, path string) { |
| 391 | t.Helper() |
| 392 | if err := os.WriteFile(path, nil, 0o644); err != nil { |
| 393 | t.Fatal(err) |
| 394 | } |
| 395 | } |
| 396 | |