carlos / platform Public

host: carlos system apply — install, gate, arm, record

.prev refreshed only when the disk is known good; both unit sets
staged from the binaries that own them; quiesce before the rename;
sysusers before tmpfiles; reset-failed before restart; a 15 s soak on
NRestarts; arming after the gate via systemctlPlan.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
Paul Campbell pushed by paul@keymail.dev 1c33565d2f5257c54cdd919e77c9026ca0508a87 parent 309e10b
2 files changed, +515 −5
  • internal/host/systemapply.go +344 −5
  • internal/host/systemapply_test.go +171 −0
diff --git a/internal/host/systemapply.go b/internal/host/systemapply.go
index df6995191..1513756a9 100644
--- a/internal/host/systemapply.go
+++ b/internal/host/systemapply.go
@@ -42,9 +42,8 @@ var systemQuiesceUnits = []string{
}
var (
- errSystemRefused = errors.New("refused")
- errSystemDeferred = errors.New("deferred")
- errNotYetImplemented = errors.New("system apply: install path not implemented")
+ errSystemRefused = errors.New("refused")
+ errSystemDeferred = errors.New("deferred")
// errSystemUnsupported is what the non-linux build's primitives return.
// It is declared here so Run can tell it from a lock another apply holds
@@ -150,11 +149,351 @@ func (a *systemApplier) Run(ctx context.Context, recover bool) error {
if err != nil {
return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err))
}
- return a.install(ctx, req, p, staged) // Task 8b
+ return a.install(ctx, req, p, staged)
}
+// install is steps 6–12: decide .prev, stage both unit sets, then the
+// box-changing dance — each step journaled before it happens, and any
+// failure from the quiesce on going to the rollback of step 13.
func (a *systemApplier) install(ctx context.Context, req systemRequest, p *release.SystemPointer, staged string) error {
- return errNotYetImplemented
+ target := p.Current.Version
+ prevOK, err := a.decidePrev(ctx)
+ if err != nil {
+ return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err))
+ }
+ if !prevOK {
+ return a.record(req, fmt.Errorf("%w: no known-good carlos.prev to fall back to", errSystemRefused))
+ }
+ newDir, prevDir := filepath.Join(a.stagingDir(), "new"), filepath.Join(a.stagingDir(), "prev")
+ newPlan, newUnits, err := a.stagePlan(ctx, staged, newDir)
+ if err != nil {
+ return a.record(req, fmt.Errorf("%w: candidate bootstrap: %v", errSystemRefused, err))
+ }
+ prevPlan, prevUnits, err := a.stagePlan(ctx, systemPrevPath(a.optDir), prevDir)
+ if err != nil {
+ return a.record(req, fmt.Errorf("%w: carlos.prev bootstrap: %v", errSystemRefused, err))
+ }
+ j := systemJournal{Phase: systemPhaseInstalling, Target: target, PrevBuild: a.prevBuild(ctx),
+ UnitsAdded: diff(newUnits, prevUnits), UnitsRemoved: diff(prevUnits, newUnits), StartedAt: a.ops.now().UTC()}
+ if err := a.journal(j); err != nil {
+ return err
+ }
+ fail := func(step string, err error) error {
+ return a.rollback(ctx, req, fmt.Sprintf("%s: %v", step, err), &j, prevPlan)
+ }
+ // Stop the root passes that exec the old binary against the old edge
+ // before the rename; one that is already running is killed, and those
+ // passes are idempotent.
+ if _, err := a.ops.systemctl(append([]string{"stop"}, systemQuiesceUnits...)...); err != nil {
+ a.ops.logf("system apply: quiesce: %v (continuing)", err)
+ }
+ if err := os.Rename(staged, systemBinaryPath(a.optDir)); err != nil {
+ return fail("install", err)
+ }
+ if err := os.Chown(systemBinaryPath(a.optDir), 0, 0); err != nil && !errors.Is(err, os.ErrPermission) {
+ a.ops.logf("system apply: chown root: %v", err)
+ }
+ j.Phase = systemPhaseInstalled
+ if err := a.journal(j); err != nil {
+ return fail("journal", err)
+ }
+ if err := a.installUnits(newDir, j.UnitsRemoved); err != nil {
+ return fail("units", err)
+ }
+ j.Phase = systemPhaseUnits
+ if err := a.journal(j); err != nil {
+ return fail("journal", err)
+ }
+ if err := a.restartEdge(); err != nil {
+ return fail("restart", err)
+ }
+ j.Phase = systemPhaseRestarted
+ if err := a.journal(j); err != nil {
+ return fail("journal", err)
+ }
+ if err := a.gate(ctx, target); err != nil {
+ return fail("gate", err)
+ }
+ j.Phase = systemPhaseGated
+ if err := a.journal(j); err != nil {
+ return fail("journal", err)
+ }
+ return a.finish(req, target, newPlan)
+}
+
+// finish is steps 11–12, and what recovery runs forward from phase gated.
+func (a *systemApplier) finish(req systemRequest, target string, plan bootstrapPlan) error {
+ if err := a.arm(plan); err != nil {
+ a.ops.logf("system apply: arm: %v", err)
+ }
+ if err := writeJSONDurable(a.file("applied.json"), systemApplied{Version: target, Channel: a.channel, At: a.ops.now().UTC()}, 0o644); err != nil {
+ return err
+ }
+ if err := a.writeResult(req, systemOutcomeApplied, ""); err != nil {
+ return err
+ }
+ os.Remove(a.file("journal.json"))
+ return syncDirBestEffort(a.rootDir())
+}
+
+// decidePrev refreshes carlos.prev from the on-disk binary only when that
+// binary is known good — applied.json names it, or nothing has ever been
+// applied and there is no .prev (a hand-rolled box whose running binary
+// passed the script's own post-condition). Otherwise it leaves .prev alone
+// and reports whether there is one to fall back to at all.
+func (a *systemApplier) decidePrev(ctx context.Context) (bool, error) {
+ prev := systemPrevPath(a.optDir)
+ _, prevErr := os.Stat(prev)
+ applied, err := readSystemApplied(a.optDir)
+ if err != nil {
+ return false, err
+ }
+ disk := a.onDiskBuild(ctx)
+ known := (applied != nil && applied.Version == disk && disk != "") || (applied == nil && prevErr != nil)
+ if !known {
+ return prevErr == nil, nil
+ }
+ if err := a.journal(systemJournal{Phase: systemPhaseBackingUp, Target: "", PrevBuild: disk, StartedAt: a.ops.now().UTC()}); err != nil {
+ return false, err
+ }
+ if err := copyFileDurable(systemBinaryPath(a.optDir), prev, 0o755); err != nil {
+ return false, err
+ }
+ os.Remove(a.file("journal.json"))
+ return true, nil
+}
+
+func (a *systemApplier) prevBuild(ctx context.Context) string {
+ out, err := a.ops.run(ctx, systemVersionWait, systemPrevPath(a.optDir), "version")
+ if err != nil {
+ return ""
+ }
+ return carlosVersionOf(out)
+}
+
+// stagePlan runs `<binary> bootstrap -root <dir> -plan-json` and returns
+// that binary's arming plan and the unit files it staged.
+func (a *systemApplier) stagePlan(ctx context.Context, binary, dir string) (bootstrapPlan, []string, error) {
+ os.RemoveAll(dir)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return bootstrapPlan{}, nil, err
+ }
+ out, err := a.ops.run(ctx, systemBootstrapWait, binary, "bootstrap", "-root", dir, "-plan-json")
+ if err != nil {
+ return bootstrapPlan{}, nil, err
+ }
+ var plan bootstrapPlan
+ if err := json.Unmarshal([]byte(out), &plan); err != nil {
+ return bootstrapPlan{}, nil, fmt.Errorf("plan: %w", err)
+ }
+ entries, err := os.ReadDir(filepath.Join(dir, "etc", "systemd", "system"))
+ if err != nil {
+ return bootstrapPlan{}, nil, err
+ }
+ var units []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ units = append(units, e.Name())
+ }
+ }
+ return plan, units, nil
+}
+
+func (a *systemApplier) journal(j systemJournal) error {
+ return writeJSONDurable(a.file("journal.json"), j, 0o600)
+}
+
+// installUnits removes what the target build dropped, copies its units in,
+// then sysusers BEFORE tmpfiles — tmpfiles silently drops a line whose
+// group does not resolve, and carlos-edge never starts again after the
+// next boot — then daemon-reload. Never the staging root's data/: it holds
+// a 0-byte registry.db.
+func (a *systemApplier) installUnits(stagedRoot string, remove []string) error {
+ unitDir := filepath.Join(a.etcDir, "systemd", "system")
+ for _, u := range remove {
+ if _, err := a.ops.systemctl("disable", "--now", u); err != nil {
+ a.ops.logf("system apply: disable %s: %v", u, err)
+ }
+ os.Remove(filepath.Join(unitDir, u))
+ }
+ entries, err := os.ReadDir(filepath.Join(stagedRoot, "etc", "systemd", "system"))
+ if err != nil {
+ return err
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ if err := copyFileDurable(filepath.Join(stagedRoot, "etc", "systemd", "system", e.Name()), filepath.Join(unitDir, e.Name()), 0o644); err != nil {
+ return err
+ }
+ }
+ sys := filepath.Join(stagedRoot, "etc", "sysusers.d", "carlos.conf")
+ if _, err := os.Stat(sys); err == nil {
+ if err := os.MkdirAll(filepath.Join(a.etcDir, "sysusers.d"), 0o755); err != nil {
+ return err
+ }
+ dst := filepath.Join(a.etcDir, "sysusers.d", "carlos.conf")
+ if err := copyFileDurable(sys, dst, 0o644); err != nil {
+ return err
+ }
+ if _, err := a.ops.run(context.Background(), systemVersionWait, "systemd-sysusers", dst); err != nil {
+ return fmt.Errorf("systemd-sysusers: %w", err)
+ }
+ }
+ tmp := filepath.Join(stagedRoot, "etc", "tmpfiles.d", "carlos.conf")
+ if err := os.MkdirAll(filepath.Join(a.etcDir, "tmpfiles.d"), 0o755); err != nil {
+ return err
+ }
+ dst := filepath.Join(a.etcDir, "tmpfiles.d", "carlos.conf")
+ if err := copyFileDurable(tmp, dst, 0o644); err != nil {
+ return err
+ }
+ if _, err := a.ops.run(context.Background(), systemVersionWait, "systemd-tmpfiles", "--create", dst); err != nil {
+ a.ops.logf("system apply: systemd-tmpfiles: %v (continuing, as the script does)", err)
+ }
+ _, err = a.ops.systemctl("daemon-reload")
+ return err
+}
+
+// restartEdge resets the failure counter FIRST — a unit that hit its start
+// limit refuses restart, and so would the rollback — then restarts.
+func (a *systemApplier) restartEdge() error {
+ a.ops.systemctl("reset-failed", systemEdgeUnit)
+ _, err := a.ops.systemctl("restart", systemEdgeUnit)
+ return err
+}
+
+// gate: within gateWait the edge is active, answers on loopback, and the
+// installed binary reports the target; then a soak across which neither
+// NRestarts nor ActiveEnterTimestampMonotonic moves, so a build that
+// starts, answers and dies a second later is caught here.
+func (a *systemApplier) gate(ctx context.Context, target string) error {
+ wait, soak := a.gateWait, a.soak
+ if wait <= 0 {
+ wait = systemGateWait
+ }
+ if soak <= 0 {
+ soak = systemGateSoak
+ }
+ deadline := a.ops.now().Add(wait)
+ var lastErr error
+ for {
+ lastErr = a.gateOnce(ctx, target)
+ if lastErr == nil {
+ break
+ }
+ if !a.ops.now().Before(deadline) {
+ return lastErr
+ }
+ a.ops.sleep(time.Second)
+ }
+ before, err := a.ops.systemctl("show", "-p", "NRestarts", "-p", "ActiveEnterTimestampMonotonic", "--value", systemEdgeUnit)
+ if err != nil {
+ return err
+ }
+ a.ops.sleep(soak)
+ after, err := a.ops.systemctl("show", "-p", "NRestarts", "-p", "ActiveEnterTimestampMonotonic", "--value", systemEdgeUnit)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(before) != strings.TrimSpace(after) {
+ return fmt.Errorf("edge restarted during the soak (%s → %s)",
+ strings.ReplaceAll(strings.TrimSpace(before), "\n", "/"), strings.ReplaceAll(strings.TrimSpace(after), "\n", "/"))
+ }
+ return a.gateOnce(ctx, target)
+}
+
+func (a *systemApplier) gateOnce(ctx context.Context, target string) error {
+ if out, err := a.ops.systemctl("is-active", systemEdgeUnit); err != nil || strings.TrimSpace(out) != "active" {
+ return fmt.Errorf("%s is %s", systemEdgeUnit, strings.TrimSpace(out))
+ }
+ if err := a.ops.probeEdge(); err != nil {
+ return fmt.Errorf("edge probe: %w", err)
+ }
+ if got := a.onDiskBuild(ctx); got != target {
+ return fmt.Errorf("installed binary reports %q, not %s", got, target)
+ }
+ return nil
+}
+
+// arm is systemctlPlan verbatim: enable all, start timers and paths, never
+// a service — which is why apply can call it from inside itself.
+func (a *systemApplier) arm(plan bootstrapPlan) error {
+ for _, args := range systemctlPlan(plan.Enabled) {
+ if _, err := a.ops.systemctl(args...); err != nil {
+ return fmt.Errorf("systemctl %s: %w", strings.Join(args, " "), err)
+ }
+ }
+ return nil
+}
+
+// rollback is step 13 (Task 8c). Until then it records the outcome the
+// box is actually in and fails loudly rather than pretending.
+func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason string, j *systemJournal, prevPlan bootstrapPlan) error {
+ _ = a.writeResult(req, systemOutcomeRolledBack, reason)
+ return errors.New("rollback not implemented: " + reason)
+}
+
+// diff is have − drop, order preserved.
+func diff(have, drop []string) []string {
+ skip := map[string]bool{}
+ for _, d := range drop {
+ skip[d] = true
+ }
+ var out []string
+ for _, h := range have {
+ if !skip[h] {
+ out = append(out, h)
+ }
+ }
+ return out
+}
+
+// copyFileDurable is writeFileDurable for a file too large to hold in
+// memory: temp, fsync, rename, directory fsync.
+func copyFileDurable(src, dst string, mode os.FileMode) (err error) {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ tmp := dst + ".tmp"
+ out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ out.Close()
+ os.Remove(tmp)
+ }
+ }()
+ if _, err = io.Copy(out, in); err != nil {
+ return err
+ }
+ if err = out.Chmod(mode); err != nil {
+ return err
+ }
+ if err = out.Sync(); err != nil {
+ return err
+ }
+ if err = out.Close(); err != nil {
+ return err
+ }
+ if err = os.Rename(tmp, dst); err != nil {
+ return err
+ }
+ return syncDirBestEffort(filepath.Dir(dst))
+}
+
+func syncDirBestEffort(dir string) error {
+ d, err := os.Open(dir)
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ return d.Sync()
}
func (a *systemApplier) recoverIfNeeded(ctx context.Context) error {
diff --git a/internal/host/systemapply_test.go b/internal/host/systemapply_test.go
index 3a5aca1d8..090a20600 100644
--- a/internal/host/systemapply_test.go
+++ b/internal/host/systemapply_test.go
@@ -66,6 +66,14 @@ func newApplyFixture(t *testing.T, channel, build string) *applyFixture {
if v, ok := f.answer["version:"+name]; ok {
return v, nil
}
+ // With no scripted answer the "binary" speaks for its own
+ // bytes ("#!/bin/sh\necho carlos vN"), so a rename over it
+ // changes what it reports — which is what the install does.
+ if b, err := os.ReadFile(name); err == nil {
+ if _, rest, ok := strings.Cut(string(b), "echo "); ok {
+ return strings.SplitN(rest, "\n", 2)[0] + "\n", nil
+ }
+ }
return "", errors.New("no such binary")
}
if len(args) >= 3 && args[0] == "bootstrap" && args[1] == "-root" {
@@ -89,6 +97,10 @@ func newApplyFixture(t *testing.T, channel, build string) *applyFixture {
os.WriteFile(filepath.Join(args[2], "data", "registry.db"), nil, 0o640)
return plan, nil
}
+ // The two config appliers the install shells out to.
+ if b := filepath.Base(name); b == "systemd-sysusers" || b == "systemd-tmpfiles" {
+ return "", nil
+ }
return "", errors.New("unexpected command")
},
now: func() time.Time { return f.now },
@@ -370,3 +382,162 @@ func TestApplyStagesTheCandidateFromARootOwnedCopy(t *testing.T) {
t.Fatal("followed a symlinked cache file")
}
}
+
+// applyHappy sets up a v1 box with a v2 candidate whose fake bootstrap
+// plan adds carlos-new-only.path and drops carlos-old-only.timer.
+func applyHappy(t *testing.T) (*applyFixture, systemRequest) {
+ f := newApplyFixture(t, "stable", "v1")
+ body := []byte("#!/bin/sh\necho carlos v2\n")
+ req := f.promote("v2", body, false)
+ staged := filepath.Join(f.a.stagingDir(), "carlos")
+ f.answer["version:"+staged] = "carlos v2\n"
+ f.answer["plan:"+staged] = `{"enabled":["carlos-edge.service","carlos-adopt.timer","carlos-adopt.path","carlos-stage.path","carlos-restart.path","carlos-system-update.path","carlos-system-recover.service","carlos-new-only.path"],"inert":["carlos-restore-verify.timer"]}`
+ f.answer["units:"+staged] = "carlos-edge.service carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-system-update.service carlos-system-recover.service carlos-new-only.path"
+ // .prev, once made, answers as v1 and stages v1's units.
+ prev := systemPrevPath(f.a.optDir)
+ f.answer["version:"+prev] = "carlos v1\n"
+ f.answer["plan:"+prev] = f.answer["plan:"+systemBinaryPath(f.a.optDir)]
+ f.answer["units:"+prev] = f.answer["units:"+systemBinaryPath(f.a.optDir)]
+ // The on-disk binary answers from its own bytes, so it says v1 until
+ // the rename and v2 after it — a scripted answer would make the run a
+ // no-op before it started.
+ delete(f.answer, "version:"+systemBinaryPath(f.a.optDir))
+ return f, req
+}
+
+func TestApplyInstallsGatesArmsAndRecords(t *testing.T) {
+ f, req := applyHappy(t)
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ r := f.result()
+ if r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) {
+ t.Fatalf("result = %+v", r)
+ }
+ ap, _ := readSystemApplied(f.a.optDir)
+ if ap == nil || ap.Version != "v2" || ap.Channel != "stable" {
+ t.Fatalf("applied = %+v", ap)
+ }
+ if j, _ := readSystemJournal(f.a.optDir); j != nil {
+ t.Fatalf("journal left open: %+v", j)
+ }
+ if _, err := os.Stat(f.a.stagingDir()); err == nil {
+ t.Fatal("staging/ left behind")
+ }
+ bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
+ if !strings.Contains(string(bin), "carlos v2") {
+ t.Fatal("binary not swapped")
+ }
+ prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
+ if !strings.Contains(string(prev), "carlos v1") {
+ t.Fatal(".prev is not the old binary")
+ }
+ units := filepath.Join(f.a.etcDir, "systemd", "system")
+ if _, err := os.Stat(filepath.Join(units, "carlos-new-only.path")); err != nil {
+ t.Error("new unit not installed")
+ }
+ if _, err := os.Stat(filepath.Join(units, "carlos-old-only.timer")); err == nil {
+ t.Error("removed unit still installed")
+ }
+ if _, err := os.Stat(filepath.Join(f.a.etcDir, "sysusers.d", "carlos.conf")); err != nil {
+ t.Error("sysusers not installed")
+ }
+ if _, err := os.Stat(filepath.Join(f.a.etcDir, "tmpfiles.d", "carlos.conf")); err != nil {
+ t.Error("tmpfiles not installed")
+ }
+ if _, err := os.Stat(filepath.Join(f.a.etcDir, "registry.db")); err == nil {
+ t.Error("staging data/ copied")
+ }
+ // Order of the systemctl calls is the spec.
+ joined := strings.Join(f.calls, "\n")
+ order := []string{
+ "systemctl stop carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-adopt.service carlos-config-sync.service carlos-stage.service carlos-restart.service",
+ "systemctl disable --now carlos-old-only.timer",
+ "run systemd-sysusers",
+ "run systemd-tmpfiles --create",
+ "systemctl daemon-reload",
+ "systemctl reset-failed carlos-edge",
+ "systemctl restart carlos-edge",
+ "systemctl is-active carlos-edge",
+ "systemctl daemon-reload",
+ "systemctl enable carlos-edge.service carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-system-recover.service carlos-new-only.path",
+ "systemctl start carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-new-only.path",
+ }
+ at := 0
+ for _, want := range order {
+ i := strings.Index(joined[at:], want)
+ if i < 0 {
+ t.Fatalf("call order broken: %q not found after position %d in:\n%s", want, at, joined)
+ }
+ at += i + len(want)
+ }
+ for _, c := range f.calls {
+ if strings.HasPrefix(c, "systemctl start ") && strings.Contains(c, ".service") {
+ t.Fatalf("apply started a service: %s", c)
+ }
+ }
+ if strings.Contains(joined, "systemctl enable --now") {
+ t.Fatal("enable --now is never used")
+ }
+}
+
+func TestApplyGateSoakCatchesAFlap(t *testing.T) {
+ f, req := applyHappy(t)
+ // Edge comes up, then restarts once during the soak.
+ restarts := 0
+ orig := f.a.ops.systemctl
+ f.a.ops.systemctl = func(args ...string) (string, error) {
+ if args[0] == "show" {
+ restarts++
+ if restarts > 1 {
+ return "1\n2000\n", nil
+ }
+ }
+ return orig(args...)
+ }
+ err := f.a.Run(context.Background(), false)
+ if err == nil {
+ t.Fatal("a flapping edge passed the gate")
+ }
+ r := f.result()
+ if r == nil || !r.matches("stable", "v2", req.PromotedAt) || r.Outcome == systemOutcomeApplied {
+ t.Fatalf("result = %+v", r)
+ }
+}
+
+func TestApplyPrevIsRefreshedOnlyWhenTheDiskIsKnownGood(t *testing.T) {
+ // applied.json names v0 but the disk says v1 (a rollback-failed left a
+ // bad v1 in place, .prev is the good v0): .prev must be left alone.
+ f, _ := applyHappy(t)
+ os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0\n"), 0o755)
+ f.answer["version:"+systemPrevPath(f.a.optDir)] = "carlos v0\n"
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "applied.json"), systemApplied{Version: "v0", Channel: "stable", At: f.now}, 0o644)
+ _ = f.a.Run(context.Background(), false)
+ prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
+ if !strings.Contains(string(prev), "carlos v0") {
+ t.Fatal(".prev was overwritten by a binary that never passed a gate")
+ }
+}
+
+func TestApplyPrevUnitsComeFromPrevNotFromDisk(t *testing.T) {
+ f, _ := applyHappy(t)
+ os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0\n"), 0o755)
+ f.answer["version:"+systemPrevPath(f.a.optDir)] = "carlos v0\n"
+ f.answer["plan:"+systemPrevPath(f.a.optDir)] = `{"enabled":["carlos-edge.service"],"inert":[]}`
+ f.answer["units:"+systemPrevPath(f.a.optDir)] = "carlos-edge.service carlos-v0-only.timer"
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "applied.json"), systemApplied{Version: "v0", Channel: "stable", At: f.now}, 0o644)
+ // Inspect the journal mid-flight by failing the gate.
+ f.edgeUp = false
+ _ = f.a.Run(context.Background(), false)
+ // units_removed must be computed against .prev's set (v0-only.timer),
+ // not the on-disk v1's (old-only.timer).
+ found := false
+ for _, c := range f.calls {
+ if c == "run carlos.prev bootstrap -root "+filepath.Join(f.a.stagingDir(), "prev")+" -plan-json" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("prev units not staged from carlos.prev:\n%s", strings.Join(f.calls, "\n"))
+ }
+}