| index 3aceda729..85ce69beb 100644 |
| --- a/internal/host/seedsfloor_test.go |
| +++ b/internal/host/seedsfloor_test.go |
| @@ -3,11 +3,17 @@ 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" |
| @@ -266,3 +272,378 @@ func TestMovesFloorRefreshArmsTheSeedsFloor(t *testing.T) { |
| 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, and only when the moves clause allows it too. |
| +func TestSystemUnitsSelectPrevByTheSeedsFloor(t *testing.T) { |
| + if runtime.GOOS == "windows" { |
| + t.Skip("no /bin/sh") |
| + } |
| + 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) |
| + } |
| + } |
| + 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 |
| + want string |
| + }{ |
| + {"no seeds floor", false, "", "", "", "", "carlos.prev"}, |
| + {"no seeds floor, no journal", true, "", "", "", "", "carlos"}, |
| + {"an empty seeds floor file", false, "", "", empty, "", "carlos.prev"}, |
| + {"seeds floor and a verdict naming it", false, "", "", "v0.27.0\n", "v0.27.0\n", "carlos.prev"}, |
| + {"seeds floor and no verdict", false, "", "", "v0.27.0\n", "", "carlos"}, |
| + {"seeds floor and an empty verdict", false, "", "", "v0.27.0\n", empty, "carlos"}, |
| + {"a seeds verdict for an older floor", false, "", "", "v0.27.0\n", "v0.26.0\n", "carlos"}, |
| + {"a seeds verdict, no journal", true, "", "", "v0.27.0\n", "v0.27.0\n", "carlos"}, |
| + {"both floors, both verdicts", false, "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "v0.27.0\n", "carlos.prev"}, |
| + {"both floors, only the moves verdict", false, "v0.30.0\n", "v0.30.0\n", "v0.27.0\n", "", "carlos"}, |
| + {"both floors, only the seeds verdict", false, "v0.30.0\n", "", "v0.27.0\n", "v0.27.0\n", "carlos"}, |
| + {"the moves verdict in the seeds verdict's place", false, "", "v0.27.0\n", "v0.27.0\n", "", "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) |
| + for _, name := range []string{"carlos", "carlos.prev"} { |
| + os.WriteFile(filepath.Join(opt, name), []byte("#!/bin/sh\necho "+name+" > "+ran+"\n"), 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) |
| + put(filepath.Join(data, seeds.FloorMinReleaseFile), tc.seedsFloor) |
| + put(filepath.Join(opt, seeds.PrevAllowedFile), tc.seedsOK) |
| + script := strings.ReplaceAll(strings.ReplaceAll(body, "/data/", data+"/"), "/opt/carlos/", opt+"/") |
| + if out, err := exec.Command("/bin/sh", "-c", script).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' cmp 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) |
| + // The copy's temp file cannot be created. |
| + os.Mkdir(systemPrevPath(f.a.optDir)+".tmp", 0o755) |
| + 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()) |
| + } |
| +} |