fix: close Pi management final review findings
This commit is contained in:
@@ -9,7 +9,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
errInterruptedRestart = errors.New("a previous Pi restart is incomplete; recover lifecycle maintenance before restarting again")
|
||||
ErrInterruptedRestart = errors.New("a previous Pi restart is incomplete; recover lifecycle maintenance before another Pi lifecycle operation")
|
||||
errRestartImageDrift = errors.New("core image changed during Pi restart")
|
||||
errRestartConfigurationDrift = errors.New("external endpoint configuration changed during Pi restart")
|
||||
errRestartMountDrift = errors.New("core persistence mount contract changed during Pi restart")
|
||||
@@ -70,14 +70,7 @@ func restartWithHooks(
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return RestartResult{StatePath: request.StatePath}, err
|
||||
}
|
||||
if state, err := readState(request.StatePath); err == nil {
|
||||
if err := validateRestartRecoveryState(state); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath}, err
|
||||
}
|
||||
if state.MutationStarted {
|
||||
return RestartResult{StatePath: request.StatePath}, errInterruptedRestart
|
||||
}
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
if err := prepareLifecycleMutation(request.StatePath, hooks.removeFile); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath}, err
|
||||
}
|
||||
clearMaintenance := true
|
||||
@@ -124,8 +117,13 @@ func restartWithHooks(
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, err
|
||||
}
|
||||
previous.ConfigurationSHA = configured.ConfigurationSHA
|
||||
transaction := lifecycleTransaction(request.StatePath)
|
||||
previous.Reference = lifecycleImageTag(transaction, "restart")
|
||||
if err := tagImage(ctx, runner, previous.ID, previous.Reference, "restart image pin"); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, err
|
||||
}
|
||||
state = State{
|
||||
Transaction: lifecycleTransaction(request.StatePath),
|
||||
Transaction: transaction,
|
||||
Phase: PhasePreflight,
|
||||
Target: Target{Version: version, Source: "restart"},
|
||||
Previous: previous,
|
||||
@@ -133,6 +131,11 @@ func restartWithHooks(
|
||||
if err := hooks.writeState(request.StatePath, state); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, err
|
||||
}
|
||||
overridePath := lifecycleOverridePath(request.StatePath, transaction)
|
||||
if err := writeLifecycleOverride(overridePath, previous.Reference); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, err
|
||||
}
|
||||
lifecycle := composeOverrideRunner{Runner: runner, path: overridePath}
|
||||
if running, err := activeSessions(ctx, runner); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, err
|
||||
} else if running {
|
||||
@@ -144,23 +147,26 @@ func restartWithHooks(
|
||||
}
|
||||
mutationStarted = true
|
||||
clearMaintenance = false
|
||||
if err := recreateCore(ctx, runner); err != nil {
|
||||
if err := recreateCoreWithoutImageChanges(ctx, lifecycle); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, recoveryRequired("Pi restart core recreation failed", err)
|
||||
}
|
||||
if err := ensureMaintenance(ctx, runner); err != nil {
|
||||
if err := ensureMaintenance(ctx, lifecycle); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, recoveryRequired("Pi restart maintenance proof failed", err)
|
||||
}
|
||||
state.Phase = PhaseRecreated
|
||||
if err := hooks.writeState(request.StatePath, state); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, recoveryRequired("Pi restart recreation state could not be recorded", err)
|
||||
}
|
||||
if err := verifyRestart(ctx, runner, version, previous); err != nil {
|
||||
if err := verifyRestart(ctx, lifecycle, version, previous); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, recoveryRequired("Pi restart verification failed", err)
|
||||
}
|
||||
state.Phase = PhaseVerified
|
||||
if err := hooks.writeState(request.StatePath, state); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, recoveryRequired("Pi restart verification state could not be recorded", err)
|
||||
}
|
||||
if err := hooks.removeFile(overridePath); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, recoveryRequired("Pi restart image override could not be removed", err)
|
||||
}
|
||||
if err := hooks.removeFile(request.StatePath); err != nil {
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, recoveryRequired("Pi restart recovery state could not be removed", err)
|
||||
}
|
||||
@@ -168,6 +174,28 @@ func restartWithHooks(
|
||||
return RestartResult{StatePath: request.StatePath, Version: version}, nil
|
||||
}
|
||||
|
||||
func recreateCoreWithoutImageChanges(ctx context.Context, runner Runner) error {
|
||||
result, err := runCompose(
|
||||
ctx,
|
||||
runner,
|
||||
"up",
|
||||
"--detach",
|
||||
"--wait",
|
||||
"--wait-timeout",
|
||||
"45",
|
||||
"--no-deps",
|
||||
"--force-recreate",
|
||||
"--no-build",
|
||||
"--pull",
|
||||
"never",
|
||||
"core",
|
||||
)
|
||||
if err != nil {
|
||||
return commandError("core recreation", result, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyRestart(ctx context.Context, runner Runner, wanted string, previous Image) error {
|
||||
if err := Doctor(ctx, runner); err != nil {
|
||||
return err
|
||||
@@ -223,14 +251,25 @@ func RecoverLifecycleMaintenance(
|
||||
if err := validateRestartRecoveryState(restartState); err != nil {
|
||||
return err
|
||||
}
|
||||
restartOverride := lifecycleOverridePath(restartStatePath, restartState.Transaction)
|
||||
if restartState.MutationStarted {
|
||||
if err := ensureMaintenance(ctx, runner); err != nil {
|
||||
if err := tagImage(ctx, runner, restartState.Previous.ID, restartState.Previous.Reference, "restart recovery image pin"); err != nil {
|
||||
return recoveryRequired("Pi restart recovery image pin could not be restored", err)
|
||||
}
|
||||
if err := writeLifecycleOverride(restartOverride, restartState.Previous.Reference); err != nil {
|
||||
return recoveryRequired("Pi restart recovery image override could not be restored", err)
|
||||
}
|
||||
lifecycle := composeOverrideRunner{Runner: runner, path: restartOverride}
|
||||
if err := ensureMaintenance(ctx, lifecycle); err != nil {
|
||||
return recoveryRequired("Pi restart maintenance recovery failed", err)
|
||||
}
|
||||
if err := verifyRestart(ctx, runner, restartState.Target.Version, restartState.Previous); err != nil {
|
||||
if err := verifyRestart(ctx, lifecycle, restartState.Target.Version, restartState.Previous); err != nil {
|
||||
return recoveryRequired("Pi restart recovery verification failed", err)
|
||||
}
|
||||
}
|
||||
if err := durableRemove(restartOverride); err != nil {
|
||||
return recoveryRequired("Pi restart recovery image override could not be removed", err)
|
||||
}
|
||||
if err := durableRemove(restartStatePath); err != nil {
|
||||
return recoveryRequired("Pi restart recovery state could not be removed", err)
|
||||
}
|
||||
@@ -292,3 +331,30 @@ func validateRestartStatePaths(restartStatePath, updateStatePath string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pairedRestartStatePath(updateStatePath string) string {
|
||||
return filepath.Join(filepath.Dir(updateStatePath), "restart-state.json")
|
||||
}
|
||||
|
||||
func prepareLifecycleMutation(restartStatePath string, removeFile func(string) error) error {
|
||||
state, err := readState(restartStatePath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("restart recovery state could not be validated: %w", err)
|
||||
}
|
||||
if err := validateRestartRecoveryState(state); err != nil {
|
||||
return err
|
||||
}
|
||||
if state.Phase != PhaseVerified || !state.MutationStarted {
|
||||
return ErrInterruptedRestart
|
||||
}
|
||||
if err := removeFile(lifecycleOverridePath(restartStatePath, state.Transaction)); err != nil {
|
||||
return recoveryRequired("verified restart override could not be cleaned up", err)
|
||||
}
|
||||
if err := removeFile(restartStatePath); err != nil {
|
||||
return recoveryRequired("verified restart state could not be cleaned up", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -55,15 +55,51 @@ func TestRestartDrainsRecreatesOnlyCoreAndRetainsImage(t *testing.T) {
|
||||
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 ")
|
||||
assertCalled(t, fake.calls, "up --detach --wait --wait-timeout 45 --no-deps --force-recreate --no-build --pull never core")
|
||||
assertNotCalled(t, fake.calls, "compose build --pull")
|
||||
for _, call := range fake.calls {
|
||||
if strings.HasPrefix(call, "pull ") {
|
||||
t.Fatalf("restart invoked direct image pull: %s", call)
|
||||
}
|
||||
}
|
||||
assertNotCalled(t, fake.calls, "frontend")
|
||||
if _, err := os.Stat(result.StatePath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("successful restart state still exists: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartPinsCapturedImageWhenConfiguredTagMovesBeforeRecreate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fake := newFakeRunner()
|
||||
hooks := defaultLifecycleHooks
|
||||
write := hooks.writeState
|
||||
hooks.writeState = func(path string, state State) error {
|
||||
if state.Phase == PhasePreflight && state.MutationStarted {
|
||||
fake.tags[fake.configuredImage] = "sha256:moved-configured-tag"
|
||||
fake.imageVersions["sha256:moved-configured-tag"] = "9.99.0"
|
||||
}
|
||||
return write(path, state)
|
||||
}
|
||||
restartStatePath := filepath.Join(dir, "restart-state.json")
|
||||
|
||||
result, err := restartWithHooks(context.Background(), fake, RestartRequest{
|
||||
StatePath: restartStatePath,
|
||||
UpdateStatePath: filepath.Join(dir, "update-state.json"),
|
||||
Confirm: true,
|
||||
}, hooks)
|
||||
if err != nil {
|
||||
t.Fatalf("restartWithHooks() error = %v", err)
|
||||
}
|
||||
if result.Version != "0.80.3" || fake.currentImage != "sha256:old" {
|
||||
t.Fatalf("restart result=%+v image=%q; want captured 0.80.3 / sha256:old", result, fake.currentImage)
|
||||
}
|
||||
assertCalled(t, fake.calls, "image tag sha256:old thothii-core:thothctl-")
|
||||
assertCalled(t, fake.calls, "pi-lifecycle-")
|
||||
if matches, globErr := filepath.Glob(filepath.Join(dir, "pi-lifecycle-*.yaml")); globErr != nil || len(matches) != 0 {
|
||||
t.Fatalf("successful restart overrides = %v, error = %v; want safe cleanup", matches, globErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartRefusesActiveSessionsWithoutDrain(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fake := newFakeRunner()
|
||||
@@ -193,6 +229,11 @@ func TestRestartPostRecreateFailureKeepsMaintenanceAndRecoveryState(t *testing.T
|
||||
if stateErr != nil || !state.MutationStarted {
|
||||
t.Fatalf("restart recovery state = %+v, %v; want durable mutation state", state, stateErr)
|
||||
}
|
||||
overridePath := lifecycleOverridePath(statePath, state.Transaction)
|
||||
selected, overrideErr := readLifecycleOverride(overridePath)
|
||||
if overrideErr != nil || selected != state.Previous.Reference || fake.tags[selected] != state.Previous.ID {
|
||||
t.Fatalf("restart override = %q, %v; want retained exact image %q", selected, overrideErr, state.Previous.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartMaintenanceClearFailureRestoresRecoveryState(t *testing.T) {
|
||||
@@ -233,6 +274,16 @@ func TestRecoverLifecycleMaintenanceVerifiesAndClearsRestartState(t *testing.T)
|
||||
Previous: previous,
|
||||
MutationStarted: true,
|
||||
})
|
||||
restartState, err := readState(restartStatePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
restartOverride := lifecycleOverridePath(restartStatePath, restartState.Transaction)
|
||||
if err := writeLifecycleOverride(restartOverride, restartState.Previous.Reference); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(fake.tags, restartState.Previous.Reference)
|
||||
fake.tags[fake.configuredImage] = "sha256:moved-before-recovery"
|
||||
candidate := previous
|
||||
candidate.Reference = "thothii-core:thothctl-recover-candidate"
|
||||
writeStateForTest(t, updateStatePath, State{
|
||||
@@ -254,6 +305,13 @@ func TestRecoverLifecycleMaintenanceVerifiesAndClearsRestartState(t *testing.T)
|
||||
if _, err := os.Stat(restartStatePath); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("restart recovery state still exists: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(restartOverride); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("restart recovery override still exists: %v", err)
|
||||
}
|
||||
if fake.tags[restartState.Previous.Reference] != restartState.Previous.ID {
|
||||
t.Fatalf("restart recovery pin = %q, want %q", fake.tags[restartState.Previous.Reference], restartState.Previous.ID)
|
||||
}
|
||||
assertCalled(t, fake.calls, restartOverride)
|
||||
if fake.maintenance {
|
||||
t.Fatal("maintenance remained active after both lifecycle states were verified")
|
||||
}
|
||||
|
||||
@@ -37,12 +37,13 @@ const (
|
||||
|
||||
// Request contains only non-secret operator inputs.
|
||||
type Request struct {
|
||||
StatePath string
|
||||
Version string
|
||||
Source Source
|
||||
Image string
|
||||
Confirm bool
|
||||
Drain bool
|
||||
StatePath string
|
||||
RestartStatePath string
|
||||
Version string
|
||||
Source Source
|
||||
Image string
|
||||
Confirm bool
|
||||
Drain bool
|
||||
}
|
||||
|
||||
// Result summarizes the completed, failed, or recovered transaction without command output.
|
||||
@@ -72,6 +73,12 @@ func updateWithHooks(ctx context.Context, runner Runner, request Request, hooks
|
||||
if request.StatePath == "" {
|
||||
return Result{}, errors.New("update state path is required")
|
||||
}
|
||||
if request.RestartStatePath == "" {
|
||||
request.RestartStatePath = pairedRestartStatePath(request.StatePath)
|
||||
}
|
||||
if err := validateRestartStatePaths(request.RestartStatePath, request.StatePath); err != nil {
|
||||
return Result{StatePath: request.StatePath}, err
|
||||
}
|
||||
lock, err := acquireLock(request.StatePath)
|
||||
if err != nil {
|
||||
return Result{StatePath: request.StatePath}, err
|
||||
@@ -96,6 +103,9 @@ func updateWithHooks(ctx context.Context, runner Runner, request Request, hooks
|
||||
}
|
||||
request.Image = canonical
|
||||
}
|
||||
if err := prepareLifecycleMutation(request.RestartStatePath, hooks.removeFile); err != nil {
|
||||
return Result{StatePath: request.StatePath}, err
|
||||
}
|
||||
if old, err := readState(request.StatePath); err == nil && stateNeedsRecovery(old) {
|
||||
return Result{StatePath: request.StatePath}, ErrInterruptedUpdate
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
@@ -254,11 +264,17 @@ func failPreparation(statePath, overridePath string, state State, cause error, h
|
||||
}
|
||||
|
||||
// Rollback restores the image recorded in durable update state. It is safe for interrupted runs.
|
||||
func Rollback(ctx context.Context, runner Runner, statePath string, confirm bool) (result Result, retErr error) {
|
||||
return rollbackWithHooks(ctx, runner, statePath, confirm, defaultLifecycleHooks)
|
||||
func Rollback(ctx context.Context, runner Runner, statePath, restartStatePath string, confirm bool) (result Result, retErr error) {
|
||||
return rollbackWithHooks(ctx, runner, statePath, restartStatePath, confirm, defaultLifecycleHooks)
|
||||
}
|
||||
|
||||
func rollbackWithHooks(ctx context.Context, runner Runner, statePath string, confirm bool, hooks lifecycleHooks) (result Result, retErr error) {
|
||||
func rollbackWithHooks(ctx context.Context, runner Runner, statePath, restartStatePath string, confirm bool, hooks lifecycleHooks) (result Result, retErr error) {
|
||||
if restartStatePath == "" {
|
||||
restartStatePath = pairedRestartStatePath(statePath)
|
||||
}
|
||||
if err := validateRestartStatePaths(restartStatePath, statePath); err != nil {
|
||||
return Result{StatePath: statePath}, err
|
||||
}
|
||||
lock, err := acquireLock(statePath)
|
||||
if err != nil {
|
||||
return Result{StatePath: statePath}, err
|
||||
@@ -267,6 +283,9 @@ func rollbackWithHooks(ctx context.Context, runner Runner, statePath string, con
|
||||
if !confirm {
|
||||
return Result{StatePath: statePath}, ErrConfirmationRequired
|
||||
}
|
||||
if err := prepareLifecycleMutation(restartStatePath, hooks.removeFile); err != nil {
|
||||
return Result{StatePath: statePath}, err
|
||||
}
|
||||
maintenanceErr := ensureMaintenance(ctx, runner)
|
||||
clearMaintenance := maintenanceErr == nil
|
||||
defer func() {
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestSuccessfulUpdateAndRollbackRemainSelectedOnFreshRecreate(t *testing.T)
|
||||
if fake.currentImage != "sha256:candidate" {
|
||||
t.Fatalf("fresh recreate image = %q, want verified candidate", fake.currentImage)
|
||||
}
|
||||
if _, err := Rollback(context.Background(), fake, statePath, true); err != nil {
|
||||
if _, err := Rollback(context.Background(), fake, statePath, pairedRestartStatePath(statePath), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fake.currentImage = "sha256:candidate"
|
||||
@@ -209,7 +209,7 @@ func TestDigestPinnedConfiguredImageIsNeverUsedAsARollbackTagTarget(t *testing.T
|
||||
if _, err := Update(context.Background(), fake, Request{StatePath: statePath, Version: "0.81.0", Source: BuildSource, Confirm: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Rollback(context.Background(), fake, statePath, true); err != nil {
|
||||
if _, err := Rollback(context.Background(), fake, statePath, pairedRestartStatePath(statePath), true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertNotCalled(t, fake.calls, "image tag sha256:old "+fake.configuredImage)
|
||||
@@ -306,7 +306,7 @@ func TestSuccessfulLifecycleCommandsPreserveTypedRecoveryErrorFromMaintenanceCle
|
||||
Confirm: true,
|
||||
})
|
||||
} else {
|
||||
result, err = Rollback(context.Background(), fake, statePath, true)
|
||||
result, err = Rollback(context.Background(), fake, statePath, pairedRestartStatePath(statePath), true)
|
||||
}
|
||||
|
||||
if !fake.maintenance {
|
||||
@@ -382,7 +382,7 @@ func TestManualRollbackSurvivesADeadCandidateCore(t *testing.T) {
|
||||
fake.coreRunning = false
|
||||
fake.maintenance = false
|
||||
|
||||
result, err := Rollback(context.Background(), fake, statePath, true)
|
||||
result, err := Rollback(context.Background(), fake, statePath, pairedRestartStatePath(statePath), true)
|
||||
|
||||
if err != nil || result.Phase != PhaseRolledBack {
|
||||
t.Fatalf("Rollback() = %+v, %v; want restored previous core", result, err)
|
||||
@@ -657,7 +657,7 @@ func TestRollbackFinalStateWriteFailureKeepsMaintenanceAndOverrideForRecovery(t
|
||||
hooks := defaultLifecycleHooks
|
||||
hooks.writeState = func(string, State) error { return errors.New("injected rollback state write failure") }
|
||||
|
||||
result, err := rollbackWithHooks(context.Background(), fake, statePath, true, hooks)
|
||||
result, err := rollbackWithHooks(context.Background(), fake, statePath, pairedRestartStatePath(statePath), true, hooks)
|
||||
if err == nil || result.Phase != PhaseFailed {
|
||||
t.Fatalf("rollbackWithHooks() = %+v, %v; want failed durable finalization", result, err)
|
||||
}
|
||||
@@ -783,7 +783,7 @@ func TestRollbackRestoresInterruptedOrPreviouslyRecordedState(t *testing.T) {
|
||||
previous.Reference = "thothii-core:thothctl-test-previous"
|
||||
fake.tags[previous.Reference] = previous.ID
|
||||
writeStateForTest(t, statePath, State{Version: 1, Phase: PhaseRecreated, Previous: previous})
|
||||
result, err := Rollback(context.Background(), fake, statePath, true)
|
||||
result, err := Rollback(context.Background(), fake, statePath, pairedRestartStatePath(statePath), true)
|
||||
if err != nil {
|
||||
t.Fatalf("Rollback() error = %v", err)
|
||||
}
|
||||
@@ -805,6 +805,100 @@ func TestUpdateRefusesToOverwriteInterruptedRecoveryState(t *testing.T) {
|
||||
assertNotCalled(t, fake.calls, "compose")
|
||||
}
|
||||
|
||||
func TestFailedRestartBlocksUpdateAndRollbackAndMalformedStateAlsoRejects(t *testing.T) {
|
||||
for _, operation := range []string{"update", "rollback"} {
|
||||
for _, restartState := range []string{"failed", "malformed"} {
|
||||
t.Run(operation+"_"+restartState, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fake := newFakeRunner()
|
||||
updateStatePath := filepath.Join(dir, "update-state.json")
|
||||
restartStatePath := filepath.Join(dir, "restart-state.json")
|
||||
if restartState == "failed" {
|
||||
fake.fail = "health"
|
||||
_, restartErr := Restart(context.Background(), fake, RestartRequest{
|
||||
StatePath: restartStatePath,
|
||||
UpdateStatePath: updateStatePath,
|
||||
Confirm: true,
|
||||
})
|
||||
var recovery *RecoveryRequiredError
|
||||
if !errors.As(restartErr, &recovery) {
|
||||
t.Fatalf("Restart() error = %v, want failed restart recovery state", restartErr)
|
||||
}
|
||||
fake.fail = ""
|
||||
} else if err := os.WriteFile(restartStatePath, []byte("{malformed"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if operation == "rollback" {
|
||||
previous := stateImageForTest(t, fake)
|
||||
writeStateForTest(t, updateStatePath, State{
|
||||
Transaction: "rollback-target",
|
||||
Phase: PhaseRecreated,
|
||||
Target: Target{Version: fake.version, Source: string(BuildSource)},
|
||||
Previous: previous,
|
||||
MutationStarted: true,
|
||||
})
|
||||
}
|
||||
fake.calls = nil
|
||||
|
||||
var err error
|
||||
if operation == "update" {
|
||||
_, err = Update(context.Background(), fake, Request{
|
||||
StatePath: updateStatePath,
|
||||
RestartStatePath: restartStatePath,
|
||||
Version: "0.81.0",
|
||||
Source: BuildSource,
|
||||
Confirm: true,
|
||||
})
|
||||
} else {
|
||||
_, err = Rollback(context.Background(), fake, updateStatePath, restartStatePath, true)
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("%s accepted %s restart recovery state", operation, restartState)
|
||||
}
|
||||
if restartState == "failed" && !errors.Is(err, ErrInterruptedRestart) {
|
||||
t.Fatalf("%s error = %v, want ErrInterruptedRestart", operation, err)
|
||||
}
|
||||
assertNotCalled(t, fake.calls, "compose")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateCleansVerifiedRestartStateBeforeNormalLifecycleWork(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
fake := newFakeRunner()
|
||||
updateStatePath := filepath.Join(dir, "update-state.json")
|
||||
restartStatePath := filepath.Join(dir, "restart-state.json")
|
||||
state := State{
|
||||
Transaction: "verified-restart",
|
||||
Phase: PhaseVerified,
|
||||
Target: Target{Version: fake.version, Source: "restart"},
|
||||
Previous: stateImageForTest(t, fake),
|
||||
MutationStarted: true,
|
||||
}
|
||||
writeStateForTest(t, restartStatePath, state)
|
||||
overridePath := lifecycleOverridePath(restartStatePath, state.Transaction)
|
||||
if err := writeLifecycleOverride(overridePath, state.Previous.Reference); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := Update(context.Background(), fake, Request{
|
||||
StatePath: updateStatePath,
|
||||
RestartStatePath: restartStatePath,
|
||||
Version: fake.version,
|
||||
Source: BuildSource,
|
||||
Confirm: true,
|
||||
})
|
||||
if err != nil || result.Phase != PhaseNoop {
|
||||
t.Fatalf("Update() = %+v, %v; want normal no-op after verified restart", result, err)
|
||||
}
|
||||
for _, path := range []string{restartStatePath, overridePath} {
|
||||
if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("terminal restart artifact %s still exists: %v", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunningImageCapturesServerBindAndNamedMountIdentity(t *testing.T) {
|
||||
fake := newFakeRunner()
|
||||
fake.mountsJSON = `[{"Type":"bind","Source":"/srv/thothii/data","Destination":"/data","RW":true},{"Type":"bind","Source":"/srv/thothii/pi","Destination":"/home/thoth/.pi","RW":true},{"Type":"volume","Name":"sessions","Source":"/var/lib/docker/volumes/sessions/_data","Destination":"/data/sessions","RW":true}]`
|
||||
|
||||
Reference in New Issue
Block a user