package host import ( "bytes" "context" "crypto/ed25519" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/seeds" ) // putSeedsFloor replaces the seeds floor object in s directly, with no // rising check, as an operator's hand or a broken publisher could. func putSeedsFloor(t *testing.T, s release.Store, f seeds.Floor) { t.Helper() body, _ := json.Marshal(f) if err := s.Delete(context.Background(), seeds.FloorKey); err != nil && !errors.Is(err, os.ErrNotExist) { t.Fatal(err) } if _, err := s.Put(context.Background(), seeds.FloorKey, body, ""); err != nil { t.Fatal(err) } } // armLocalSeedsFloor writes the box's two local seeds floor files, as a // refresh would. func armLocalSeedsFloor(t *testing.T, dataDir string, f seeds.Floor) { t.Helper() if err := withFloorLock(dataDir, true, func(d *movesDir) error { return writeSeedsFloorFiles(d, f) }); err != nil { t.Fatal(err) } } // A deployment that never publishes the seeds floor: no refresh, judgement // or adopt pass leaves a file behind, so the units and the update path see // exactly what they saw before the seeds floor existed. func TestSeedsFloorIsDormantWhenNeverPublished(t *testing.T) { ctx := context.Background() opt, dd := t.TempDir(), t.TempDir() t.Setenv("CARLOS_OPT_DIR", opt) t.Setenv("CARLOS_DATA_DIR", dd) os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) if err := writePrevVersion(opt, "v0.27.2"); err != nil { t.Fatal(err) } s := release.NewDirStore(t.TempDir()) var said []string seedsFloorRefresh(ctx, s, dd, func(format string, args ...any) { said = append(said, format) }) if len(said) != 0 { t.Fatalf("an unpublished floor was logged: %q", said) } if err := judgeSeedsPrev(opt, dd); err != nil { t.Fatal(err) } if err := rootJudgePrevWithSource(nil, nil); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(dd, ".moves")); !errors.Is(err, os.ErrNotExist) { t.Fatal("an unarmed deployment grew a .moves directory") } if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { t.Fatal("an unarmed deployment has a seeds verdict") } if seedsFloorArmed(dd) { t.Fatal("armed with nothing published") } } // The seeds floor and the moves floor share a directory and a lock, and // nothing else: arming one arms neither the other's files nor its verdict. func TestSeedsFloorLeavesTheMovesFloorAlone(t *testing.T) { ctx := context.Background() opt, dd := t.TempDir(), t.TempDir() os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) if err := writePrevVersion(opt, "v0.27.2"); err != nil { t.Fatal(err) } s := release.NewDirStore(t.TempDir()) putSeedsFloor(t, s, seeds.Floor{Min: seeds.RequiredProtocol, MinRelease: "v0.27.0"}) if _, err := syncLocalSeedsFloor(ctx, s, dd); err != nil { t.Fatal(err) } if f, err := readLocalFloor(dd); f != nil || err != nil { t.Fatalf("the moves floor read as %+v, %v", f, err) } for _, name := range []string{moves.FloorFile, moves.FloorMinReleaseFile} { if _, err := os.Stat(filepath.Join(dd, name)); !errors.Is(err, os.ErrNotExist) { t.Errorf("arming the seeds floor wrote %s", name) } } if err := judgeSeedsPrev(opt, dd); err != nil { t.Fatal(err) } if _, err := os.Stat(prevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { t.Fatal("the seeds judgement wrote the moves verdict") } // Byte-identical to the floor line, because the units compare them // with cmp. v, verr := os.ReadFile(seedsPrevAllowedPath(opt)) line, lerr := os.ReadFile(filepath.Join(dd, seeds.FloorMinReleaseFile)) if verr != nil || lerr != nil || !bytes.Equal(v, line) { t.Fatalf("verdict %q (%v), floor line %q (%v)", v, verr, line, lerr) } } // rootJudgePrev runs the seeds half on a box with no moves floor: a box can // arm the seeds floor first. func TestRootJudgePrevJudgesTheSeedsFloorWithoutAMovesFloor(t *testing.T) { opt, dd := t.TempDir(), t.TempDir() t.Setenv("CARLOS_OPT_DIR", opt) t.Setenv("CARLOS_DATA_DIR", dd) os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) if err := writePrevVersion(opt, "v0.27.2"); err != nil { t.Fatal(err) } armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) if err := rootJudgePrevWithSource(nil, nil); err != nil { t.Fatal(err) } if line, ok := readLine(seedsPrevAllowedPath(opt)); !ok || line != "v0.27.0" { t.Fatalf("verdict = %q, %v", line, ok) } // The floor rises past carlos.prev: the next pass takes the verdict away. armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.28.0"}) if err := rootJudgePrevWithSource(nil, nil); err != nil { t.Fatal(err) } if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { t.Fatal("a verdict left for a carlos.prev below the raised floor") } // The floor files gone by an operator's hand: a stale verdict still goes. writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644) os.Remove(filepath.Join(dd, seeds.FloorFile)) os.Remove(filepath.Join(dd, seeds.FloorMinReleaseFile)) if err := rootJudgePrevWithSource(nil, nil); err != nil { t.Fatal(err) } if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { t.Fatal("a verdict left with no seeds floor") } } // The seeds floor only rises on a box, like the moves floor. func TestSyncLocalSeedsFloorRefusesARegression(t *testing.T) { ctx := context.Background() dd, s := t.TempDir(), release.NewDirStore(t.TempDir()) armLocalSeedsFloor(t, dd, seeds.Floor{Min: 2, MinRelease: "v0.28.0"}) for _, tc := range []struct { name string remote seeds.Floor }{ {"a lower min_release", seeds.Floor{Min: 2, MinRelease: "v0.27.0"}}, {"a lower protocol minimum", seeds.Floor{Min: 1, MinRelease: "v0.28.0"}}, } { putSeedsFloor(t, s, tc.remote) if f, err := syncLocalSeedsFloor(ctx, s, dd); err == nil || f == nil || *f != (seeds.Floor{Min: 2, MinRelease: "v0.28.0"}) { t.Fatalf("%s = %+v, %v", tc.name, f, err) } if line, _ := readBeneathLine(dd, seeds.FloorMinReleaseFile); line != "v0.28.0" { t.Fatalf("%s: min-release line = %q", tc.name, line) } } // Raised: both files follow. putSeedsFloor(t, s, seeds.Floor{Min: 2, MinRelease: "v0.29.0"}) if f, err := syncLocalSeedsFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.29.0" { t.Fatalf("raised = %+v, %v", f, err) } if line, _ := readBeneathLine(dd, seeds.FloorMinReleaseFile); line != "v0.29.0" { t.Fatalf("min-release line after a raise = %q", line) } } // A store that cannot be read, or holds a min_release that is not a // release, leaves the armed floor as it is and says why. func TestSyncLocalSeedsFloorKeepsWhatIsArmed(t *testing.T) { ctx := context.Background() dd, s := t.TempDir(), release.NewDirStore(t.TempDir()) armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) if f, err := syncLocalSeedsFloor(ctx, unreadableStore{s}, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" { t.Fatalf("store error = %+v, %v", f, err) } if f, err := syncLocalSeedsFloor(ctx, nil, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" { t.Fatalf("no store = %+v, %v", f, err) } for _, bad := range []string{"v0.28.0-rc.1", "v0.28.0\nv0.1.0", ""} { putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: bad}) if f, err := syncLocalSeedsFloor(ctx, s, dd); err == nil || f == nil || f.MinRelease != "v0.27.0" { t.Fatalf("published %q = %+v, %v", bad, f, err) } } var said []string seedsFloorRefresh(ctx, unreadableStore{s}, dd, func(format string, args ...any) { said = append(said, format) }) if len(said) == 0 { t.Fatal("a failed refresh said nothing") } } // A seeds floor file that is there but unusable counts as armed and // unknown: the judge takes the verdict away and says why, and a usable // published floor replaces it. func TestAnUnreadableSeedsFloorIsArmed(t *testing.T) { ctx := context.Background() opt, dd := t.TempDir(), t.TempDir() os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) if err := writePrevVersion(opt, "v0.27.2"); err != nil { t.Fatal(err) } if err := os.MkdirAll(filepath.Join(dd, ".moves"), 0o755); err != nil { t.Fatal(err) } os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte("{not json"), 0o644) if !seedsFloorArmed(dd) { t.Fatal("an unreadable floor counted as not armed") } writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644) if err := judgeSeedsPrev(opt, dd); err == nil { t.Fatal("an unreadable floor was judged without a word") } if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { t.Fatal("a verdict left beside an unreadable floor") } s := release.NewDirStore(t.TempDir()) putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) if f, err := syncLocalSeedsFloor(ctx, s, dd); err != nil || f == nil || f.MinRelease != "v0.27.0" { t.Fatalf("published over an unreadable floor = %+v, %v", f, err) } } // The local seeds floor lives in a directory the sandboxed agent owns, and // root reads and writes it. Neither may follow a symlink planted there. func TestLocalSeedsFloorRefusesASymlink(t *testing.T) { ctx := context.Background() dd, elsewhere := t.TempDir(), t.TempDir() if err := os.Symlink(elsewhere, filepath.Join(dd, ".moves")); err != nil { t.Fatal(err) } s := release.NewDirStore(t.TempDir()) putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) if _, err := syncLocalSeedsFloor(ctx, s, dd); err == nil { t.Fatal("wrote through a symlinked .moves") } if entries, _ := os.ReadDir(elsewhere); len(entries) != 0 { t.Fatalf("the symlink's target was written: %v", entries) } os.WriteFile(filepath.Join(elsewhere, filepath.Base(seeds.FloorFile)), []byte(`{"min":1,"min_release":"v0.1.0"}`), 0o644) if f, err := readLocalSeedsFloor(dd); err == nil { t.Fatalf("read through a symlinked .moves: %+v", f) } } // The agent's hourly moves floor refresh refreshes the seeds floor too, on // the same store handle, whether or not a moves floor is published. func TestMovesFloorRefreshArmsTheSeedsFloor(t *testing.T) { dd, s := t.TempDir(), release.NewDirStore(t.TempDir()) putSeedsFloor(t, s, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) movesFloorRefresh(context.Background(), s, dd, func(string, ...any) {}) if f, err := readLocalSeedsFloor(dd); err != nil || f == nil || f.MinRelease != "v0.27.0" { t.Fatalf("seeds floor after the refresh = %+v, %v", f, err) } if f, _ := readLocalFloor(dd); f != nil { t.Fatalf("no moves floor published, yet armed %+v", f) } } // TestSystemUnitsSelectPrevByTheSeedsFloor runs each system unit's ExecStart // shell against a fake /opt/carlos and /data, as // TestSystemUnitsSelectPrevByTheFloor does for the moves clause. No seeds // floor file: the choice is exactly what the moves clause alone makes. // With one, carlos.prev runs only when its seeds verdict names it, byte for // byte, as carlos.prev's floor gate reads the two files (the stubs hand the // gate verb to the real Go code), and only when the moves clause allows it // too. A seeds floor path that is not a regular file never reads as "no // floor" (#267, as for the moves floor). func TestSystemUnitsSelectPrevByTheSeedsFloor(t *testing.T) { t.Parallel() if runtime.GOOS != "linux" { t.Skip("the floor gate refuses off Linux") } self, err := os.Executable() if err != nil { t.Fatal(err) } const empty = "\x00empty" // a file that is there with nothing in it for _, unit := range []string{"carlos-system-recover.service", "carlos-system-update.service"} { t.Run(unit, func(t *testing.T) { b, err := unitsFS.ReadFile("units/" + unit) if err != nil { t.Fatal(err) } var line string for _, l := range strings.Split(string(b), "\n") { if strings.HasPrefix(l, "ExecStart=") { line = l } } if strings.ContainsAny(line, "%$") { t.Fatalf("ExecStart carries a %% or a $: %s", line) } for _, path := range []string{"/data/" + seeds.FloorMinReleaseFile, "/opt/carlos/" + seeds.PrevAllowedFile} { if !strings.Contains(line, path) { t.Fatalf("ExecStart does not read %s: %s", path, line) } } if !strings.Contains(line, seedsFloorGateCall) { t.Fatalf("ExecStart does not ask carlos.prev's floor gate about the seeds floor: %s", line) } body, ok := strings.CutPrefix(line, "ExecStart=/bin/sh -c '") body, ok2 := strings.CutSuffix(body, "'") if !ok || !ok2 || strings.Contains(body, "'") { t.Fatalf("ExecStart is not one quoted sh -c body: %s", line) } for _, tc := range []struct { name string noJournal bool movesFloor, movesOK string seedsFloor, seedsOK string seedsKind string // "" a regular file; or symlink, dangling, directory, fifo prevNoVerb bool // carlos.prev predates the gate and rejects its verb want string }{ {"no seeds floor", false, "", "", "", "", "", false, "carlos.prev"}, {"no seeds floor, no journal", true, "", "", "", "", "", false, "carlos"}, {"an empty seeds floor file", false, "", "", empty, "", "", false, "carlos.prev"}, {"seeds floor and a verdict naming it", false, "", "", "v0.27.0\n", "v0.27.0\n", "", false, "carlos.prev"}, {"seeds floor and no verdict", false, "", "", "v0.27.0\n", "", "", false, "carlos"}, {"seeds floor and an empty verdict", false, "", "", "v0.27.0\n", empty, "", false, "carlos"}, {"a seeds verdict for an older floor", false, "", "", "v0.27.0\n", "v0.26.0\n", "", false, "carlos"}, {"a seeds verdict, no journal", true, "", "", "v0.27.0\n", "v0.27.0\n", "", false, "carlos"}, {"both floors, both verdicts", false, "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "v0.27.0\n", "", false, "carlos.prev"}, {"both floors, only the moves verdict", false, "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "", "", false, "carlos"}, {"both floors, only the seeds verdict", false, "v0.30.0\n", "", "v0.27.0\n", "v0.27.0\n", "", false, "carlos"}, {"the moves verdict in the seeds verdict's place", false, "", "v0.27.0\n", "v0.27.0\n", "", "", false, "carlos"}, // The agent owns /data: anything but a regular file at the // seeds floor path is refused before the gate runs. {"symlink seeds floor with matching verdict", false, "", "", "v0.27.0\n", "v0.27.0\n", "symlink", false, "carlos"}, {"dangling symlink seeds floor", false, "", "", "", "", "dangling", false, "carlos"}, {"directory seeds floor", false, "", "", "", "", "directory", false, "carlos"}, {"fifo seeds floor", false, "", "", "", "v0.27.0\n", "fifo", false, "carlos"}, // A carlos.prev older than the gate answers its verb with an // unknown-verb exit: a mismatch, so carlos runs. {"a carlos.prev without the gate verb", false, "", "", "v0.27.0\n", "v0.27.0\n", "", true, "carlos"}, } { t.Run(tc.name, func(t *testing.T) { root := t.TempDir() opt, data, ran := filepath.Join(root, "opt"), filepath.Join(root, "data"), filepath.Join(root, "ran") os.MkdirAll(filepath.Join(opt, "system", "router"), 0o755) os.MkdirAll(filepath.Join(data, ".moves"), 0o755) // Each stub answers the gate verb by handing it to this // test binary (TestFloorGateHelperProcess); anything // else records the stub as run. for _, name := range []string{"carlos", "carlos.prev"} { answer := " shift 2\n" + " exec " + self + " -test.run='^TestFloorGateHelperProcess$' -- \"$@\"\n" if name == "carlos.prev" && tc.prevNoVerb { answer = " echo 'system: unknown verb \"floor-allows-prev\"' >&2\n exit 2\n" } sh := "#!/bin/sh\n" + "if [ \"$1\" = system ] && [ \"$2\" = floor-allows-prev ]; then\n" + answer + "fi\n" + "echo " + name + " > " + ran + "\n" os.WriteFile(filepath.Join(opt, name), []byte(sh), 0o755) } if !tc.noJournal { os.WriteFile(filepath.Join(opt, "system", "router", "journal.json"), []byte("{}"), 0o600) } put := func(path, body string) { switch body { case "": case empty: os.WriteFile(path, nil, 0o644) default: os.WriteFile(path, []byte(body), 0o644) } } put(filepath.Join(data, moves.FloorMinReleaseFile), tc.movesFloor) put(filepath.Join(opt, moves.PrevAllowedFile), tc.movesOK) seedsPath := filepath.Join(data, seeds.FloorMinReleaseFile) switch tc.seedsKind { case "symlink", "dangling": target := filepath.Join(root, "seeds-floor-target") if tc.seedsKind == "symlink" { put(target, tc.seedsFloor) } if err := os.Symlink(target, seedsPath); err != nil { t.Fatal(err) } case "directory": if err := os.Mkdir(seedsPath, 0o755); err != nil { t.Fatal(err) } case "fifo": if out, err := exec.Command("mkfifo", seedsPath).CombinedOutput(); err != nil { t.Fatalf("mkfifo: %v: %s", err, out) } default: put(seedsPath, tc.seedsFloor) } put(filepath.Join(opt, seeds.PrevAllowedFile), tc.seedsOK) script := strings.ReplaceAll(strings.ReplaceAll(body, "/data/", data+"/"), "/opt/carlos/", opt+"/") cmd := exec.Command("/bin/sh", "-c", script) cmd.Env = append(os.Environ(), floorGateHelperEnv+"=1") if out, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%v: %s", err, out) } got, _ := os.ReadFile(ran) if strings.TrimSpace(string(got)) != tc.want { t.Fatalf("ran %q, want %s", strings.TrimSpace(string(got)), tc.want) } }) } }) } } func TestSeedsActivationRefusal(t *testing.T) { dd := t.TempDir() if err := seedsActivationRefusal("v0.20.0", dd); err != nil { t.Fatalf("no seeds floor: %v", err) } os.MkdirAll(filepath.Join(dd, ".moves"), 0o755) os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{"min":1,"min_release":"v0.27.0"}`), 0o644) if err := seedsActivationRefusal("v0.27.0", dd); err != nil { t.Fatalf("at the floor: %v", err) } err := seedsActivationRefusal("v0.26.9", dd) var refused activationRefused if !errors.As(err, &refused) || err.Error() != "seeds protocol floor: v0.26.9 is below v0.27.0" { t.Fatalf("below the floor: %v", err) } // A carlos.prev with no recorded version is below any floor. if err := seedsActivationRefusal("", dd); !errors.As(err, &refused) { t.Fatalf("no version: %v, want a refusal", err) } os.WriteFile(filepath.Join(dd, seeds.FloorFile), []byte(`{garbage`), 0o644) if err := seedsActivationRefusal("v0.30.0", dd); !errors.As(err, &refused) { t.Fatalf("an unreadable seeds floor: %v, want a refusal", err) } } // Task 2's review: with the seeds floor armed and a carlos.prev that has no // version sidecar, the seeds verdict is withdrawn (and not written), so the // units' floor gate fails and the current binary runs. func TestJudgeSeedsPrevDeniesACarlosPrevWithNoVersion(t *testing.T) { opt, dd := t.TempDir(), t.TempDir() os.WriteFile(systemPrevPath(opt), []byte("#!/bin/sh\n"), 0o755) armLocalSeedsFloor(t, dd, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) writeFileDurable(seedsPrevAllowedPath(opt), []byte("v0.27.0\n"), 0o644) if err := judgeSeedsPrev(opt, dd); err != nil { t.Fatal(err) } if _, err := os.Stat(seedsPrevAllowedPath(opt)); !errors.Is(err, os.ErrNotExist) { t.Fatal("a seeds verdict for a carlos.prev that records no version") } } // The apply arms the seeds floor from the store before it stages anything, // and a candidate below it is refused with SD-N1, never staged or run. func TestSystemApplyArmsTheSeedsFloorBeforeStaging(t *testing.T) { f, _ := floorFixture(t, "v0.28.0", "v0.29.0") putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"}) staged := filepath.Join(f.a.stagingDir(), "carlos") run := f.a.ops.run f.a.ops.run = func(ctx context.Context, d time.Duration, name string, args ...string) (string, error) { if name == staged { t.Errorf("a candidate below the seeds floor was staged and run: %s %v", name, args) } return run(ctx, d, name, args...) } if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } if lf, err := readLocalSeedsFloor(f.a.dataDir); err != nil || lf == nil || lf.MinRelease != "v0.30.0" { t.Fatalf("local seeds floor = %+v, %v", lf, err) } r := f.result() if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "seeds protocol floor: v0.29.0 is below v0.30.0") { t.Fatalf("result = %+v", r) } if f.onDisk() != "v0.28.0" { t.Fatalf("on disk %q", f.onDisk()) } if f, err := readLocalFloor(f.a.dataDir); f != nil || err != nil { t.Fatalf("the moves floor read as %+v, %v", f, err) } } // A seeds floor this box could not write down defers the apply; a store // that cannot be read is a log line and the apply goes on. func TestArmSeedsFloor(t *testing.T) { f, _ := floorFixture(t, "v0.28.0", "v0.29.0") putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.27.0"}) f.a.store = f.store // .moves is a file: floor.lock cannot be made beside it. os.WriteFile(filepath.Join(f.a.dataDir, ".moves"), nil, 0o644) if err := f.a.armSeedsFloor(context.Background()); !errors.Is(err, errSystemDeferred) || !strings.Contains(err.Error(), "seeds protocol floor") { t.Fatalf("unwritable: %v, want a deferral", err) } os.Remove(filepath.Join(f.a.dataDir, ".moves")) var said []string f.a.ops.logf = func(format string, args ...any) { said = append(said, fmt.Sprintf(format, args...)) } f.a.store = unreadableStore{f.store} if err := f.a.armSeedsFloor(context.Background()); err != nil { t.Fatalf("unreadable store: %v", err) } if len(said) != 1 || !strings.Contains(said[0], "the seeds floor on this box stays none") { t.Fatalf("logged %q", said) } f.a.store = f.store if err := f.a.armSeedsFloor(context.Background()); err != nil { t.Fatal(err) } if lf, err := readLocalSeedsFloor(f.a.dataDir); err != nil || lf == nil || lf.MinRelease != "v0.27.0" { t.Fatalf("local seeds floor = %+v, %v", lf, err) } } // The install's backup of the running build is judged against the seeds // floor as well, and a replacement withdraws the old seeds verdict first. func TestInstallJudgesCarlosPrevByTheSeedsFloor(t *testing.T) { for _, tc := range []struct { name, running string verdict bool }{ {"at or above the seeds floor", "v0.31.0", true}, {"below the seeds floor", "v0.29.0", false}, } { t.Run(tc.name, func(t *testing.T) { f, _ := floorFixture(t, tc.running, "v0.32.0") putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"}) // A verdict from an earlier judgement must not outlive this one. writeFileDurable(seedsPrevAllowedPath(f.a.optDir), []byte("v0.30.0\n"), 0o644) if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0" { t.Fatalf("result = %+v, on disk %q", r, f.onDisk()) } if line, ok := readLine(prevVersionPath(f.a.optDir)); !ok || line != tc.running { t.Fatalf("sidecar = %q, %v", line, ok) } b, err := os.ReadFile(seedsPrevAllowedPath(f.a.optDir)) if tc.verdict != (err == nil) || (tc.verdict && string(b) != "v0.30.0\n") { t.Fatalf("seeds verdict = %q, %v; want present %v", b, err, tc.verdict) } if _, err := os.Stat(prevAllowedPath(f.a.optDir)); !errors.Is(err, os.ErrNotExist) { t.Fatal("a moves verdict with no moves floor") } }) } } // Boot recovery runs before the network: the seeds floor it enforces is // the one on disk, and the version it judges is carlos.prev's sidecar. func TestBootRecoveryUsesTheArmedSeedsFloorOffline(t *testing.T) { for _, tc := range []struct { name, sidecar string restored bool }{ {"below the seeds floor", "v0.29.0", false}, {"at the seeds floor", "v0.30.0", true}, {"no sidecar", "", false}, } { t.Run(tc.name, func(t *testing.T) { f, _ := floorFixture(t, "v0.31.0", "v0.32.0") f.a.openStore = func(context.Context) (release.Store, ed25519.PublicKey, error) { return unreadableStore{f.store}, f.pub, nil } armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.30.0"}) prev := tc.sidecar if prev == "" { prev = "v0.30.0" } f.brokenBox("v0.31.0", prev, tc.sidecar) f.edgeStartedThisBoot = false err := f.a.Run(context.Background(), true) j, _ := readSystemJournal(f.a.optDir) if tc.restored { if err != nil || f.onDisk() != "v0.30.0" || j != nil { t.Fatalf("err %v, on disk %q, journal %+v", err, f.onDisk(), j) } return } if err == nil || !strings.Contains(err.Error(), "seeds protocol floor") { t.Fatalf("recovery err = %v", err) } if f.onDisk() != "v0.31.0" || j == nil { t.Fatalf("a refused recovery changed the box: on disk %q, journal %+v", f.onDisk(), j) } }) } } // A gated journal is finished forward only when its target is at or above // the seeds floor armed now; below it, recovery goes back to carlos.prev. func TestForwardRecoveryChecksTheSeedsFloor(t *testing.T) { for _, tc := range []struct { name, prev, want string ok bool }{ {"carlos.prev below the seeds floor too", "v0.30.0", "v0.31.0", false}, {"carlos.prev at the seeds floor", "v0.32.0", "v0.32.0", true}, } { t.Run(tc.name, func(t *testing.T) { f, _ := floorFixture(t, "v0.30.0", "v0.31.0") f.brokenBox("v0.31.0", tc.prev, tc.prev) f.a.journal(systemJournal{Phase: systemPhaseGated, Target: "v0.31.0", PrevBuild: tc.prev, StartedAt: f.now}) armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.32.0"}) f.edgeStartedThisBoot = false err := f.a.Run(context.Background(), true) if ap, _ := readSystemApplied(f.a.optDir); ap != nil && ap.Version == "v0.31.0" { t.Fatalf("a target below the seeds floor was finished forward: %+v", ap) } j, _ := readSystemJournal(f.a.optDir) if f.onDisk() != tc.want { t.Fatalf("on disk %q, want %s", f.onDisk(), tc.want) } if tc.ok && (err != nil || j != nil) { t.Fatalf("err %v, journal %+v", err, j) } if !tc.ok && (err == nil || !strings.Contains(err.Error(), "seeds protocol floor") || j == nil) { t.Fatalf("err %v, journal %+v", err, j) } // Refused before the forward path stages a plan from the binary // on disk, not only at the finish's guard. for _, c := range f.calls { if strings.HasPrefix(c, "run carlos bootstrap") { t.Fatalf("the forward path staged a plan for a target below the seeds floor: %s", c) } } }) } } // Replacing carlos.prev withdraws the old seeds verdict before the copy, so // a copy that dies part way leaves no seeds verdict on the new bytes. func TestReplacingCarlosPrevForgetsTheOldSeedsVerdictFirst(t *testing.T) { f, _ := floorFixture(t, "v0.31.0", "v0.32.0") putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"}) os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0.30.0\n"), 0o755) writePrevVersion(f.a.optDir, "v0.30.0") writeFileDurable(seedsPrevAllowedPath(f.a.optDir), []byte("v0.30.0\n"), 0o644) // Prevent publication without depending on the writer's temporary name // (as TestDecidePrevForgetsTheOldVerdictBeforeTheCopy does). if err := os.Remove(systemPrevPath(f.a.optDir)); err != nil { t.Fatal(err) } if err := os.Mkdir(systemPrevPath(f.a.optDir), 0o755); err != nil { t.Fatal(err) } if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } if r := f.result(); r == nil || r.Outcome != systemOutcomeRefused { t.Fatalf("result = %+v", r) } if _, err := os.Stat(seedsPrevAllowedPath(f.a.optDir)); !errors.Is(err, os.ErrNotExist) { t.Fatal("the seeds verdict survived a failed replacement of carlos.prev") } } // An armed seeds floor arms the floor's own steps (floorStep): a failure in // them is the apply's error, as with the moves floor. With no floor of // either kind the same failure is a log line and the install goes on. func TestAnArmedSeedsFloorMakesFloorStepsFatal(t *testing.T) { for _, armed := range []bool{true, false} { t.Run(fmt.Sprintf("armed=%v", armed), func(t *testing.T) { f, _ := floorFixture(t, "v0.31.0", "v0.32.0") if armed { putSeedsFloor(t, f.store, seeds.Floor{Min: 1, MinRelease: "v0.30.0"}) } // carlos.prev's lock cannot be opened. os.MkdirAll(filepath.Join(systemRootDir(f.a.optDir), prevLockFile), 0o755) f.a.Run(context.Background(), false) r := f.result() if armed && (r == nil || r.Outcome == systemOutcomeApplied || f.onDisk() != "v0.31.0") { t.Fatalf("armed: result %+v, on disk %q", r, f.onDisk()) } if !armed && (r == nil || r.Outcome != systemOutcomeApplied || f.onDisk() != "v0.32.0") { t.Fatalf("unarmed: result %+v, on disk %q", r, f.onDisk()) } }) } } // The seeds floor raised while the candidate is staged: the rename's guard // refuses it. func TestSeedsFloorIsCheckedAgainAtTheRename(t *testing.T) { f, _ := floorFixture(t, "v0.31.0", "v0.32.0") f.duringStaging(func() { armLocalSeedsFloor(t, f.a.dataDir, seeds.Floor{Min: 1, MinRelease: "v0.33.0"}) }) if err := f.a.Run(context.Background(), false); err != nil { t.Fatal(err) } r := f.result() if r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "seeds protocol floor: v0.32.0 is below v0.33.0") { t.Fatalf("result = %+v", r) } if f.onDisk() != "v0.31.0" { t.Fatalf("on disk %q", f.onDisk()) } }