carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "os"
11 "os/exec"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "testing"
16 "time"
17
18 "github.com/carlosframework/platform/internal/moves"
19 "github.com/carlosframework/platform/internal/release"
20 "github.com/carlosframework/platform/internal/seeds"
21)
22
23// putSeedsFloor replaces the seeds floor object in s directly, with no
24// rising check, as an operator's hand or a broken publisher could.
25func putSeedsFloor(t *testing.T, s release.Store, f seeds.Floor) {
26 t.Helper()
27 body, _ := json.Marshal(f)
28 if err := s.Delete(context.Background(), seeds.FloorKey); err != nil && !errors.Is(err, os.ErrNotExist) {
29 t.Fatal(err)
30 }
31 if _, err := s.Put(context.Background(), seeds.FloorKey, body, ""); err != nil {
32 t.Fatal(err)
33 }
34}
35
36// armLocalSeedsFloor writes the box's two local seeds floor files, as a
37// refresh would.
38func armLocalSeedsFloor(t *testing.T, dataDir string, f seeds.Floor) {
39 t.Helper()
40 if err := withFloorLock(dataDir, true, func(d *movesDir) error { return writeSeedsFloorFiles(d, f) }); err != nil {
41 t.Fatal(err)
42 }
43}
44
45// A deployment that never publishes the seeds floor: no refresh, judgement
46// or adopt pass leaves a file behind, so the units and the update path see
47// exactly what they saw before the seeds floor existed.
48func TestSeedsFloorIsDormantWhenNeverPublished(t *testing.T) {
49 ctx := context.Background()
50 opt, dd := t.TempDir(), t.TempDir()
51 t.Setenv("CARLOS_OPT_DIR", opt)
52 t.Setenv("CARLOS_DATA_DIR", dd)
53 os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
54 if err := writePrevVersion(opt, "v0.27.2"); err != nil {
55 t.Fatal(err)
56 }
57 s := release.NewDirStore(t.TempDir())
58 var said []string
59 seedsFloorRefresh(ctx, s, dd, func(format string, args ...any) { said = append(said, format) })
60 if len(said) != 0 {
61 t.Fatalf("an unpublished floor was logged: %q", said)
62 }
63 if err := judgeSeedsPrev(opt, dd); err != nil {
64 t.Fatal(err)
65 }
66 if err := rootJudgePrevWithSource(nil, nil); err != nil {
67 t.Fatal(err)
68 }
69 if _, err := os.Stat(filepath.Join(dd, ".moves")); !errors.Is(err, os.ErrNotExist) {
70 t.Fatal("an unarmed deployment grew a .moves directory")
71 }
72 if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
73 t.Fatal("an unarmed deployment has a seeds verdict")
74 }
75 if seedsFloorArmed(dd) {
76 t.Fatal("armed with nothing published")
77 }
78}
79
80// The seeds floor and the moves floor share a directory and a lock, and
81// nothing else: arming one arms neither the other's files nor its verdict.
82func TestSeedsFloorLeavesTheMovesFloorAlone(t *testing.T) {
83 ctx := context.Background()
84 opt, dd := t.TempDir(), t.TempDir()
85 os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
86 if err := writePrevVersion(opt, "v0.27.2"); err != nil {
87 t.Fatal(err)
88 }
89 s := release.NewDirStore(t.TempDir())
90 putSeedsFloor(t, s, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: "v0.27.0"})
91 if _, err := syncLocalSeedsFloor(ctx, s, dd); err != nil {
92 t.Fatal(err)
93 }
94 if f, err := readLocalFloor(dd); f != nil || err != nil {
95 t.Fatalf("the moves floor read as %+v, %v", f, err)
96 }
97 for _, name := range []string{moves.FloorFile, moves.FloorMinReleaseFile} {
98 if _, err := os.Stat(filepath.Join(dd, name)); !errors.Is(err, os.ErrNotExist) {
99 t.Errorf("arming the seeds floor wrote %s", name)
100 }
101 }
102 if err := judgeSeedsPrev(opt, dd); err != nil {
103 t.Fatal(err)
104 }
105 if _, err := os.Stat(prevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
106 t.Fatal("the seeds judgement wrote the moves verdict")
107 }
108 // Byte-identical to the floor line, because the units compare them
109 // with cmp.
110 v, verr := os.ReadFile(seedsPrevAllowedPath(opt))
111 line, lerr := os.ReadFile(filepath.Join(dd, seeds.FloorMinReleaseFile))
112 if verr != nil || lerr != nil || !bytes.Equal(v, line) {
113 t.Fatalf("verdict %q (%v), floor line %q (%v)", v, verr, line, lerr)
114 }
115}
116
117// rootJudgePrev runs the seeds half on a box with no moves floor: a box can
118// arm the seeds floor first.
119func TestRootJudgePrevJudgesTheSeedsFloorWithoutAMovesFloor(t *testing.T) {
120 opt, dd := t.TempDir(), t.TempDir()
121 t.Setenv("CARLOS_OPT_DIR", opt)
122 t.Setenv("CARLOS_DATA_DIR", dd)
123 os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
124 if err := writePrevVersion(opt, "v0.27.2"); err != nil {
125 t.Fatal(err)
126 }
127 armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
128 if err := rootJudgePrevWithSource(nil, nil); err != nil {
129 t.Fatal(err)
130 }
131 if line, ok := readLine(seedsPrevAllowedPath(opt)); !ok || line != "v0.27.0" {
132 t.Fatalf("verdict = %q, %v", line, ok)
133 }
134 // The floor rises past carlos.prev: the next pass takes the verdict away.
135 armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.28.0"})
136 if err := rootJudgePrevWithSource(nil, nil); err != nil {
137 t.Fatal(err)
138 }
139 if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
140 t.Fatal("a verdict left for a carlos.prev below the raised floor")
141 }
142 // The floor files gone by an operator's hand: a stale verdict still goes.
143 writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644)
144 os.Remove(filepath.Join(dd, seeds.FloorFile))
145 os.Remove(filepath.Join(dd, seeds.FloorMinReleaseFile))
146 if err := rootJudgePrevWithSource(nil, nil); err != nil {
147 t.Fatal(err)
148 }
149 if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
150 t.Fatal("a verdict left with no seeds floor")
151 }
152}
153
154// The seeds floor only rises on a box, like the moves floor.
155func TestSyncLocalSeedsFloorRefusesARegression(t *testing.T) {
156 ctx := context.Background()
157 dd, s := t.TempDir(), release.NewDirStore(t.TempDir())
158 armLocalSeedsFloor(t, dd, seeds.Floor{Min: 2, MinRelease: "v0.28.0"})
159 for _, tc := range []struct {
160 name string
161 remote seeds.Floor
162 }{
163 {"a lower min_release", seeds.Floor{Min: 2, MinRelease: "v0.27.0"}},
164 {"a lower protocol minimum", seeds.Floor{Min: 1, MinRelease: "v0.28.0"}},
165 } {
166 putSeedsFloor(t, s, tc.remote)
167 if f, err := syncLocalSeedsFloor(ctx, s, dd); err == nil || f == nil || *f != (seeds.Floor{Min: 2, MinRelease: "v0.28.0"}) {
168 t.Fatalf("%s = %+v, %v", tc.name, f, err)
169 }
170 if line, _ := readBeneathLine(dd, seeds.FloorMinReleaseFile); line != "v0.28.0" {
171 t.Fatalf("%s: min-release line = %q", tc.name, line)
172 }
173 }
174 // Raised: both files follow.
175 putSeedsFloor(t, s, seeds.Floor{Min: 2, MinRelease: "v0.29.0"})
176 if f, err := syncLocalSeedsFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.29.0" {
177 t.Fatalf("raised = %+v, %v", f, err)
178 }
179 if line, _ := readBeneathLine(dd, seeds.FloorMinReleaseFile); line != "v0.29.0" {
180 t.Fatalf("min-release line after a raise = %q", line)
181 }
182}
183
184// A store that cannot be read, or holds a min_release that is not a
185// release, leaves the armed floor as it is and says why.
186func TestSyncLocalSeedsFloorKeepsWhatIsArmed(t *testing.T) {
187 ctx := context.Background()
188 dd, s := t.TempDir(), release.NewDirStore(t.TempDir())
189 armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
190 if f, err := syncLocalSeedsFloor(ctx, unreadableStore{s}, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" {
191 t.Fatalf("store error = %+v, %v", f, err)
192 }
193 if f, err := syncLocalSeedsFloor(ctx, nil, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" {
194 t.Fatalf("no store = %+v, %v", f, err)
195 }
196 for _, bad := range []string{"v0.28.0-rc.1", "v0.28.0\nv0.1.0", ""} {
197 putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: bad})
198 if f, err := syncLocalSeedsFloor(ctx, s, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" {
199 t.Fatalf("published %q = %+v, %v", bad, f, err)
200 }
201 }
202 var said []string
203 seedsFloorRefresh(ctx, unreadableStore{s}, dd, func(format string, args ...any) { said = append(said, format) })
204 if len(said) == 0 {
205 t.Fatal("a failed refresh said nothing")
206 }
207}
208
209// A seeds floor file that is there but unusable counts as armed and
210// unknown: the judge takes the verdict away and says why, and a usable
211// published floor replaces it.
212func TestAnUnreadableSeedsFloorIsArmed(t *testing.T) {
213 ctx := context.Background()
214 opt, dd := t.TempDir(), t.TempDir()
215 os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
216 if err := writePrevVersion(opt, "v0.27.2"); err != nil {
217 t.Fatal(err)
218 }
219 if err := os.MkdirAll(filepath.Join(dd, ".moves"), 0o755); err != nil {
220 t.Fatal(err)
221 }
222 os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte("{not json"), 0o644)
223 if !seedsFloorArmed(dd) {
224 t.Fatal("an unreadable floor counted as not armed")
225 }
226 writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644)
227 if err := judgeSeedsPrev(opt, dd); err == nil {
228 t.Fatal("an unreadable floor was judged without a word")
229 }
230 if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
231 t.Fatal("a verdict left beside an unreadable floor")
232 }
233 s := release.NewDirStore(t.TempDir())
234 putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
235 if f, err := syncLocalSeedsFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.27.0" {
236 t.Fatalf("published over an unreadable floor = %+v, %v", f, err)
237 }
238}
239
240// The local seeds floor lives in a directory the sandboxed agent owns, and
241// root reads and writes it. Neither may follow a symlink planted there.
242func TestLocalSeedsFloorRefusesASymlink(t *testing.T) {
243 ctx := context.Background()
244 dd, elsewhere := t.TempDir(), t.TempDir()
245 if err := os.Symlink(elsewhere, filepath.Join(dd, ".moves")); err != nil {
246 t.Fatal(err)
247 }
248 s := release.NewDirStore(t.TempDir())
249 putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
250 if _, err := syncLocalSeedsFloor(ctx, s, dd); err == nil {
251 t.Fatal("wrote through a symlinked .moves")
252 }
253 if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 {
254 t.Fatalf("the symlink's target was written: %v", entries)
255 }
256 os.WriteFile(filepath.Join(elsewhere, filepath.Base(seeds.FloorFile)), []byte(`{"min":1,"min_release":"v0.1.0"}`), 0o644)
257 if f, err := readLocalSeedsFloor(dd); err == nil {
258 t.Fatalf("read through a symlinked .moves: %+v", f)
259 }
260}
261
262// The agent's hourly moves floor refresh refreshes the seeds floor too, on
263// the same store handle, whether or not a moves floor is published.
264func TestMovesFloorRefreshArmsTheSeedsFloor(t *testing.T) {
265 dd, s := t.TempDir(), release.NewDirStore(t.TempDir())
266 putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
267 movesFloorRefresh(context.Background(), s, dd, func(string, ...any) {})
268 if f, err := readLocalSeedsFloor(dd); err != nil || f == nil || f.MinRelease != "v0.27.0" {
269 t.Fatalf("seeds floor after the refresh = %+v, %v", f, err)
270 }
271 if f, _ := readLocalFloor(dd); f != nil {
272 t.Fatalf("no moves floor published, yet armed %+v", f)
273 }
274}
275
276// TestSystemUnitsSelectPrevByTheSeedsFloor runs each system unit's ExecStart
277// shell against a fake /opt/carlos and /data, as
278// TestSystemUnitsSelectPrevByTheFloor does for the moves clause. No seeds
279// floor file: the choice is exactly what the moves clause alone makes.
280// With one, carlos.prev runs only when its seeds verdict names it, byte for
281// byte, as carlos.prev's floor gate reads the two files (the stubs hand the
282// gate verb to the real Go code), and only when the moves clause allows it
283// too. A seeds floor path that is not a regular file never reads as "no
284// floor" (#267, as for the moves floor).
285func TestSystemUnitsSelectPrevByTheSeedsFloor(t *testing.T) {
286 t.Parallel()
287 if runtime.GOOS != "linux" {
288 t.Skip("the floor gate refuses off Linux")
289 }
290 self, err := os.Executable()
291 if err != nil {
292 t.Fatal(err)
293 }
294 const empty = "\x00empty" // a file that is there with nothing in it
295 for _, unit := range []string{"carlos-system-recover.service", "carlos-system-update.service"} {
296 t.Run(unit, func(t *testing.T) {
297 b, err := unitsFS.ReadFile("units/" + unit)
298 if err != nil {
299 t.Fatal(err)
300 }
301 var line string
302 for _, l := range strings.Split(string(b), "\n") {
303 if strings.HasPrefix(l, "ExecStart=") {
304 line = l
305 }
306 }
307 if strings.ContainsAny(line, "%$") {
308 t.Fatalf("ExecStart carries a %% or a $: %s", line)
309 }
310 for _, path := range []string{"/data/" + seeds.FloorMinReleaseFile, "/opt/carlos/" + seeds.PrevAllowedFile} {
311 if !strings.Contains(line, path) {
312 t.Fatalf("ExecStart does not read %s: %s", path, line)
313 }
314 }
315 if !strings.Contains(line, seedsFloorGateCall) {
316 t.Fatalf("ExecStart does not ask carlos.prev's floor gate about the seeds floor: %s", line)
317 }
318 body, ok := strings.CutPrefix(line, "ExecStart=/bin/sh -c '")
319 body, ok2 := strings.CutSuffix(body, "'")
320 if !ok || !ok2 || strings.Contains(body, "'") {
321 t.Fatalf("ExecStart is not one quoted sh -c body: %s", line)
322 }
323 for _, tc := range []struct {
324 name string
325 noJournal bool
326 movesFloor, movesOK string
327 seedsFloor, seedsOK string
328 seedsKind string // "" a regular file; or symlink, dangling, directory, fifo
329 prevNoVerb bool // carlos.prev predates the gate and rejects its verb
330 want string
331 }{
332 {"no seeds floor", false, "", "", "", "", "", false, "carlos.prev"},
333 {"no seeds floor, no journal", true, "", "", "", "", "", false, "carlos"},
334 {"an empty seeds floor file", false, "", "", empty, "", "", false, "carlos.prev"},
335 {"seeds floor and a verdict naming it", false, "", "", "v0.27.0\n", "v0.27.0\n", "", false, "carlos.prev"},
336 {"seeds floor and no verdict", false, "", "", "v0.27.0\n", "", "", false, "carlos"},
337 {"seeds floor and an empty verdict", false, "", "", "v0.27.0\n", empty, "", false, "carlos"},
338 {"a seeds verdict for an older floor", false, "", "", "v0.27.0\n", "v0.26.0\n", "", false, "carlos"},
339 {"a seeds verdict, no journal", true, "", "", "v0.27.0\n", "v0.27.0\n", "", false, "carlos"},
340 {"both floors, both verdicts", false, "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "v0.27.0\n", "", false, "carlos.prev"},
341 {"both floors, only the moves verdict", false, "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "", "", false, "carlos"},
342 {"both floors, only the seeds verdict", false, "v0.30.0\n", "", "v0.27.0\n", "v0.27.0\n", "", false, "carlos"},
343 {"the moves verdict in the seeds verdict's place", false, "", "v0.27.0\n", "v0.27.0\n", "", "", false, "carlos"},
344 // The agent owns /data: anything but a regular file at the
345 // seeds floor path is refused before the gate runs.
346 {"symlink seeds floor with matching verdict", false, "", "", "v0.27.0\n", "v0.27.0\n", "symlink", false, "carlos"},
347 {"dangling symlink seeds floor", false, "", "", "", "", "dangling", false, "carlos"},
348 {"directory seeds floor", false, "", "", "", "", "directory", false, "carlos"},
349 {"fifo seeds floor", false, "", "", "", "v0.27.0\n", "fifo", false, "carlos"},
350 // A carlos.prev older than the gate answers its verb with an
351 // unknown-verb exit: a mismatch, so carlos runs.
352 {"a carlos.prev without the gate verb", false, "", "", "v0.27.0\n", "v0.27.0\n", "", true, "carlos"},
353 } {
354 t.Run(tc.name, func(t *testing.T) {
355 root := t.TempDir()
356 opt, data, ran := filepath.Join(root, "opt"), filepath.Join(root, "data"), filepath.Join(root, "ran")
357 os.MkdirAll(filepath.Join(opt, "system", "router"), 0o755)
358 os.MkdirAll(filepath.Join(data, ".moves"), 0o755)
359 // Each stub answers the gate verb by handing it to this
360 // test binary (TestFloorGateHelperProcess); anything
361 // else records the stub as run.
362 for _, name := range []string{"carlos", "carlos.prev"} {
363 answer := " shift 2\n" +
364 " exec " + self + " -test.run='^TestFloorGateHelperProcess$' -- \"$@\"\n"
365 if name == "carlos.prev" && tc.prevNoVerb {
366 answer = " echo 'system: unknown verb \"floor-allows-prev\"' >&2\n exit 2\n"
367 }
368 sh := "#!/bin/sh\n" +
369 "if [ \"$1\" = system ] && [ \"$2\" = floor-allows-prev ]; then\n" +
370 answer +
371 "fi\n" +
372 "echo " + name + " > " + ran + "\n"
373 os.WriteFile(filepath.Join(opt, name), []byte(sh), 0o755)
374 }
375 if !tc.noJournal {
376 os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600)
377 }
378 put := func(path, body string) {
379 switch body {
380 case "":
381 case empty:
382 os.WriteFile(path, nil, 0o644)
383 default:
384 os.WriteFile(path, []byte(body), 0o644)
385 }
386 }
387 put(filepath.Join(data, moves.FloorMinReleaseFile), tc.movesFloor)
388 put(filepath.Join(opt, moves.PrevAllowedFile), tc.movesOK)
389 seedsPath := filepath.Join(data, seeds.FloorMinReleaseFile)
390 switch tc.seedsKind {
391 case "symlink", "dangling":
392 target := filepath.Join(root, "seeds-floor-target")
393 if tc.seedsKind == "symlink" {
394 put(target, tc.seedsFloor)
395 }
396 if err := os.Symlink(target, seedsPath); err != nil {
397 t.Fatal(err)
398 }
399 case "directory":
400 if err := os.Mkdir(seedsPath, 0o755); err != nil {
401 t.Fatal(err)
402 }
403 case "fifo":
404 if out, err := exec.Command("mkfifo", seedsPath).CombinedOutput(); err != nil {
405 t.Fatalf("mkfifo: %v: %s", err, out)
406 }
407 default:
408 put(seedsPath, tc.seedsFloor)
409 }
410 put(filepath.Join(opt, seeds.PrevAllowedFile), tc.seedsOK)
411 script := strings.ReplaceAll(strings.ReplaceAll(body, "/data/", data+"/"), "/opt/carlos/", opt+"/")
412 cmd := exec.Command("/bin/sh", "-c", script)
413 cmd.Env = append(os.Environ(), floorGateHelperEnv+"=1")
414 if out, err := cmd.CombinedOutput(); err != nil {
415 t.Fatalf("%v: %s", err, out)
416 }
417 got, _ := os.ReadFile(ran)
418 if strings.TrimSpace(string(got)) != tc.want {
419 t.Fatalf("ran %q, want %s", strings.TrimSpace(string(got)), tc.want)
420 }
421 })
422 }
423 })
424 }
425}
426
427func TestSeedsActivationRefusal(t *testing.T) {
428 dd := t.TempDir()
429 if err := seedsActivationRefusal("v0.20.0", dd); err != nil {
430 t.Fatalf("no seeds floor: %v", err)
431 }
432 os.MkdirAll(filepath.Join(dd, ".moves"), 0o755)
433 os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{"min":1,"min_release":"v0.27.0"}`), 0o644)
434 if err := seedsActivationRefusal("v0.27.0", dd); err != nil {
435 t.Fatalf("at the floor: %v", err)
436 }
437 err := seedsActivationRefusal("v0.26.9", dd)
438 var refused activationRefused
439 if !errors.As(err, &refused) || err.Error() != "seeds protocol floor: v0.26.9 is below v0.27.0" {
440 t.Fatalf("below the floor: %v", err)
441 }
442 // A carlos.prev with no recorded version is below any floor.
443 if err := seedsActivationRefusal("", dd); !errors.As(err, &refused) {
444 t.Fatalf("no version: %v, want a refusal", err)
445 }
446 os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{garbage`), 0o644)
447 if err := seedsActivationRefusal("v0.30.0", dd); !errors.As(err, &refused) {
448 t.Fatalf("an unreadable seeds floor: %v, want a refusal", err)
449 }
450}
451
452// Task 2's review: with the seeds floor armed and a carlos.prev that has no
453// version sidecar, the seeds verdict is withdrawn (and not written), so the
454// units' floor gate fails and the current binary runs.
455func TestJudgeSeedsPrevDeniesACarlosPrevWithNoVersion(t *testing.T) {
456 opt, dd := t.TempDir(), t.TempDir()
457 os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755)
458 armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
459 writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644)
460 if err := judgeSeedsPrev(opt, dd); err != nil {
461 t.Fatal(err)
462 }
463 if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) {
464 t.Fatal("a seeds verdict for a carlos.prev that records no version")
465 }
466}
467
468// The apply arms the seeds floor from the store before it stages anything,
469// and a candidate below it is refused with SD-N1, never staged or run.
470func TestSystemApplyArmsTheSeedsFloorBeforeStaging(t *testing.T) {
471 f, _ := floorFixture(t, "v0.28.0", "v0.29.0")
472 putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"})
473 staged := filepath.Join(f.a.stagingDir(), "carlos")
474 run := f.a.ops.run
475 f.a.ops.run = func(ctx context.Context, d time.Duration, name string, args ...string) (string, error) {
476 if name == staged {
477 t.Errorf("a candidate below the seeds floor was staged and run: %s %v", name, args)
478 }
479 return run(ctx, d, name, args...)
480 }
481 if err := f.a.Run(context.Background(), false); err != nil {
482 t.Fatal(err)
483 }
484 if lf, err := readLocalSeedsFloor(f.a.dataDir); err != nil || lf == nil || lf.MinRelease != "v0.30.0" {
485 t.Fatalf("local seeds floor = %+v, %v", lf, err)
486 }
487 r := f.result()
488 if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "seeds protocol floor: v0.29.0 is below v0.30.0") {
489 t.Fatalf("result = %+v", r)
490 }
491 if f.onDisk() != "v0.28.0" {
492 t.Fatalf("on disk %q", f.onDisk())
493 }
494 if f, err := readLocalFloor(f.a.dataDir); f != nil || err != nil {
495 t.Fatalf("the moves floor read as %+v, %v", f, err)
496 }
497}
498
499// A seeds floor this box could not write down defers the apply; a store
500// that cannot be read is a log line and the apply goes on.
501func TestArmSeedsFloor(t *testing.T) {
502 f, _ := floorFixture(t, "v0.28.0", "v0.29.0")
503 putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.27.0"})
504 f.a.store = f.store
505 // .moves is a file: floor.lock cannot be made beside it.
506 os.WriteFile(filepath.Join(f.a.dataDir, ".moves"), nil, 0o644)
507 if err := f.a.armSeedsFloor(context.Background()); !errors.Is(err, errSystemDeferred) || !strings.Contains(err.Error(), "seeds protocol floor") {
508 t.Fatalf("unwritable: %v, want a deferral", err)
509 }
510 os.Remove(filepath.Join(f.a.dataDir, ".moves"))
511 var said []string
512 f.a.ops.logf = func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) }
513 f.a.store = unreadableStore{f.store}
514 if err := f.a.armSeedsFloor(context.Background()); err != nil {
515 t.Fatalf("unreadable store: %v", err)
516 }
517 if len(said) != 1 || !strings.Contains(said[0], "the seeds floor on this box stays none") {
518 t.Fatalf("logged %q", said)
519 }
520 f.a.store = f.store
521 if err := f.a.armSeedsFloor(context.Background()); err != nil {
522 t.Fatal(err)
523 }
524 if lf, err := readLocalSeedsFloor(f.a.dataDir); err != nil || lf == nil || lf.MinRelease != "v0.27.0" {
525 t.Fatalf("local seeds floor = %+v, %v", lf, err)
526 }
527}
528
529// The install's backup of the running build is judged against the seeds
530// floor as well, and a replacement withdraws the old seeds verdict first.
531func TestInstallJudgesCarlosPrevByTheSeedsFloor(t *testing.T) {
532 for _, tc := range []struct {
533 name, running string
534 verdict bool
535 }{
536 {"at or above the seeds floor", "v0.31.0", true},
537 {"below the seeds floor", "v0.29.0", false},
538 } {
539 t.Run(tc.name, func(t *testing.T) {
540 f, _ := floorFixture(t, tc.running, "v0.32.0")
541 putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"})
542 // A verdict from an earlier judgement must not outlive this one.
543 writeFileDurable(seedsPrevAllowedPath(f.a.optDir), []byte("v0.30.0\n"), 0o644)
544 if err := f.a.Run(context.Background(), false); err != nil {
545 t.Fatal(err)
546 }
547 if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0" {
548 t.Fatalf("result = %+v, on disk %q", r, f.onDisk())
549 }
550 if line, ok := readLine(prevVersionPath(f.a.optDir)); !ok || line != tc.running {
551 t.Fatalf("sidecar = %q, %v", line, ok)
552 }
553 b, err := os.ReadFile(seedsPrevAllowedPath(f.a.optDir))
554 if tc.verdict != (err == nil) || (tc.verdict && string(b) != "v0.30.0\n") {
555 t.Fatalf("seeds verdict = %q, %v; want present %v", b, err, tc.verdict)
556 }
557 if _, err := os.Stat(prevAllowedPath(f.a.optDir)); !errors.Is(err, os.ErrNotExist) {
558 t.Fatal("a moves verdict with no moves floor")
559 }
560 })
561 }
562}
563
564// Boot recovery runs before the network: the seeds floor it enforces is
565// the one on disk, and the version it judges is carlos.prev's sidecar.
566func TestBootRecoveryUsesTheArmedSeedsFloorOffline(t *testing.T) {
567 for _, tc := range []struct {
568 name, sidecar string
569 restored bool
570 }{
571 {"below the seeds floor", "v0.29.0", false},
572 {"at the seeds floor", "v0.30.0", true},
573 {"no sidecar", "", false},
574 } {
575 t.Run(tc.name, func(t *testing.T) {
576 f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
577 f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) {
578 return unreadableStore{f.store}, f.pub, nil
579 }
580 armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.30.0"})
581 prev := tc.sidecar
582 if prev == "" {
583 prev = "v0.30.0"
584 }
585 f.brokenBox("v0.31.0", prev, tc.sidecar)
586 f.edgeStartedThisBoot = false
587 err := f.a.Run(context.Background(), true)
588 j, _ := readSystemJournal(f.a.optDir)
589 if tc.restored {
590 if err != nil || f.onDisk() != "v0.30.0" || j != nil {
591 t.Fatalf("err %v, on disk %q, journal %+v", err, f.onDisk(), j)
592 }
593 return
594 }
595 if err == nil || !strings.Contains(err.Error(), "seeds protocol floor") {
596 t.Fatalf("recovery err = %v", err)
597 }
598 if f.onDisk() != "v0.31.0" || j == nil {
599 t.Fatalf("a refused recovery changed the box: on disk %q, journal %+v", f.onDisk(), j)
600 }
601 })
602 }
603}
604
605// A gated journal is finished forward only when its target is at or above
606// the seeds floor armed now; below it, recovery goes back to carlos.prev.
607func TestForwardRecoveryChecksTheSeedsFloor(t *testing.T) {
608 for _, tc := range []struct {
609 name, prev, want string
610 ok bool
611 }{
612 {"carlos.prev below the seeds floor too", "v0.30.0", "v0.31.0", false},
613 {"carlos.prev at the seeds floor", "v0.32.0", "v0.32.0", true},
614 } {
615 t.Run(tc.name, func(t *testing.T) {
616 f, _ := floorFixture(t, "v0.30.0", "v0.31.0")
617 f.brokenBox("v0.31.0", tc.prev, tc.prev)
618 f.a.journal(systemJournal{Phase: systemPhaseGated, Target: "v0.31.0", PrevBuild: tc.prev, StartedAt: f.now})
619 armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.32.0"})
620 f.edgeStartedThisBoot = false
621 err := f.a.Run(context.Background(), true)
622 if ap, _ := readSystemApplied(f.a.optDir); ap != nil && ap.Version == "v0.31.0" {
623 t.Fatalf("a target below the seeds floor was finished forward: %+v", ap)
624 }
625 j, _ := readSystemJournal(f.a.optDir)
626 if f.onDisk() != tc.want {
627 t.Fatalf("on disk %q, want %s", f.onDisk(), tc.want)
628 }
629 if tc.ok && (err != nil || j != nil) {
630 t.Fatalf("err %v, journal %+v", err, j)
631 }
632 if !tc.ok && (err == nil || !strings.Contains(err.Error(), "seeds protocol floor") || j == nil) {
633 t.Fatalf("err %v, journal %+v", err, j)
634 }
635 // Refused before the forward path stages a plan from the binary
636 // on disk, not only at the finish's guard.
637 for _, c := range f.calls {
638 if strings.HasPrefix(c, "run carlos bootstrap") {
639 t.Fatalf("the forward path staged a plan for a target below the seeds floor: %s", c)
640 }
641 }
642 })
643 }
644}
645
646// Replacing carlos.prev withdraws the old seeds verdict before the copy, so
647// a copy that dies part way leaves no seeds verdict on the new bytes.
648func TestReplacingCarlosPrevForgetsTheOldSeedsVerdictFirst(t *testing.T) {
649 f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
650 putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"})
651 os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0.30.0\n"), 0o755)
652 writePrevVersion(f.a.optDir, "v0.30.0")
653 writeFileDurable(seedsPrevAllowedPath(f.a.optDir), []byte("v0.30.0\n"), 0o644)
654 // Prevent publication without depending on the writer's temporary name
655 // (as TestDecidePrevForgetsTheOldVerdictBeforeTheCopy does).
656 if err := os.Remove(systemPrevPath(f.a.optDir)); err != nil {
657 t.Fatal(err)
658 }
659 if err := os.Mkdir(systemPrevPath(f.a.optDir), 0o755); err != nil {
660 t.Fatal(err)
661 }
662 if err := f.a.Run(context.Background(), false); err != nil {
663 t.Fatal(err)
664 }
665 if r := f.result(); r == nil || r.Outcome != systemOutcomeRefused {
666 t.Fatalf("result = %+v", r)
667 }
668 if _, err := os.Stat(seedsPrevAllowedPath(f.a.optDir)); !errors.Is(err, os.ErrNotExist) {
669 t.Fatal("the seeds verdict survived a failed replacement of carlos.prev")
670 }
671}
672
673// An armed seeds floor arms the floor's own steps (floorStep): a failure in
674// them is the apply's error, as with the moves floor. With no floor of
675// either kind the same failure is a log line and the install goes on.
676func TestAnArmedSeedsFloorMakesFloorStepsFatal(t *testing.T) {
677 for _, armed := range []bool{true, false} {
678 t.Run(fmt.Sprintf("armed=%v", armed), func(t *testing.T) {
679 f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
680 if armed {
681 putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"})
682 }
683 // carlos.prev's lock cannot be opened.
684 os.MkdirAll(filepath.Join(systemRootDir(f.a.optDir), prevLockFile), 0o755)
685 f.a.Run(context.Background(), false)
686 r := f.result()
687 if armed && (r == nil || r.Outcome == systemOutcomeApplied || f.onDisk() != "v0.31.0") {
688 t.Fatalf("armed: result %+v, on disk %q", r, f.onDisk())
689 }
690 if !armed && (r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0") {
691 t.Fatalf("unarmed: result %+v, on disk %q", r, f.onDisk())
692 }
693 })
694 }
695}
696
697// The seeds floor raised while the candidate is staged: the rename's guard
698// refuses it.
699func TestSeedsFloorIsCheckedAgainAtTheRename(t *testing.T) {
700 f, _ := floorFixture(t, "v0.31.0", "v0.32.0")
701 f.duringStaging(func() { armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.33.0"}) })
702 if err := f.a.Run(context.Background(), false); err != nil {
703 t.Fatal(err)
704 }
705 r := f.result()
706 if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "seeds protocol floor: v0.32.0 is below v0.33.0") {
707 t.Fatalf("result = %+v", r)
708 }
709 if f.onDisk() != "v0.31.0" {
710 t.Fatalf("on disk %q", f.onDisk())
711 }
712}
713