host: named-unit restarts settle and roll back by restart (console defences D1)
checkedUnitRestart verifies the unit's binding then settles; rollbackUnitRestart resets, restarts and settles the restored build. Wired into the adopt loop, fast loop and RunAdopt.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+201
−32
internal/host/agent.go+89 −9internal/host/agent_test.go+23 −18internal/host/converge_adopted_test.go+5 −5internal/host/unitsettle_test.go+84 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go| index 328c83bef..b124c4d13 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, systemctlTryRestartUnit, adoptBounceFn(svc)) |
| + restart := makeRestartRoute(reg, systemctlTryRestart, checkedUnitRestart(systemdUnits{}, optDir, os.Getenv), 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. |
| @@ -868,7 +868,8 @@ func adoptLoop(reg *registry.Registry, svc *activator.Service, sd *scheduleDeps) |
| if err := release.Pass(ctx, reg, store, pub, optDir, restart, |
| release.BakeDir(dataDir()), release.OnStageBlocked(stageRequester(dataDir())), |
| release.RestartHibernating(restart), |
| - release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit))); err != nil { |
| + release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit)), |
| + release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv))); err != nil { |
| log.Printf("agent: adopt pass: %v", err) |
| } |
| // The schedule converge's hourly floor (schedule spec §6.2), AFTER the |
| @@ -1928,10 +1929,13 @@ 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, systemctlTryRestartUnit, adoptBounceFn(svc))) |
| + restart := d.observeRestart(makeRestartRoute(reg, systemctlTryRestart, checkedUnitRestart(systemdUnits{}, optDir, os.Getenv), 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)) |
| + // The rollback's restart (D1): checked like the forward one, and a replace |
| + // rather than a start, so a rejected build left up is cycled out. |
| + opts = append(opts, release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, optDir, os.Getenv))) |
| // The restart check's memory (issue #113, narrowed requests added by |
| // discussion #16), seeded ONCE here — two reads per app at arming (the |
| // app-wide generation and the narrowed request log) and never again except |
| @@ -3098,14 +3102,14 @@ func statusTick(ctx context.Context, s release.Store, reg *registry.Registry, p |
| // merely unattended. Returned, adoptHost rolls the symlink back to the |
| // version that IS running, skips SetVersions, prints the failure, and the |
| // next pass retries; the header stays honest on the old version throughout. |
| -func makeRestartRoute(reg *registry.Registry, tryRestart func(host string), tryRestartUnit func(unit string) error, bounce func(host string) bool) func(host string) error { |
| +func makeRestartRoute(reg *registry.Registry, tryRestart func(host string), tryRestartUnit func(rt registry.Route) error, bounce func(host string) bool) func(host string) error { |
| return func(host string) error { |
| rt, err := reg.Get(host) |
| if err != nil { |
| log.Printf("adopt: registry lookup %s: %v (restarting as a unit)", host, err) |
| } |
| if err == nil && rt != nil && rt.Unit != "" { |
| - return tryRestartUnit(rt.Unit) |
| + return tryRestartUnit(*rt) |
| } |
| if err == nil && rt != nil && rt.Backing != registry.BackingUnit { |
| if bounce != nil && bounce(host) { |
| @@ -3201,6 +3205,81 @@ func systemctlTryRestartUnit(unit string) error { |
| return nil |
| } |
| +// unitOnly adapts a unit-name restart to makeRestartRoute's route-aware arm. |
| +func unitOnly(f func(unit string) error) func(rt registry.Route) error { |
| + return func(rt registry.Route) error { return f(rt.Unit) } |
| +} |
| + |
| +// checkedUnitRestart is the named-unit arm with D1's settle check (spec |
| +// §2.2-§2.3). The binding is verified first: the check judges a restart only |
| +// 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 { |
| + return func(rt registry.Route) error { |
| + before, err := sys.Show(rt.Unit) |
| + if err != nil { |
| + log.Printf("adopt: %s: cannot read %s (%v); restart not checked", rt.Host, rt.Unit, err) |
| + return sys.TryRestart(rt.Unit) |
| + } |
| + link := filepath.Join(optDir, "live", rt.Host) |
| + if before.ExecPath != link { |
| + log.Printf("adopt: binding: %s runs %s, but the unit stamp is on %s; restart not checked", rt.Unit, before.ExecPath, rt.Host) |
| + return sys.TryRestart(rt.Unit) |
| + } |
| + if before.ActiveState != "active" { |
| + return sys.TryRestart(rt.Unit) // stopped by an operator: stays a no-op |
| + } |
| + want, err := filepath.EvalSymlinks(link) |
| + if err != nil { |
| + return sys.TryRestart(rt.Unit) |
| + } |
| + if err := sys.TryRestart(rt.Unit); err != nil { |
| + return err |
| + } |
| + timeout := unitSettleTimeout(getenv) |
| + err = settleUnit(sys, rt.Unit, before.InvocationID, settleOpts{ |
| + Timeout: timeout, Poll: 500 * time.Millisecond, Hold: 5 * time.Second, |
| + WantDir: filepath.Dir(want), Ready: routeReady(rt, 2*time.Second), |
| + Now: time.Now, Sleep: time.Sleep}) |
| + if err == nil { |
| + log.Printf("adopt: %s: %s settled on %s", rt.Host, rt.Unit, filepath.Dir(want)) |
| + } |
| + return err |
| + } |
| +} |
| + |
| +// rollbackUnitRestart is D1's rollback restart (release.WithRollbackRestart): |
| +// the live link already names the restored build; replace whatever is |
| +// running (a deadline failure can leave the rejected build up) and check |
| +// the restored build comes up too. |
| +func rollbackUnitRestart(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string) func(host string) error { |
| + return func(host string) error { |
| + rt, err := reg.Get(host) |
| + if err != nil || rt == nil || rt.Unit == "" { |
| + return nil |
| + } |
| + before, _ := sys.Show(rt.Unit) |
| + if err := sys.ResetFailed(rt.Unit); err != nil { |
| + log.Printf("adopt: %s: reset-failed %s: %v (continuing)", host, rt.Unit, err) |
| + } |
| + if err := sys.Restart(rt.Unit); err != nil { |
| + return err |
| + } |
| + want, err := filepath.EvalSymlinks(filepath.Join(optDir, "live", host)) |
| + if err != nil { |
| + return err |
| + } |
| + if err := settleUnit(sys, rt.Unit, before.InvocationID, settleOpts{ |
| + Timeout: unitSettleTimeout(getenv), Poll: 500 * time.Millisecond, Hold: 5 * time.Second, |
| + WantDir: filepath.Dir(want), Ready: routeReady(*rt, 2*time.Second), |
| + Now: time.Now, Sleep: time.Sleep}); err != nil { |
| + return fmt.Errorf("rollback did not come up either: %w", err) |
| + } |
| + return nil |
| + } |
| +} |
| + |
| // systemctlStartUnit is makeStartRoute's production path: bring the named |
| // unit up after a rolled-back restart left it down. START, not try-restart — |
| // try-restart is a no-op on a stopped unit, which on this path would leave |
| @@ -3239,8 +3318,8 @@ func systemctlStartUnit(unit string) error { |
| // staging, the repoint, the restart and the version stamp. A restart arm |
| // wired only into the agent would therefore be a restart arm the consoles |
| // never reach. |
| -func rootRestartRoute(reg *registry.Registry) func(host string) error { |
| - return makeRestartRoute(reg, systemctlTryRestart, systemctlTryRestartUnit, nil) |
| +func rootRestartRoute(reg *registry.Registry, optDir string) func(host string) error { |
| + return makeRestartRoute(reg, systemctlTryRestart, checkedUnitRestart(systemdUnits{}, optDir, os.Getenv), nil) |
| } |
| // bounceFn adapts an activator to makeRestartRoute's bounce parameter, |
| @@ -3533,7 +3612,7 @@ func RunAdopt(args []string) { |
| // -unit` refuses to stamp — so config sync sees no unit-named route by |
| // construction. It is handed the same callback anyway because there should |
| // be ONE restart dispatch on this pass, not two that could drift. |
| - restart := rootRestartRoute(reg) |
| + restart := rootRestartRoute(reg, release.OptDir()) |
| // The restart-request half, and the ONE place it dispatches from — |
| // including for exec-backed routes, whose children belong to the agent: |
| // rootRestartRoute's try-restart arm reaches a carlos-app@ unit, and an |
| @@ -3658,7 +3737,8 @@ func RunAdopt(args []string) { |
| // run). -stage-only keeps it for the same reason it keeps the converge: |
| // a relayed stage is exactly the request that performs such a restart. |
| passOpts := []release.PassOption{release.WithCapabilities(caps.Ops{}), release.BakeDir(dataDir()), |
| - release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit))} |
| + release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit)), |
| + release.WithRollbackRestart(rollbackUnitRestart(reg, systemdUnits{}, release.OptDir(), os.Getenv))} |
| if !*stageOnly { |
| passOpts = append(passOpts, release.NagExpiredCanaries(os.Stdout)) |
| } |
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go| index ca22797b1..fcdd5ee2a 100644 |
| --- a/internal/host/agent_test.go |
| +++ b/internal/host/agent_test.go |
| @@ -551,10 +551,10 @@ func TestMakeRestartRouteDispatchesOnBacking(t *testing.T) { |
| var restarted, bounced []string |
| restart := makeRestartRoute(reg, |
| func(h string) { restarted = append(restarted, h) }, |
| - func(u string) error { |
| + unitOnly(func(u string) error { |
| t.Fatalf("named-unit arm fired for %q on a route that names no unit", u) |
| return nil |
| - }, |
| + }), |
| func(h string) bool { bounced = append(bounced, h); return true }) |
| if err := restart("unit.test"); err != nil { |
| t.Fatalf("restart unit.test: %v", err) |
| @@ -577,10 +577,10 @@ func TestMakeRestartRouteUnknownHostFallsBackToUnit(t *testing.T) { |
| reg := restartTestRegistry(t) |
| var restarted []string |
| restart := makeRestartRoute(reg, func(h string) { restarted = append(restarted, h) }, |
| - func(u string) error { |
| + unitOnly(func(u string) error { |
| t.Fatalf("named-unit arm fired for %q on a host the registry cannot answer for", u) |
| return nil |
| - }, nil) |
| + }), nil) |
| if err := restart("ghost.test"); err != nil { |
| t.Fatalf("restart ghost.test: %v", err) |
| } |
| @@ -616,10 +616,10 @@ func TestMakeRestartRouteExecFallsThroughWhenNothingBounced(t *testing.T) { |
| } |
| var restarted []string |
| restart := makeRestartRoute(reg, func(h string) { restarted = append(restarted, h) }, |
| - func(u string) error { |
| + unitOnly(func(u string) error { |
| t.Fatalf("named-unit arm fired for %q on a route that names no unit", u) |
| return nil |
| - }, tc.bounce) |
| + }), tc.bounce) |
| if err := restart("exec.test"); err != nil { |
| t.Fatalf("restart exec.test: %v", err) |
| } |
| @@ -647,7 +647,7 @@ func TestMakeRestartRouteRestartsTheUnitARouteNames(t *testing.T) { |
| var units, restarted, bounced []string |
| restart := makeRestartRoute(reg, |
| func(h string) { restarted = append(restarted, h) }, |
| - func(u string) error { units = append(units, u); return nil }, |
| + unitOnly(func(u string) error { units = append(units, u); return nil }), |
| func(h string) bool { bounced = append(bounced, h); return true }) |
| if err := restart("console.carloku.com"); err != nil { |
| t.Fatalf("restart console.carloku.com: %v", err) |
| @@ -679,7 +679,7 @@ func TestMakeRestartRouteUnitNameBeatsBacking(t *testing.T) { |
| var units, restarted []string |
| restart := makeRestartRoute(reg, |
| func(h string) { restarted = append(restarted, h) }, |
| - func(u string) error { units = append(units, u); return nil }, |
| + unitOnly(func(u string) error { units = append(units, u); return nil }), |
| nil) |
| if err := restart("odd.test"); err != nil { |
| t.Fatalf("restart odd.test: %v", err) |
| @@ -715,14 +715,17 @@ func TestRootRestartRouteCarriesTheNamedUnitArm(t *testing.T) { |
| } |
| t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) |
| - if err := rootRestartRoute(reg)("console.carloku.com"); err != nil { |
| + if err := rootRestartRoute(reg, t.TempDir())("console.carloku.com"); err != nil { |
| t.Fatalf("root restart: %v", err) |
| } |
| got, err := os.ReadFile(callLog) |
| if err != nil { |
| t.Fatalf("the root pass ran no systemctl at all: %v", err) |
| } |
| - if want := "try-restart console.service\n"; string(got) != want { |
| + // 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 InvocationID -p MainPID -p NRestarts -p ExecStart\ntry-restart console.service\n"; string(got) != want { |
| t.Fatalf("systemctl calls = %q, want %q", got, want) |
| } |
| } |
| @@ -762,7 +765,7 @@ func TestAdoptPassRestartsANamedUnitOnAdoption(t *testing.T) { |
| var units []string |
| restart := makeRestartRoute(reg, |
| func(h string) { t.Fatalf("carlos-app@ arm fired for %q — the console has no such unit", h) }, |
| - func(u string) error { units = append(units, u); return nil }, |
| + unitOnly(func(u string) error { units = append(units, u); return nil }), |
| func(h string) bool { |
| t.Fatalf("bounce arm fired for %q — the activator owns no console child", h) |
| return false |
| @@ -833,7 +836,7 @@ func TestAdoptPassRefusesToStampAVersionAWrongUnitDidNotStart(t *testing.T) { |
| log.SetOutput(&journal) |
| t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| - restart := rootRestartRoute(reg) |
| + restart := rootRestartRoute(reg, optDir) |
| if err := release.Pass(ctx, reg, s, pub, optDir, restart); err != nil { |
| t.Fatalf("Pass: %v", err) |
| } |
| @@ -960,7 +963,7 @@ func TestAdoptPassStartsTheNamedUnitAgainAfterAFailedRestart(t *testing.T) { |
| log.SetOutput(&journal) |
| t.Cleanup(func() { log.SetOutput(os.Stderr) }) |
| - if err := release.Pass(ctx, reg, s, pub, optDir, rootRestartRoute(reg), |
| + if err := release.Pass(ctx, reg, s, pub, optDir, rootRestartRoute(reg, optDir), |
| release.WithStartUnit(makeStartRoute(reg, systemctlStartUnit))); err != nil { |
| t.Fatalf("Pass: %v", err) |
| } |
| @@ -969,7 +972,9 @@ func TestAdoptPassStartsTheNamedUnitAgainAfterAFailedRestart(t *testing.T) { |
| if err != nil { |
| t.Fatalf("no systemctl calls at all: %v", err) |
| } |
| - if want := "try-restart console.service\nstart console.service\n"; string(got) != want { |
| + // 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 InvocationID -p MainPID -p NRestarts -p ExecStart\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" { |
| @@ -1032,10 +1037,10 @@ func TestAdoptPassBouncesAWarmHibernatingInstance(t *testing.T) { |
| // it never starts anything. |
| restart := makeRestartRoute(reg, |
| func(h string) { tried = append(tried, h) }, |
| - func(u string) error { |
| + unitOnly(func(u string) error { |
| t.Fatalf("named-unit arm fired for %q on a route that names no unit", u) |
| return nil |
| - }, |
| + }), |
| func(h string) bool { |
| bounced = append(bounced, h) |
| return h == "warm.test" |
| @@ -1378,10 +1383,10 @@ func TestFastSweepBouncesAWarmHibernatingCanary(t *testing.T) { |
| var bounced []string |
| restart := makeRestartRoute(env.reg, |
| func(string) {}, |
| - func(u string) error { |
| + unitOnly(func(u string) error { |
| t.Fatalf("named-unit arm fired for %q on a route that names no unit", u) |
| return nil |
| - }, |
| + }), |
| func(h string) bool { bounced = append(bounced, h); return true }) |
| d := env.deps(nil) |
| d.restart = restart |
diff --git a/internal/host/converge_adopted_test.go b/internal/host/converge_adopted_test.go| index ea527cd27..70c0346f6 100644 |
| --- a/internal/host/converge_adopted_test.go |
| +++ b/internal/host/converge_adopted_test.go |
| @@ -119,7 +119,7 @@ func TestRootAdoptionOfAWarmExecInstanceIsFinishedByTheAgent(t *testing.T) { |
| // rootRestartRoute with systemctl stubbed out: same dispatch, bounce nil. |
| rootPass := func() { |
| t.Helper() |
| - restart := makeRestartRoute(env.reg, func(string) {}, func(string) error { return nil }, nil) |
| + restart := makeRestartRoute(env.reg, func(string) {}, unitOnly(func(string) error { return nil }), nil) |
| if err := release.Pass(ctx, env.reg, env.store, env.pub, env.optDir, restart, release.BakeDir(env.dataDir)); err != nil { |
| t.Fatalf("root pass: %v", err) |
| } |
| @@ -147,7 +147,7 @@ func TestRootAdoptionOfAWarmExecInstanceIsFinishedByTheAgent(t *testing.T) { |
| // The agent's tick. Its pass has a bounce to offer and never gets to use |
| // it: the host is already up to date. |
| var bounced, cycled []string |
| - restart := makeRestartRoute(env.reg, func(string) {}, func(string) error { return nil }, |
| + restart := makeRestartRoute(env.reg, func(string) {}, unitOnly(func(string) error { return nil }), |
| func(h string) bool { bounced = append(bounced, h); return true }) |
| d := env.deps(nil) |
| d.restart = restart |
| @@ -301,7 +301,7 @@ func TestConvergeLegCyclesARootAdoptedInstanceOnARealActivator(t *testing.T) { |
| t.Fatalf("Promote %s: %v", ver, err) |
| } |
| // The root pass: rootRestartRoute's dispatch (bounce nil), systemctl stubbed. |
| - restart := makeRestartRoute(env.reg, func(string) {}, func(string) error { return nil }, nil) |
| + restart := makeRestartRoute(env.reg, func(string) {}, unitOnly(func(string) error { return nil }), nil) |
| if err := release.Pass(ctx, env.reg, env.store, env.pub, env.optDir, restart, release.BakeDir(env.dataDir)); err != nil { |
| t.Fatalf("root pass for %s: %v", ver, err) |
| } |
| @@ -400,10 +400,10 @@ func TestAdoptRestartDispatchSparesAConvergedChild(t *testing.T) { |
| t.Fatalf("wake request = %d", rec.Code) |
| } |
| } |
| - restart := makeRestartRoute(env.reg, func(string) {}, func(u string) error { |
| + restart := makeRestartRoute(env.reg, func(string) {}, unitOnly(func(u string) error { |
| t.Fatalf("named-unit arm fired for %q on a route that names no unit", u) |
| return nil |
| - }, adoptBounceFn(svc)) |
| + }), adoptBounceFn(svc)) |
| // Asked of the spawn count, after a request — see the note in |
| // TestConvergeLegCyclesARootAdoptedInstanceOnARealActivator. |
diff --git a/internal/host/unitsettle_test.go b/internal/host/unitsettle_test.go| index 2ec404380..e2ff0a97d 100644 |
| --- a/internal/host/unitsettle_test.go |
| +++ b/internal/host/unitsettle_test.go |
| @@ -286,3 +286,87 @@ func TestRouteReadyDoesNotFollowRedirects(t *testing.T) { |
| t.Fatalf("ready expecting 302 = %v, want nil", err) |
| } |
| } |
| + |
| +func boundRoute() registry.Route { |
| + return registry.Route{Host: "platform.example.com", App: "console", Unit: "console.service", Addr: "127.0.0.1:1"} |
| +} |
| + |
| +func liveTo(t *testing.T, opt, host, ver string) { |
| + t.Helper() |
| + dir := filepath.Join(opt, "versions", ver) |
| + if err := os.MkdirAll(dir, 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.WriteFile(filepath.Join(dir, "console"), []byte("x"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := os.MkdirAll(filepath.Join(opt, "live"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + os.Remove(filepath.Join(opt, "live", host)) |
| + if err := os.Symlink(filepath.Join(dir, "console"), filepath.Join(opt, "live", host)); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestNamedRestartBindingMismatchKeepsTheError(t *testing.T) { |
| + opt := t.TempDir() |
| + liveTo(t, opt, "platform.example.com", "new") |
| + 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()) |
| + if err == nil || !strings.Contains(err.Error(), "try-restart failed") { |
| + t.Fatalf("err = %v, want the try-restart error propagated (codex 12)", err) |
| + } |
| +} |
| + |
| +type failingTry struct{ *fakeUnits } |
| + |
| +func (failingTry) TryRestart(string) error { return errors.New("try-restart failed") } |
| + |
| +func TestNamedRestartShowFailsFallsBack(t *testing.T) { |
| + opt := t.TempDir() |
| + sys := &showFails{fakeUnits: &fakeUnits{}} |
| + if err := checkedUnitRestart(sys, opt, func(string) string { return "" })(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" { |
| + t.Fatalf("restarts = %v", sys.restarts) |
| + } |
| +} |
| + |
| +type showFails struct{ *fakeUnits } |
| + |
| +func (showFails) Show(string) (unitProps, error) { return unitProps{}, errors.New("Access denied") } |
| + |
| +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 { |
| + t.Fatalf("err = %v, want nil for an operator-stopped unit", err) |
| + } |
| +} |
| + |
| +func TestRollbackRestartResetsThenRestarts(t *testing.T) { |
| + opt := t.TempDir() |
| + liveTo(t, opt, "platform.example.com", "old") |
| + reg := restartTestRegistry(t) |
| + r := boundRoute() |
| + r.Addr = "" // no upstream: readiness is nil and settle holds instead of dialling |
| + if err := reg.Upsert(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + f := &fakeUnits{shows: []unitProps{ |
| + {ActiveState: "active", InvocationID: "bad"}, |
| + {ActiveState: "active", InvocationID: "restored", MainPID: 9}, |
| + }, exe: map[int]string{9: filepath.Join(opt, "versions", "old", "console")}} |
| + rb := rollbackUnitRestart(reg, f, opt, func(string) string { return "" }) |
| + if err := rb("platform.example.com"); err != nil { |
| + t.Fatalf("rollback = %v", err) |
| + } |
| + if strings.Join(f.restarts, ",") != "reset-failed console.service,restart console.service" { |
| + t.Fatalf("restarts = %v, want reset-failed then restart (never start)", f.restarts) |
| + } |
| +} |