| index e2ff0a97d..c249b9060 100644 |
| --- a/internal/host/unitsettle_test.go |
| +++ b/internal/host/unitsettle_test.go |
| @@ -22,11 +22,18 @@ type fakeUnits struct { |
| n int |
| exe map[int]string |
| exeErr map[int]error // optional: errors to return from Exe |
| + exeSeq []string // optional: Exe's answers in order, whatever the pid; the last repeats |
| + exeN int |
| + showErr map[int]error // optional: the Nth Show call (0-based) fails with this |
| journal string |
| restarts []string |
| } |
| |
| func (f *fakeUnits) Show(string) (unitProps, error) { |
| + if err, ok := f.showErr[f.n]; ok { |
| + f.n++ |
| + return unitProps{}, err |
| + } |
| p := f.shows[min(f.n, len(f.shows)-1)] |
| f.n++ |
| return p, nil |
| @@ -48,6 +55,11 @@ func (f *fakeUnits) Exe(pid int) (string, error) { |
| if err, ok := f.exeErr[pid]; ok { |
| return "", err |
| } |
| + if len(f.exeSeq) > 0 { |
| + e := f.exeSeq[min(f.exeN, len(f.exeSeq)-1)] |
| + f.exeN++ |
| + return e, nil |
| + } |
| if e, ok := f.exe[pid]; ok { |
| return e, nil |
| } |
| @@ -68,9 +80,9 @@ func opts(ready func() error) settleOpts { |
| } |
| |
| func TestParseShow(t *testing.T) { |
| - p := parseShow("ActiveState=active\nInvocationID=abc\nMainPID=42\nNRestarts=0\n" + |
| + p := parseShow("ActiveState=active\nSubState=running\nInvocationID=abc\nMainPID=42\nNRestarts=0\n" + |
| "ExecStart={ path=/opt/carlos/live/platform.example.com ; argv[]=/opt/carlos/live/platform.example.com serve ; ignore_errors=no }\n") |
| - if p.ActiveState != "active" || p.InvocationID != "abc" || p.MainPID != 42 || |
| + if p.ActiveState != "active" || p.SubState != "running" || p.InvocationID != "abc" || p.MainPID != 42 || |
| p.ExecPath != "/opt/carlos/live/platform.example.com" { |
| t.Fatalf("parseShow = %+v", p) |
| } |
| @@ -131,8 +143,67 @@ func TestSettleRejectsTheOldBinary(t *testing.T) { |
| f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "new", MainPID: 7}}, |
| exe: map[int]string{7: "/opt/carlos/versions/old/console"}} |
| var nu *release.UnitNotUpError |
| - if err := settleUnit(f, "console.service", "old", opts(func() error { return nil })); !errors.As(err, &nu) { |
| - t.Fatalf("settle = %v, want not-up: the process runs the old build", err) |
| + if err := settleUnit(f, "console.service", "old", opts(func() error { return nil })); !errors.As(err, &nu) || |
| + !strings.Contains(nu.Reason, "running /opt/carlos/versions/old/console, not the new build in /opt/carlos/versions/new") { |
| + t.Fatalf("settle = %v, want not-up at the deadline naming the old build's exe", err) |
| + } |
| +} |
| + |
| +// Final review F1: for Type=simple, systemd reports the new invocation active |
| +// once it has forked and before it execs ExecStart, so the first poll can see |
| +// systemd's executor as MainPID's exe. That is not-ready, not a failure. |
| +func TestSettleWaitsOutTheExecWindow(t *testing.T) { |
| + f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "new", MainPID: 7}}, |
| + exeSeq: []string{"/usr/lib/systemd/systemd-executor", "/opt/carlos/versions/new/console"}} |
| + readies := 0 |
| + err := settleUnit(f, "console.service", "old", opts(func() error { readies++; return nil })) |
| + if err != nil { |
| + t.Fatalf("settle = %v, want healthy once the new binary is exec'd", err) |
| + } |
| + if f.exeN != 2 || readies != 1 { |
| + t.Fatalf("exe reads %d, ready calls %d; want 2 and 1 (no readiness judged on the executor)", f.exeN, readies) |
| + } |
| +} |
| + |
| +func TestSettleExecutorAtTheDeadlineNamesTheExe(t *testing.T) { |
| + f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "new", MainPID: 7}}, |
| + exeSeq: []string{"/usr/lib/systemd/systemd-executor"}} |
| + var nu *release.UnitNotUpError |
| + err := settleUnit(f, "console.service", "old", opts(func() error { return nil })) |
| + if !errors.As(err, &nu) || |
| + !strings.Contains(nu.Reason, "running /usr/lib/systemd/systemd-executor, not the new build in /opt/carlos/versions/new") { |
| + t.Fatalf("settle = %v, want a deadline UnitNotUpError naming the exe seen", err) |
| + } |
| +} |
| + |
| +// Final review F5: one failed `systemctl show` is retried, not a verdict. |
| +func TestSettleRetriesATransientShowError(t *testing.T) { |
| + f := &fakeUnits{shows: []unitProps{{ActiveState: "active", InvocationID: "new", MainPID: 7}}, |
| + exe: map[int]string{7: "/opt/carlos/versions/new/console"}, |
| + showErr: map[int]error{0: errors.New("Failed to get properties: Connection timed out")}} |
| + if err := settleUnit(f, "console.service", "old", opts(func() error { return nil })); err != nil { |
| + t.Fatalf("settle = %v, want healthy after one show error", err) |
| + } |
| +} |
| + |
| +func TestSettleShowErrorAtTheDeadline(t *testing.T) { |
| + sys := &showFails{fakeUnits: &fakeUnits{}} |
| + var nu *release.UnitNotUpError |
| + err := settleUnit(sys, "console.service", "old", opts(func() error { return nil })) |
| + if !errors.As(err, &nu) || !strings.Contains(nu.Reason, "cannot observe console.service: Access denied") { |
| + t.Fatalf("settle = %v, want a deadline UnitNotUpError saying the unit could not be observed", err) |
| + } |
| +} |
| + |
| +// Final review F2: inactive/dead after the restart is an operator's stop. |
| +func TestSettleStoppedUnitIsErrUnitStopped(t *testing.T) { |
| + f := &fakeUnits{shows: []unitProps{ |
| + {ActiveState: "active", InvocationID: "new", MainPID: 7}, |
| + {ActiveState: "inactive", SubState: "dead", InvocationID: "new"}, |
| + }, exe: map[int]string{7: "/opt/carlos/versions/new/console"}} |
| + err := settleUnit(f, "console.service", "old", opts(func() error { return errors.New("refused") })) |
| + if !errors.Is(err, errUnitStopped) { |
| + t.Fatalf("settle = %v, want errUnitStopped", err) |
| } |
| } |
| |
| @@ -370,3 +441,99 @@ func TestRollbackRestartResetsThenRestarts(t *testing.T) { |
| t.Fatalf("restarts = %v, want reset-failed then restart (never start)", f.restarts) |
| } |
| } |
| + |
| +// newBuildDir is where checkedUnitRestart expects the new process to run |
| +// from: the directory of the live link's resolved target, the same |
| +// EvalSymlinks the production code does (t.TempDir can sit under a symlink). |
| +func newBuildDir(t *testing.T, opt, host string) string { |
| + t.Helper() |
| + want, err := filepath.EvalSymlinks(filepath.Join(opt, "live", host)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return filepath.Dir(want) |
| +} |
| + |
| +// The production wiring, end to end: checkedUnitRestart reads the unit, |
| +// try-restarts it, and settles a healthy forward restart on the new build |
| +// (ledger Task 6 minor). Real clock; the first poll is healthy. |
| +func TestNamedRestartSettlesAHealthyForwardRestart(t *testing.T) { |
| + opt := t.TempDir() |
| + liveTo(t, opt, "platform.example.com", "new") |
| + ln, err := net.Listen("tcp", "127.0.0.1:0") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { ln.Close() }) |
| + rt := boundRoute() |
| + rt.Addr = ln.Addr().String() |
| + f := &fakeUnits{shows: []unitProps{ |
| + {ActiveState: "active", SubState: "running", InvocationID: "old", MainPID: 6, |
| + 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 { |
| + t.Fatalf("forward restart = %v, want settled", err) |
| + } |
| + if strings.Join(f.restarts, ",") != "try-restart console.service" { |
| + t.Fatalf("restarts = %v, want exactly the try-restart", f.restarts) |
| + } |
| +} |
| + |
| +// Final review F2: the unit read active, then an operator stopped it (before |
| +// or during the settle). checkedUnitRestart reports nil, so adoptHost does |
| +// not roll back, refuse or restart it, and the only systemctl verb issued is |
| +// the try-restart itself. |
| +func TestNamedRestartStopDuringSettleIsNotJudged(t *testing.T) { |
| + opt := t.TempDir() |
| + liveTo(t, opt, "platform.example.com", "new") |
| + f := &fakeUnits{shows: []unitProps{ |
| + {ActiveState: "active", SubState: "running", InvocationID: "old", MainPID: 6, |
| + 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 { |
| + 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" { |
| + t.Fatalf("restarts = %v, want only the try-restart (no restart, no reset-failed)", f.restarts) |
| + } |
| +} |
| + |
| +// The other side of F2: failed is a crash, never a stop. |
| +func TestNamedRestartFailedStillFails(t *testing.T) { |
| + opt := t.TempDir() |
| + liveTo(t, opt, "platform.example.com", "new") |
| + f := &fakeUnits{shows: []unitProps{ |
| + {ActiveState: "active", SubState: "running", InvocationID: "old", MainPID: 6, |
| + ExecPath: filepath.Join(opt, "live", "platform.example.com")}, |
| + {ActiveState: "failed", SubState: "failed", InvocationID: "new"}, |
| + }} |
| + var nu *release.UnitNotUpError |
| + if err := checkedUnitRestart(f, opt, func(string) string { return "" })(boundRoute()); !errors.As(err, &nu) { |
| + t.Fatalf("err = %v, want UnitNotUpError for a failed unit", err) |
| + } |
| +} |
| + |
| +// A stop during the rollback's own settle is the operator's too: the link |
| +// names the restored build, and nothing restarts it a second time. |
| +func TestRollbackRestartStopDuringSettleIsNotJudged(t *testing.T) { |
| + opt := t.TempDir() |
| + liveTo(t, opt, "platform.example.com", "old") |
| + reg := restartTestRegistry(t) |
| + r := boundRoute() |
| + r.Addr = "" |
| + if err := reg.Upsert(r); err != nil { |
| + t.Fatal(err) |
| + } |
| + f := &fakeUnits{shows: []unitProps{ |
| + {ActiveState: "active", InvocationID: "bad"}, |
| + {ActiveState: "inactive", SubState: "dead", InvocationID: "bad"}, |
| + }} |
| + if err := rollbackUnitRestart(reg, f, opt, func(string) string { return "" })("platform.example.com"); err != nil { |
| + t.Fatalf("rollback = %v, want nil for a stop during its settle", err) |
| + } |
| + if strings.Join(f.restarts, ",") != "reset-failed console.service,restart console.service" { |
| + t.Fatalf("restarts = %v, want the one reset-failed and restart only", f.restarts) |
| + } |
| +} |