fix(thothctl): harden Pi restart recovery

This commit is contained in:
2026-08-14 18:15:00 +02:00
parent 9414a4b4dd
commit 3a1f2ede4a
3 changed files with 326 additions and 15 deletions
+251 -2
View File
@@ -20,15 +20,25 @@ func TestRestartRequiresConfirmationWithoutInvokingCompose(t *testing.T) {
if !errors.Is(err, ErrConfirmationRequired) {
t.Fatalf("Restart() error = %v, want ErrConfirmationRequired", err)
}
if got, want := err.Error(), "restart requires --yes after reviewing the planned Pi core recreation"; got != want {
t.Fatalf("Restart() error text = %q, want %q", got, want)
}
assertNotCalled(t, fake.calls, "compose")
}
func TestRestartDrainsRecreatesOnlyCoreAndRetainsImage(t *testing.T) {
fake := newFakeRunner()
fake.activeSessions = true
fake.sessionsWire = `[{"status":"open","archived":false}]`
dir := t.TempDir()
hooks := defaultLifecycleHooks
hooks.sleep = func(time.Duration) { fake.activeSessions = false }
sleepCalls := 0
hooks.sleep = func(duration time.Duration) {
sleepCalls++
if duration != time.Second {
t.Fatalf("drain sleep = %s, want %s", duration, time.Second)
}
fake.sessionsWire = `[]`
}
result, err := restartWithHooks(context.Background(), fake, RestartRequest{
StatePath: filepath.Join(dir, "restart-state.json"),
@@ -42,6 +52,9 @@ func TestRestartDrainsRecreatesOnlyCoreAndRetainsImage(t *testing.T) {
if result.Version != fake.version {
t.Fatalf("version = %q, want %q", result.Version, fake.version)
}
if sleepCalls != 1 {
t.Fatalf("drain sleep calls = %d, want 1", sleepCalls)
}
assertCalled(t, fake.calls, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate core")
assertNotCalled(t, fake.calls, "build --pull")
assertNotCalled(t, fake.calls, "pull ")
@@ -64,6 +77,9 @@ func TestRestartRefusesActiveSessionsWithoutDrain(t *testing.T) {
if !errors.Is(err, ErrActiveSessions) {
t.Fatalf("Restart() error = %v, want ErrActiveSessions", err)
}
if got, want := err.Error(), "active sessions must be drained before restarting Pi; use --drain only after they are complete"; got != want {
t.Fatalf("Restart() error text = %q, want %q", got, want)
}
if fake.maintenance {
t.Fatal("maintenance remained active after refusing pre-mutation restart")
}
@@ -72,6 +88,37 @@ func TestRestartRefusesActiveSessionsWithoutDrain(t *testing.T) {
}
}
func TestRestartActivationFailureClearsPreMutationMaintenance(t *testing.T) {
for _, failure := range []string{
"maintenance-activate-durability",
"maintenance-activate-durability-without-status-flag",
} {
t.Run(failure, func(t *testing.T) {
dir := t.TempDir()
fake := newFakeRunner()
fake.fail = failure
_, err := Restart(context.Background(), fake, RestartRequest{
StatePath: filepath.Join(dir, "restart-state.json"),
UpdateStatePath: filepath.Join(dir, "update-state.json"),
Confirm: true,
})
var recovery *RecoveryRequiredError
if !errors.As(err, &recovery) {
t.Fatalf("Restart() error = %v, want RecoveryRequiredError", err)
}
if fake.maintenance {
t.Fatal("pre-mutation activation failure left maintenance active")
}
if fake.recreated {
t.Fatal("pre-mutation activation failure recreated core")
}
assertCalled(t, fake.calls, "/internal/maintenance/activate")
assertCalled(t, fake.calls, "/internal/maintenance/deactivate")
})
}
}
func TestRestartRefusesInterruptedUpdateOrRestartState(t *testing.T) {
for _, stateFile := range []string{"update-state.json", "restart-state.json"} {
t.Run(stateFile, func(t *testing.T) {
@@ -233,6 +280,208 @@ func TestRecoverLifecycleMaintenanceVerifiesAndClearsRestartState(t *testing.T)
}
}
func TestRecoverLifecycleMaintenanceRejectsMalformedRestartState(t *testing.T) {
for _, test := range []struct {
name string
phase Phase
source string
}{
{name: "recreated_without_mutation_marker", phase: PhaseRecreated, source: "restart"},
{name: "non_restart_source", phase: PhasePreflight, source: string(BuildSource)},
} {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
fake := newFakeRunner()
fake.maintenance = true
restartStatePath := filepath.Join(dir, "restart-state.json")
updateStatePath := filepath.Join(dir, "update-state.json")
writeStateForTest(t, restartStatePath, State{
Transaction: "malformed-restart",
Phase: test.phase,
Target: Target{Version: fake.version, Source: test.source},
Previous: stateImageForTest(t, fake),
})
fake.calls = nil
err := RecoverLifecycleMaintenance(
context.Background(), fake, updateStatePath, restartStatePath, true,
)
if !errors.Is(err, ErrInvalidRequest) || !strings.Contains(err.Error(), "restart recovery state is invalid") {
t.Fatalf("RecoverLifecycleMaintenance() error = %v, want invalid restart recovery state", err)
}
if !fake.maintenance {
t.Fatal("malformed restart state reopened admission")
}
if _, stateErr := os.Stat(restartStatePath); stateErr != nil {
t.Fatalf("malformed restart state was removed: %v", stateErr)
}
assertNotCalled(t, fake.calls, "/internal/maintenance/deactivate")
})
}
}
func TestRestartRefusesMalformedRestartStateWithoutInvokingCompose(t *testing.T) {
dir := t.TempDir()
fake := newFakeRunner()
restartStatePath := filepath.Join(dir, "restart-state.json")
writeStateForTest(t, restartStatePath, State{
Transaction: "malformed-restart",
Phase: PhasePreflight,
Target: Target{Version: fake.version, Source: string(BuildSource)},
Previous: stateImageForTest(t, fake),
})
fake.calls = nil
_, err := Restart(context.Background(), fake, RestartRequest{
StatePath: restartStatePath,
UpdateStatePath: filepath.Join(dir, "update-state.json"),
Confirm: true,
})
if !errors.Is(err, ErrInvalidRequest) || !strings.Contains(err.Error(), "restart recovery state is invalid") {
t.Fatalf("Restart() error = %v, want invalid restart recovery state", err)
}
if _, stateErr := os.Stat(restartStatePath); stateErr != nil {
t.Fatalf("malformed restart state was removed: %v", stateErr)
}
assertNotCalled(t, fake.calls, "compose")
}
func TestRestartDurabilityFailureBoundaries(t *testing.T) {
injected := errors.New("injected restart durability failure")
for _, test := range []struct {
name string
configure func(*fakeRunner, *lifecycleHooks)
wantPhase Phase
wantMutation bool
wantRecreated bool
wantMaintenance bool
wantRecovery bool
wantInjected bool
}{
{
name: "mutation_marker_write",
configure: func(_ *fakeRunner, hooks *lifecycleHooks) {
write := hooks.writeState
hooks.writeState = func(path string, state State) error {
if state.Phase == PhasePreflight && state.MutationStarted {
return injected
}
return write(path, state)
}
},
wantPhase: PhasePreflight,
wantInjected: true,
},
{
name: "core_recreation",
configure: func(fake *fakeRunner, _ *lifecycleHooks) {
fake.fail = "recreate"
},
wantPhase: PhasePreflight,
wantMutation: true,
wantRecreated: true,
wantMaintenance: true,
wantRecovery: true,
},
{
name: "maintenance_proof",
configure: func(fake *fakeRunner, _ *lifecycleHooks) {
fake.fail = "maintenance-proof"
},
wantPhase: PhasePreflight,
wantMutation: true,
wantRecreated: true,
wantMaintenance: true,
wantRecovery: true,
},
{
name: "recreated_phase_write",
configure: func(_ *fakeRunner, hooks *lifecycleHooks) {
write := hooks.writeState
hooks.writeState = func(path string, state State) error {
if state.Phase == PhaseRecreated {
return injected
}
return write(path, state)
}
},
wantPhase: PhasePreflight,
wantMutation: true,
wantRecreated: true,
wantMaintenance: true,
wantRecovery: true,
wantInjected: true,
},
{
name: "verified_phase_write",
configure: func(_ *fakeRunner, hooks *lifecycleHooks) {
write := hooks.writeState
hooks.writeState = func(path string, state State) error {
if state.Phase == PhaseVerified {
return injected
}
return write(path, state)
}
},
wantPhase: PhaseRecreated,
wantMutation: true,
wantRecreated: true,
wantMaintenance: true,
wantRecovery: true,
wantInjected: true,
},
{
name: "restart_state_removal",
configure: func(_ *fakeRunner, hooks *lifecycleHooks) {
hooks.removeFile = func(string) error { return injected }
},
wantPhase: PhaseVerified,
wantMutation: true,
wantRecreated: true,
wantMaintenance: true,
wantRecovery: true,
wantInjected: true,
},
} {
t.Run(test.name, func(t *testing.T) {
dir := t.TempDir()
fake := newFakeRunner()
hooks := defaultLifecycleHooks
test.configure(fake, &hooks)
statePath := filepath.Join(dir, "restart-state.json")
_, err := restartWithHooks(context.Background(), fake, RestartRequest{
StatePath: statePath,
UpdateStatePath: filepath.Join(dir, "update-state.json"),
Confirm: true,
}, hooks)
if err == nil {
t.Fatal("restartWithHooks() error = nil, want injected boundary failure")
}
var recovery *RecoveryRequiredError
if got := errors.As(err, &recovery); got != test.wantRecovery {
t.Fatalf("recovery-required = %t, want %t; error = %v", got, test.wantRecovery, err)
}
if test.wantInjected && !errors.Is(err, injected) {
t.Fatalf("restartWithHooks() error = %v, want injected durability cause", err)
}
state, stateErr := readState(statePath)
if stateErr != nil {
t.Fatalf("readState() error = %v", stateErr)
}
if state.Phase != test.wantPhase || state.MutationStarted != test.wantMutation {
t.Fatalf("state = %+v, want phase=%q mutation=%t", state, test.wantPhase, test.wantMutation)
}
if fake.recreated != test.wantRecreated {
t.Fatalf("recreated = %t, want %t", fake.recreated, test.wantRecreated)
}
if fake.maintenance != test.wantMaintenance {
t.Fatalf("maintenance = %t, want %t", fake.maintenance, test.wantMaintenance)
}
})
}
}
func TestRestartRejectsImageConfigurationAndMountDrift(t *testing.T) {
for _, test := range []struct {
failure string