carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1//go:build linux
2
3package host
4
5import (
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
22const (
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.
31func 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.
51func 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
56type 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.
67func 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
105func allWindowUnits() map[string]bool {
106 m := map[string]bool{}
107 for _, u := range claimWindowUnits {
108 m[u] = true
109 }
110 return m
111}
112
113func (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).
143func 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.
175func 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.
185func 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.
199func 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.
216func 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.
289func 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.)
314func 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.
336func 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
375func 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
383func 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
390func 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