host: establish a unit's running build from its bytes, and record settled builds as healthy
observe checks the mint conditions against /proc/<MainPID>/exe; recordSettled is the settled hook on checkedUnitRestart, run only when the settle check passed. Agent loops pass nil: only root records healthy builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+340
−13
internal/host/agent.go+14 −4internal/host/agent_test.go+2 −2internal/host/proofobserve.go+113 −0internal/host/proofobserve_test.go+189 −0internal/host/unitsettle.go+16 −1internal/host/unitsettle_test.go+6 −6
diff --git a/internal/host/agent.go b/internal/host/agent.go| index db6bc1eff..036fd7b46 100644 |
| --- a/internal/host/agent.go |
| +++ b/internal/host/agent.go |
| @@ -817,7 +817,7 @@ func adoptLoop(reg *registry.Registry, svc *activator.Service, sd *scheduleDeps) |
| return |
| } |
| optDir := release.OptDir() |
| - restart := makeRestartRoute(reg, systemctlTryRestart, checkedUnitRestart(systemdUnits{}, optDir, os.Getenv), adoptBounceFn(svc)) |
| + restart := makeRestartRoute(reg, systemctlTryRestart, checkedUnitRestart(systemdUnits{}, optDir, os.Getenv, nil), adoptBounceFn(svc)) |
| log.Printf("agent: adopt loop armed (hourly + jitter)") |
| // First pass shortly after boot (Eleven's OnBootSec=5min, compressed — |
| // the edge is already up by the time this fires), then hourly + jitter. |
| @@ -1929,7 +1929,7 @@ func fastConvergeLoop(reg *registry.Registry, svc *activator.Service, sd *schedu |
| // sweep instead of the sweep re-cycling the service every tick. The |
| // remaining fields are filled in right below. |
| d := &fastDeps{reg: reg, store: store, pub: pub, optDir: optDir} |
| - restart := d.observeRestart(makeRestartRoute(reg, systemctlTryRestart, checkedUnitRestart(systemdUnits{}, optDir, os.Getenv), adoptBounceFn(svc))) |
| + restart := d.observeRestart(makeRestartRoute(reg, systemctlTryRestart, checkedUnitRestart(systemdUnits{}, optDir, os.Getenv, nil), adoptBounceFn(svc))) |
| // Built once, used by both pass shapes below — see fastPassOpts for the |
| // DO-NOT list and why the stage requester is per-loop, not per-tick. |
| opts := fastPassOpts(dd, stageRequester(dd), restart, makeStartRoute(reg, systemctlStartUnit)) |
| @@ -3218,7 +3218,12 @@ func unitOnly(f func(unit string) error) func(rt registry.Route) error { |
| // when the unit's ExecStart runs THIS route's live link. Anything it cannot |
| // establish (show denied, wrong binding, unit stopped) falls back to today's |
| // plain try-restart and returns that command's own error. |
| -func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) string) func(rt registry.Route) error { |
| +// |
| +// settled runs only when the settle check passed (never for a unit stopped |
| +// during it, a failed settle or an unchecked fallback). The agent loops pass |
| +// nil: the sandboxed agent cannot hash another unit's executable, and spec |
| +// §4.5 has root record the healthy history. |
| +func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) string, settled func(rt registry.Route)) func(rt registry.Route) error { |
| return func(rt registry.Route) error { |
| before, err := sys.Show(rt.Unit) |
| if err != nil { |
| @@ -3254,6 +3259,11 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin |
| } |
| if err == nil { |
| log.Printf("adopt: %s: %s settled on %s", rt.Host, rt.Unit, filepath.Dir(want)) |
| + // D3 (proofobserve.go): a build that passed the settle check is a |
| + // build this box has run well. |
| + if settled != nil { |
| + settled(rt) |
| + } |
| } |
| return err |
| } |
| @@ -3336,7 +3346,7 @@ func systemctlStartUnit(unit string) error { |
| // wired only into the agent would therefore be a restart arm the consoles |
| // never reach. |
| func rootRestartRoute(reg *registry.Registry, optDir string) func(host string) error { |
| - return makeRestartRoute(reg, systemctlTryRestart, checkedUnitRestart(systemdUnits{}, optDir, os.Getenv), nil) |
| + return makeRestartRoute(reg, systemctlTryRestart, checkedUnitRestart(systemdUnits{}, optDir, os.Getenv, nil), nil) |
| } |
| // bounceFn adapts an activator to makeRestartRoute's bounce parameter, |
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go| index ff39be92e..5ffdde92b 100644 |
| --- a/internal/host/agent_test.go |
| +++ b/internal/host/agent_test.go |
| @@ -725,7 +725,7 @@ func TestRootRestartRouteCarriesTheNamedUnitArm(t *testing.T) { |
| // The checked arm reads the unit first (show), finds no binding it can |
| // verify from the fake's empty answer, and falls back to the plain |
| // try-restart: the argv this test pins is the last call. |
| - if want := "show console.service -p ActiveState -p SubState -p InvocationID -p MainPID -p NRestarts -p ExecStart\ntry-restart console.service\n"; string(got) != want { |
| + if want := "show console.service -p ActiveState -p SubState -p InvocationID -p MainPID -p NRestarts -p ExecStart -p ActiveEnterTimestamp\ntry-restart console.service\n"; string(got) != want { |
| t.Fatalf("systemctl calls = %q, want %q", got, want) |
| } |
| } |
| @@ -974,7 +974,7 @@ func TestAdoptPassStartsTheNamedUnitAgainAfterAFailedRestart(t *testing.T) { |
| } |
| // The checked arm's show comes first (its empty fake answer verifies no |
| // binding, so the restart falls back to the plain try-restart). |
| - if want := "show console.service -p ActiveState -p SubState -p InvocationID -p MainPID -p NRestarts -p ExecStart\ntry-restart console.service\nstart console.service\n"; string(got) != want { |
| + if want := "show console.service -p ActiveState -p SubState -p InvocationID -p MainPID -p NRestarts -p ExecStart -p ActiveEnterTimestamp\ntry-restart console.service\nstart console.service\n"; string(got) != want { |
| t.Fatalf("systemctl calls = %q, want %q — the rolled-back unit must be STARTED (try-restart would no-op on it)", got, want) |
| } |
| if v, err := release.LiveVersion(optDir, "console.test"); err != nil || v != "old" { |
diff --git a/internal/host/proofobserve.go b/internal/host/proofobserve.go| new file mode 100644 |
| index 000000000..fdad3fbcf |
| --- /dev/null |
| +++ b/internal/host/proofobserve.go |
| @@ -0,0 +1,113 @@ |
| +// What a named unit is running, established from the bytes — D3 of the |
| +// console release defences (spec 2026-10-06-console-release-defences-design.md |
| +// §4.2, §4.3, §4.5). |
| +// |
| +// observe checks the first three of spec §4.3's mint conditions: the binding |
| +// is verified, the unit is active in one invocation for at least `after`, the |
| +// sha256 of /proc/<MainPID>/exe equals the live link's bytes, and the route is |
| +// ready now. The root pass mints on it (proofroot.go) and records the build |
| +// in the healthy history on it. recordSettled is the other way into that |
| +// history: the hook D1's checkedUnitRestart runs when the settle check passes. |
| +// |
| +// Both paths hash the running executable's bytes. A sha256 is never taken |
| +// from a manifest or a version directory's name: the only build a box may |
| +// vouch for is the one it is running. |
| +package host |
| + |
| +import ( |
| + "fmt" |
| + "log" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type liveBuild struct { |
| + SHA256 string |
| + Version string |
| + ActiveSince time.Time |
| +} |
| + |
| +type proofObserver struct { |
| + sys unitSystem |
| + procExe func(pid int) string // "/proc/<pid>/exe" in production |
| + optDir string |
| + now func() time.Time |
| + ready func(rt registry.Route) func() error // routeReady in production; nil func = no upstream |
| +} |
| + |
| +func productionProofObserver(optDir string) proofObserver { |
| + return proofObserver{sys: systemdUnits{}, procExe: procExePath, optDir: optDir, now: time.Now, |
| + ready: func(rt registry.Route) func() error { return routeReady(rt, 2*time.Second) }} |
| +} |
| + |
| +func procExePath(pid int) string { return fmt.Sprintf("/proc/%d/exe", pid) } |
| + |
| +func (o proofObserver) observe(rt registry.Route, after time.Duration) (liveBuild, error) { |
| + link := release.LiveLink(o.optDir, rt.Host) |
| + before, err := o.sys.Show(rt.Unit) |
| + if err != nil { |
| + return liveBuild{}, fmt.Errorf("cannot read %s: %v", rt.Unit, err) |
| + } |
| + if before.ExecPath != link { |
| + return liveBuild{}, fmt.Errorf("binding: %s runs %s, not %s", rt.Unit, before.ExecPath, link) |
| + } |
| + if before.ActiveState != "active" || before.MainPID <= 0 { |
| + return liveBuild{}, fmt.Errorf("%s is %s", rt.Unit, before.ActiveState) |
| + } |
| + if before.ActiveEnterTimestamp.IsZero() { |
| + return liveBuild{}, fmt.Errorf("%s reports no ActiveEnterTimestamp", rt.Unit) |
| + } |
| + if up := o.now().Sub(before.ActiveEnterTimestamp); up < after { |
| + return liveBuild{}, fmt.Errorf("%s has been active for %s, needs %s", rt.Unit, up.Round(time.Second), after) |
| + } |
| + exe, err := release.FileSHA256(o.procExe(before.MainPID)) |
| + if err != nil { |
| + return liveBuild{}, fmt.Errorf("hashing the running executable: %v", err) |
| + } |
| + live, err := release.FileSHA256(link) |
| + if err != nil { |
| + return liveBuild{}, fmt.Errorf("hashing the live link's build: %v", err) |
| + } |
| + if exe != live { |
| + return liveBuild{}, fmt.Errorf("the running executable (sha256 %s) is not the live link's build (sha256 %s)", exe, live) |
| + } |
| + if o.ready != nil { |
| + if ready := o.ready(rt); ready != nil { |
| + if err := ready(); err != nil { |
| + return liveBuild{}, fmt.Errorf("not ready: %v", err) |
| + } |
| + } |
| + } |
| + again, err := o.sys.Show(rt.Unit) |
| + if err != nil || again.ActiveState != "active" || again.InvocationID != before.InvocationID || again.MainPID != before.MainPID { |
| + return liveBuild{}, fmt.Errorf("%s restarted during the check", rt.Unit) |
| + } |
| + ver, _ := release.LiveVersion(o.optDir, rt.Host) |
| + return liveBuild{SHA256: exe, Version: ver, ActiveSince: before.ActiveEnterTimestamp}, nil |
| +} |
| + |
| +// recordSettled is checkedUnitRestart's settled hook on the root pass: hash |
| +// the executable that just passed the settle check and add it to the app's |
| +// healthy history. Every failure is a log line; a missed record costs only a |
| +// proof wait later. |
| +func recordSettled(sys unitSystem, procExe func(pid int) string, optDir, bakeDir string, now func() time.Time) func(rt registry.Route) { |
| + return func(rt registry.Route) { |
| + p, err := sys.Show(rt.Unit) |
| + if err != nil || p.ActiveState != "active" || p.MainPID <= 0 { |
| + log.Printf("adopt: %s: settled, but cannot read %s's process for the healthy history (%v)", rt.Host, rt.Unit, err) |
| + return |
| + } |
| + sha, err := release.FileSHA256(procExe(p.MainPID)) |
| + if err != nil { |
| + log.Printf("adopt: %s: settled, but hashing its executable failed: %v", rt.Host, err) |
| + return |
| + } |
| + ver, _ := release.LiveVersion(optDir, rt.Host) |
| + if err := release.RecordHealthy(bakeDir, release.RouteAccount(rt), rt.App, |
| + release.HealthyBuild{SHA256: sha, Version: ver, How: "settle", At: now().UTC()}); err != nil { |
| + log.Printf("adopt: %s: recording the healthy build: %v", rt.Host, err) |
| + } |
| + } |
| +} |
diff --git a/internal/host/proofobserve_test.go b/internal/host/proofobserve_test.go| new file mode 100644 |
| index 000000000..2b7bce228 |
| --- /dev/null |
| +++ b/internal/host/proofobserve_test.go |
| @@ -0,0 +1,189 @@ |
| +package host |
| + |
| +import ( |
| + "errors" |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestParseShowReadsActiveEnterTimestamp(t *testing.T) { |
| + p := parseShow("ActiveState=active\nActiveEnterTimestamp=Mon 2026-10-06 09:30:30 UTC\n") |
| + if want := time.Date(2026, 10, 6, 9, 30, 30, 0, time.UTC); !p.ActiveEnterTimestamp.Equal(want) { |
| + t.Fatalf("ActiveEnterTimestamp = %v, want %v", p.ActiveEnterTimestamp, want) |
| + } |
| + if !parseShow("ActiveEnterTimestamp=\n").ActiveEnterTimestamp.IsZero() { |
| + t.Fatal("an empty timestamp is not zero") |
| + } |
| + if !parseShow("ActiveEnterTimestamp=n/a\n").ActiveEnterTimestamp.IsZero() { |
| + t.Fatal("n/a is not zero") |
| + } |
| +} |
| + |
| +const observedHost = "platform.example.com" |
| + |
| +// observeFixture: the route's live link names versions/v1/console, and by |
| +// default the unit runs that very file, bound, active for 20 minutes, ready. |
| +func observeFixture(t *testing.T) (proofObserver, registry.Route, string, time.Time) { |
| + t.Helper() |
| + opt := t.TempDir() |
| + liveTo(t, opt, observedHost, "v1") |
| + staged := filepath.Join(opt, "versions", "v1", "console") |
| + now := time.Date(2026, 10, 6, 10, 0, 0, 0, time.UTC) |
| + o := proofObserver{ |
| + sys: &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "i1", MainPID: 7, |
| + ExecPath: release.LiveLink(opt, observedHost), ActiveEnterTimestamp: now.Add(-20 * time.Minute)}}}, |
| + procExe: func(int) string { return staged }, |
| + optDir: opt, |
| + now: func() time.Time { return now }, |
| + ready: func(registry.Route) func() error { return nil }, |
| + } |
| + rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"} |
| + return o, rt, staged, now |
| +} |
| + |
| +func TestProofObserveAcceptsABakedBuild(t *testing.T) { |
| + o, rt, staged, now := observeFixture(t) |
| + b, err := o.observe(rt, 15*time.Minute) |
| + if err != nil { |
| + t.Fatalf("observe: %v", err) |
| + } |
| + want, _ := release.FileSHA256(staged) |
| + if b.SHA256 != want || b.Version != "v1" || !b.ActiveSince.Equal(now.Add(-20*time.Minute)) { |
| + t.Fatalf("build = %+v", b) |
| + } |
| +} |
| + |
| +// Spec §7: the mint conditions, each false in turn. |
| +func TestProofObserveConditionsEachFalse(t *testing.T) { |
| + for name, c := range map[string]struct { |
| + edit func(t *testing.T, o *proofObserver, now time.Time) |
| + want string |
| + }{ |
| + "binding": {func(t *testing.T, o *proofObserver, now time.Time) { |
| + o.sys.(*fakeUnits).shows[0].ExecPath = release.LiveLink(o.optDir, "console.example.com") |
| + }, "binding"}, |
| + "not active": {func(t *testing.T, o *proofObserver, now time.Time) { |
| + o.sys.(*fakeUnits).shows[0].ActiveState = "failed" |
| + }, "failed"}, |
| + "too young": {func(t *testing.T, o *proofObserver, now time.Time) { |
| + o.sys.(*fakeUnits).shows[0].ActiveEnterTimestamp = now.Add(-5 * time.Minute) |
| + }, "needs 15m"}, |
| + "restarted during the check": {func(t *testing.T, o *proofObserver, now time.Time) { |
| + f := o.sys.(*fakeUnits) |
| + second := f.shows[0] |
| + second.InvocationID = "i2" |
| + f.shows = append(f.shows, second) |
| + }, "restarted"}, |
| + "exe and live link differ": {func(t *testing.T, o *proofObserver, now time.Time) { |
| + other := filepath.Join(t.TempDir(), "console") |
| + if err := os.WriteFile(other, []byte("a different build"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + o.procExe = func(int) string { return other } |
| + }, "is not the live link's build"}, |
| + "not ready": {func(t *testing.T, o *proofObserver, now time.Time) { |
| + o.ready = func(registry.Route) func() error { return func() error { return errors.New("status 503") } } |
| + }, "not ready"}, |
| + } { |
| + t.Run(name, func(t *testing.T) { |
| + o, rt, _, now := observeFixture(t) |
| + c.edit(t, &o, now) |
| + if _, err := o.observe(rt, 15*time.Minute); err == nil || !strings.Contains(err.Error(), c.want) { |
| + t.Fatalf("observe = %v, want an error containing %q", err, c.want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// D1's settle passing is the hook that records a healthy build, hashed from |
| +// the executable that passed. |
| +func TestSettledRestartRecordsTheRunningExe(t *testing.T) { |
| + opt, bake := t.TempDir(), t.TempDir() |
| + liveTo(t, opt, observedHost, "new") |
| + staged, err := filepath.EvalSymlinks(filepath.Join(opt, "versions", "new", "console")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { ln.Close() }) |
| + rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service", Addr: ln.Addr().String()} |
| + f := &fakeUnits{shows: []unitProps{ |
| + {ActiveState: "active", InvocationID: "old", ExecPath: filepath.Join(opt, "live", observedHost)}, |
| + {ActiveState: "active", InvocationID: "new", MainPID: 7}, |
| + }, exe: map[int]string{7: staged}} |
| + settled := recordSettled(f, func(int) string { return staged }, opt, bake, time.Now) |
| + if err := checkedUnitRestart(f, opt, func(string) string { return "" }, settled)(rt); err != nil { |
| + t.Fatalf("restart: %v", err) |
| + } |
| + sha, _ := release.FileSHA256(staged) |
| + h := release.ReadHealthy(bake, "bab", "console") |
| + if len(h) != 1 || h[0].SHA256 != sha || h[0].How != "settle" || h[0].Version != "new" { |
| + t.Fatalf("history = %+v, want the settled build", h) |
| + } |
| +} |
| + |
| +func TestUnsettledRestartRecordsNothing(t *testing.T) { |
| + opt, bake := t.TempDir(), t.TempDir() |
| + liveTo(t, opt, observedHost, "new") |
| + rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"} |
| + f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "old", |
| + ExecPath: filepath.Join(opt, "live", "console.example.com")}}} |
| + settled := recordSettled(f, func(int) string { return "/nonexistent" }, opt, bake, time.Now) |
| + if err := checkedUnitRestart(f, opt, func(string) string { return "" }, settled)(rt); err != nil { |
| + t.Fatalf("restart: %v", err) |
| + } |
| + if h := release.ReadHealthy(bake, "bab", "console"); len(h) != 0 { |
| + t.Fatalf("a restart that was not checked recorded %+v", h) |
| + } |
| +} |
| + |
| +// F2: a unit an operator stopped during the settle is not judged, and its |
| +// build is not recorded as healthy, even though its executable is readable. |
| +func TestStoppedDuringSettleRecordsNothing(t *testing.T) { |
| + opt, bake := t.TempDir(), t.TempDir() |
| + liveTo(t, opt, observedHost, "new") |
| + staged := filepath.Join(opt, "versions", "new", "console") |
| + rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"} |
| + f := &fakeUnits{shows: []unitProps{ |
| + {ActiveState: "active", SubState: "running", InvocationID: "old", MainPID: 6, |
| + ExecPath: filepath.Join(opt, "live", observedHost)}, |
| + {ActiveState: "inactive", SubState: "dead", InvocationID: "old"}, |
| + }} |
| + settled := recordSettled(f, func(int) string { return staged }, opt, bake, time.Now) |
| + if err := checkedUnitRestart(f, opt, func(string) string { return "" }, settled)(rt); err != nil { |
| + t.Fatalf("restart: %v", err) |
| + } |
| + if h := release.ReadHealthy(bake, "bab", "console"); len(h) != 0 { |
| + t.Fatalf("a stopped unit recorded %+v", h) |
| + } |
| +} |
| + |
| +// A settle that fails records nothing either. |
| +func TestFailedSettleRecordsNothing(t *testing.T) { |
| + opt, bake := t.TempDir(), t.TempDir() |
| + liveTo(t, opt, observedHost, "new") |
| + staged := filepath.Join(opt, "versions", "new", "console") |
| + rt := registry.Route{Host: observedHost, App: "console", Unit: "console.service"} |
| + f := &fakeUnits{shows: []unitProps{ |
| + {ActiveState: "active", SubState: "running", InvocationID: "old", MainPID: 6, |
| + ExecPath: filepath.Join(opt, "live", observedHost)}, |
| + {ActiveState: "failed", SubState: "failed", InvocationID: "new"}, |
| + }} |
| + settled := recordSettled(f, func(int) string { return staged }, opt, bake, time.Now) |
| + if err := checkedUnitRestart(f, opt, func(string) string { return "" }, settled)(rt); err == nil { |
| + t.Fatal("a failed unit settled") |
| + } |
| + if h := release.ReadHealthy(bake, "bab", "console"); len(h) != 0 { |
| + t.Fatalf("a failed build recorded %+v", h) |
| + } |
| +} |
diff --git a/internal/host/unitsettle.go b/internal/host/unitsettle.go| index 15e15d842..918ec0bae 100644 |
| --- a/internal/host/unitsettle.go |
| +++ b/internal/host/unitsettle.go |
| @@ -38,6 +38,8 @@ type unitProps struct { |
| MainPID int |
| NRestarts int |
| ExecPath string // ExecStart's executable path |
| + |
| + ActiveEnterTimestamp time.Time // when the unit last entered active; zero when unknown |
| } |
| type unitSystem interface { |
| @@ -53,7 +55,7 @@ type systemdUnits struct{} |
| func (systemdUnits) Show(unit string) (unitProps, error) { |
| out, err := exec.Command("systemctl", "show", unit, |
| - "-p", "ActiveState", "-p", "SubState", "-p", "InvocationID", "-p", "MainPID", "-p", "NRestarts", "-p", "ExecStart").Output() |
| + "-p", "ActiveState", "-p", "SubState", "-p", "InvocationID", "-p", "MainPID", "-p", "NRestarts", "-p", "ExecStart", "-p", "ActiveEnterTimestamp").Output() |
| if err != nil { |
| return unitProps{}, fmt.Errorf("systemctl show %s: %w", unit, err) |
| } |
| @@ -105,11 +107,24 @@ func parseShow(out string) unitProps { |
| if m := execPathRE.FindStringSubmatch(v); m != nil { |
| p.ExecPath = m[1] |
| } |
| + case "ActiveEnterTimestamp": |
| + p.ActiveEnterTimestamp = parseSystemdTime(v) |
| } |
| } |
| return p |
| } |
| +// parseSystemdTime reads systemctl show's default timestamp form, |
| +// "Mon 2026-10-06 09:30:30 UTC". Boxes run in UTC. "" and "n/a" are zero, |
| +// which every caller reads as "unknown", never as "long ago". |
| +func parseSystemdTime(v string) time.Time { |
| + t, err := time.Parse("Mon 2006-01-02 15:04:05 MST", strings.TrimSpace(v)) |
| + if err != nil { |
| + return time.Time{} |
| + } |
| + return t |
| +} |
| + |
| type settleOpts struct { |
| Timeout time.Duration // the whole window (CARLOS_UNIT_SETTLE) |
| Poll time.Duration // 500ms |
diff --git a/internal/host/unitsettle_test.go b/internal/host/unitsettle_test.go| index c249b9060..a949ee2ef 100644 |
| --- a/internal/host/unitsettle_test.go |
| +++ b/internal/host/unitsettle_test.go |
| @@ -386,7 +386,7 @@ func TestNamedRestartBindingMismatchKeepsTheError(t *testing.T) { |
| f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "old", |
| ExecPath: filepath.Join(opt, "live", "console.example.com")}}} |
| failing := &failingTry{fakeUnits: f} |
| - err := checkedUnitRestart(failing, opt, func(string) string { return "" })(boundRoute()) |
| + err := checkedUnitRestart(failing, opt, func(string) string { return "" }, nil)(boundRoute()) |
| if err == nil || !strings.Contains(err.Error(), "try-restart failed") { |
| t.Fatalf("err = %v, want the try-restart error propagated (codex 12)", err) |
| } |
| @@ -399,7 +399,7 @@ func (failingTry) TryRestart(string) error { return errors.New("try-restart fail |
| func TestNamedRestartShowFailsFallsBack(t *testing.T) { |
| opt := t.TempDir() |
| sys := &showFails{fakeUnits: &fakeUnits{}} |
| - if err := checkedUnitRestart(sys, opt, func(string) string { return "" })(boundRoute()); err != nil { |
| + if err := checkedUnitRestart(sys, opt, func(string) string { return "" }, nil)(boundRoute()); err != nil { |
| t.Fatalf("err = %v, want today's behaviour (plain try-restart, its nil)", err) |
| } |
| if len(sys.restarts) != 1 || sys.restarts[0] != "try-restart console.service" { |
| @@ -415,7 +415,7 @@ func TestNamedRestartStoppedUnitIsNotChecked(t *testing.T) { |
| opt := t.TempDir() |
| liveTo(t, opt, "platform.example.com", "new") |
| f := &fakeUnits{shows: []unitProps{{ActiveState: "inactive", ExecPath: filepath.Join(opt, "live", "platform.example.com")}}} |
| - if err := checkedUnitRestart(f, opt, func(string) string { return "" })(boundRoute()); err != nil { |
| + if err := checkedUnitRestart(f, opt, func(string) string { return "" }, nil)(boundRoute()); err != nil { |
| t.Fatalf("err = %v, want nil for an operator-stopped unit", err) |
| } |
| } |
| @@ -472,7 +472,7 @@ func TestNamedRestartSettlesAHealthyForwardRestart(t *testing.T) { |
| ExecPath: filepath.Join(opt, "live", "platform.example.com")}, |
| {ActiveState: "active", SubState: "running", InvocationID: "new", MainPID: 7}, |
| }, exe: map[int]string{7: filepath.Join(newBuildDir(t, opt, "platform.example.com"), "console")}} |
| - if err := checkedUnitRestart(f, opt, func(string) string { return "" })(rt); err != nil { |
| + if err := checkedUnitRestart(f, opt, func(string) string { return "" }, nil)(rt); err != nil { |
| t.Fatalf("forward restart = %v, want settled", err) |
| } |
| if strings.Join(f.restarts, ",") != "try-restart console.service" { |
| @@ -492,7 +492,7 @@ func TestNamedRestartStopDuringSettleIsNotJudged(t *testing.T) { |
| ExecPath: filepath.Join(opt, "live", "platform.example.com")}, |
| {ActiveState: "inactive", SubState: "dead", InvocationID: "old"}, |
| }} |
| - if err := checkedUnitRestart(f, opt, func(string) string { return "" })(boundRoute()); err != nil { |
| + if err := checkedUnitRestart(f, opt, func(string) string { return "" }, nil)(boundRoute()); err != nil { |
| t.Fatalf("err = %v, want nil: a stop is not a verdict on the build", err) |
| } |
| if strings.Join(f.restarts, ",") != "try-restart console.service" { |
| @@ -510,7 +510,7 @@ func TestNamedRestartFailedStillFails(t *testing.T) { |
| {ActiveState: "failed", SubState: "failed", InvocationID: "new"}, |
| }} |
| var nu *release.UnitNotUpError |
| - if err := checkedUnitRestart(f, opt, func(string) string { return "" })(boundRoute()); !errors.As(err, &nu) { |
| + if err := checkedUnitRestart(f, opt, func(string) string { return "" }, nil)(boundRoute()); !errors.As(err, &nu) { |
| t.Fatalf("err = %v, want UnitNotUpError for a failed unit", err) |
| } |
| } |