fix: harden embedded Pi lifecycle recovery
This commit is contained in:
@@ -289,10 +289,8 @@ func rollbackWithHooks(ctx context.Context, runner Runner, statePath string, con
|
||||
if !confirm {
|
||||
return Result{StatePath: statePath}, ErrConfirmationRequired
|
||||
}
|
||||
if err := setMaintenance(ctx, runner, true); err != nil {
|
||||
return Result{StatePath: statePath}, err
|
||||
}
|
||||
clearMaintenance := true
|
||||
maintenanceErr := ensureMaintenance(ctx, runner)
|
||||
clearMaintenance := maintenanceErr == nil
|
||||
defer func() {
|
||||
if !clearMaintenance {
|
||||
return
|
||||
@@ -306,14 +304,18 @@ func rollbackWithHooks(ctx context.Context, runner Runner, statePath string, con
|
||||
}
|
||||
}
|
||||
}()
|
||||
if active, err := activeSessions(ctx, runner); err != nil {
|
||||
return Result{StatePath: statePath}, err
|
||||
} else if active {
|
||||
return Result{StatePath: statePath}, ErrActiveSessions
|
||||
if maintenanceErr == nil {
|
||||
if active, err := activeSessions(ctx, runner); err != nil {
|
||||
return Result{StatePath: statePath}, err
|
||||
} else if active {
|
||||
return Result{StatePath: statePath}, ErrActiveSessions
|
||||
}
|
||||
}
|
||||
state, err := readState(statePath)
|
||||
if err != nil {
|
||||
clearMaintenance = false
|
||||
if maintenanceErr == nil {
|
||||
clearMaintenance = false
|
||||
}
|
||||
return Result{StatePath: statePath}, err
|
||||
}
|
||||
overridePath := lifecycleOverridePath(statePath, state.Transaction)
|
||||
@@ -321,8 +323,17 @@ func rollbackWithHooks(ctx context.Context, runner Runner, statePath string, con
|
||||
clearMaintenance = false
|
||||
return Result{StatePath: statePath}, err
|
||||
}
|
||||
clearMaintenance = false
|
||||
lifecycle := composeOverrideRunner{Runner: runner, path: overridePath}
|
||||
if maintenanceErr != nil {
|
||||
stopped, stopErr := coreIsStopped(ctx, runner)
|
||||
if stopErr != nil || !stopped {
|
||||
return Result{StatePath: statePath}, maintenanceErr
|
||||
}
|
||||
if err := persistMaintenanceWithoutLiveCore(ctx, lifecycle); err != nil {
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, err
|
||||
}
|
||||
}
|
||||
clearMaintenance = false
|
||||
if err := restore(ctx, lifecycle, state.Previous); err != nil {
|
||||
state.Phase, state.Error = PhaseFailed, "rollback failed"
|
||||
if writeErr := hooks.writeState(statePath, state); writeErr != nil {
|
||||
@@ -330,8 +341,13 @@ func rollbackWithHooks(ctx context.Context, runner Runner, statePath string, con
|
||||
}
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, err
|
||||
}
|
||||
if active, err := activeSessions(ctx, runner); err != nil {
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, err
|
||||
} else if active {
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, ErrActiveSessions
|
||||
}
|
||||
if err := promoteLifecycleOverride(overridePath, currentImageOverridePath(statePath), state.Previous.Reference); err != nil {
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, errors.New("rollback restored the core but durable current-image promotion failed: recovery required")
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, fmt.Errorf("rollback restored the core but durable current-image promotion failed: %w", err)
|
||||
}
|
||||
state.Phase, state.Error = PhaseRolledBack, ""
|
||||
if err := hooks.writeState(statePath, state); err != nil {
|
||||
@@ -342,20 +358,27 @@ func rollbackWithHooks(ctx context.Context, runner Runner, statePath string, con
|
||||
}
|
||||
|
||||
func compensate(ctx context.Context, runner Runner, statePath, overridePath string, state State, cause error, hooks lifecycleHooks) (Result, error, bool) {
|
||||
if err := ensureMaintenance(context.Background(), runner); err != nil {
|
||||
state.Phase, state.Error = PhaseFailed, "maintenance recovery failed"
|
||||
_ = hooks.writeState(statePath, state)
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, errors.New("update failed and maintenance could not be reactivated: recovery required"), false
|
||||
}
|
||||
if active, err := activeSessions(context.Background(), runner); err != nil || active {
|
||||
state.Phase, state.Error = PhaseFailed, "rollback inventory failed"
|
||||
_ = hooks.writeState(statePath, state)
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, errors.New("update failed and rollback inventory is not quiescent: recovery required"), false
|
||||
}
|
||||
if err := writeLifecycleOverride(overridePath, state.Previous.Reference); err != nil {
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, errors.New("update failed and rollback override could not be prepared: recovery required"), false
|
||||
}
|
||||
lifecycle := composeOverrideRunner{Runner: runner, path: overridePath}
|
||||
if err := ensureMaintenance(context.Background(), runner); err != nil {
|
||||
stopped, stopErr := coreIsStopped(context.Background(), runner)
|
||||
if stopErr != nil || !stopped {
|
||||
state.Phase, state.Error = PhaseFailed, "maintenance recovery failed"
|
||||
_ = hooks.writeState(statePath, state)
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, errors.New("update failed and maintenance could not be reactivated: recovery required"), false
|
||||
}
|
||||
if markerErr := persistMaintenanceWithoutLiveCore(context.Background(), lifecycle); markerErr != nil {
|
||||
state.Phase, state.Error = PhaseFailed, "maintenance recovery failed"
|
||||
_ = hooks.writeState(statePath, state)
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, errors.New("update failed and durable maintenance could not be established: recovery required"), false
|
||||
}
|
||||
} else if active, err := activeSessions(context.Background(), runner); err != nil || active {
|
||||
state.Phase, state.Error = PhaseFailed, "rollback inventory failed"
|
||||
_ = hooks.writeState(statePath, state)
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, errors.New("update failed and rollback inventory is not quiescent: recovery required"), false
|
||||
}
|
||||
if restoreErr := restore(ctx, lifecycle, state.Previous); restoreErr != nil {
|
||||
state.Phase, state.Error = PhaseFailed, "candidate verification and automatic rollback failed"
|
||||
if writeErr := hooks.writeState(statePath, state); writeErr != nil {
|
||||
@@ -363,8 +386,13 @@ func compensate(ctx context.Context, runner Runner, statePath, overridePath stri
|
||||
}
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, fmt.Errorf("update failed; automatic rollback also failed: recovery required"), false
|
||||
}
|
||||
if active, err := activeSessions(context.Background(), runner); err != nil || active {
|
||||
state.Phase, state.Error = PhaseFailed, "restored rollback inventory failed"
|
||||
_ = hooks.writeState(statePath, state)
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, errors.New("previous core image was restored but rollback inventory is not quiescent: recovery required"), false
|
||||
}
|
||||
if err := promoteLifecycleOverride(overridePath, currentImageOverridePath(statePath), state.Previous.Reference); err != nil {
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, errors.New("previous core image was restored but durable selector promotion failed: recovery required"), false
|
||||
return Result{Phase: PhaseFailed, StatePath: statePath}, fmt.Errorf("previous core image was restored but durable selector promotion failed: %w", err), false
|
||||
}
|
||||
state.Phase, state.Error = PhaseRolledBack, ""
|
||||
if writeErr := hooks.writeState(statePath, state); writeErr != nil {
|
||||
@@ -373,6 +401,49 @@ func compensate(ctx context.Context, runner Runner, statePath, overridePath stri
|
||||
return Result{Phase: PhaseRolledBack, StatePath: statePath}, fmt.Errorf("update failed; previous core image was restored: %w", cause), true
|
||||
}
|
||||
|
||||
func coreIsStopped(ctx context.Context, runner Runner) (bool, error) {
|
||||
result, err := runCompose(ctx, runner, "ps", "--status", "running", "-q", "core")
|
||||
if err != nil {
|
||||
return false, commandError("core running-state check", result, err)
|
||||
}
|
||||
return strings.TrimSpace(result.Stdout) == "", nil
|
||||
}
|
||||
|
||||
const maintenanceMarkerScript = `
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const marker = process.env.THT_MAINTENANCE_FILE;
|
||||
if (!marker) throw new Error("THT_MAINTENANCE_FILE is required");
|
||||
const directory = path.dirname(marker);
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
const temporary = marker + ".rollback-" + process.pid + "-" + Date.now();
|
||||
let file;
|
||||
try {
|
||||
file = fs.openSync(temporary, "wx", 0o600);
|
||||
fs.writeFileSync(file, "{\"version\":1,\"active\":true}\n", "utf8");
|
||||
fs.fsyncSync(file);
|
||||
fs.closeSync(file);
|
||||
file = undefined;
|
||||
fs.renameSync(temporary, marker);
|
||||
const directoryFile = fs.openSync(directory, "r");
|
||||
try { fs.fsyncSync(directoryFile); } finally { fs.closeSync(directoryFile); }
|
||||
} catch (error) {
|
||||
if (file !== undefined) try { fs.closeSync(file); } catch {}
|
||||
try { fs.unlinkSync(temporary); } catch {}
|
||||
throw error;
|
||||
}
|
||||
`
|
||||
|
||||
func persistMaintenanceWithoutLiveCore(ctx context.Context, runner Runner) error {
|
||||
result, err := runCompose(ctx, runner,
|
||||
"run", "--rm", "--no-deps", "--entrypoint", "node", "core", "-e", maintenanceMarkerScript,
|
||||
)
|
||||
if err != nil {
|
||||
return commandError("durable maintenance recovery", result, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sourceValue(request Request) string {
|
||||
if request.Source == PullSource {
|
||||
return request.Image
|
||||
@@ -403,15 +474,21 @@ func setMaintenance(ctx context.Context, runner Runner, enabled bool) error {
|
||||
args := []string{"exec", "-T", "core", "curl", "-fsS", "-X", "POST", "http://127.0.0.1:8787/internal/maintenance/" + path}
|
||||
result, err := runCompose(ctx, runner, args...)
|
||||
status, valid := parseMaintenanceStatus(result.Stdout)
|
||||
if err == nil && valid && status.Active == enabled && status.Admissions == 0 {
|
||||
if err == nil && valid && status.Active == enabled && status.Admissions == 0 && !status.RecoveryRequired {
|
||||
return nil
|
||||
}
|
||||
// A core recreate or transport interruption may lose only the response. Resolve ambiguity by
|
||||
// reading the durable gate state before deciding that operator recovery is required.
|
||||
observed, statusErr := MaintenanceStatus(ctx, runner)
|
||||
if statusErr == nil && observed.Active == enabled && observed.Admissions == 0 {
|
||||
if observed.RecoveryRequired {
|
||||
return recoveryRequired("maintenance durability was explicitly not acknowledged", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if valid && status.RecoveryRequired {
|
||||
return recoveryRequired("maintenance durability was explicitly not acknowledged", err)
|
||||
}
|
||||
if err != nil {
|
||||
return commandError("maintenance admission gate", result, err)
|
||||
}
|
||||
@@ -419,8 +496,9 @@ func setMaintenance(ctx context.Context, runner Runner, enabled bool) error {
|
||||
}
|
||||
|
||||
type MaintenanceState struct {
|
||||
Active bool `json:"active"`
|
||||
Admissions int `json:"admissions"`
|
||||
Active bool `json:"active"`
|
||||
Admissions int `json:"admissions"`
|
||||
RecoveryRequired bool `json:"recoveryRequired"`
|
||||
}
|
||||
|
||||
func parseMaintenanceStatus(value string) (MaintenanceState, bool) {
|
||||
@@ -443,9 +521,12 @@ func MaintenanceStatus(ctx context.Context, runner Runner) (MaintenanceState, er
|
||||
|
||||
func ensureMaintenance(ctx context.Context, runner Runner) error {
|
||||
status, err := MaintenanceStatus(ctx, runner)
|
||||
if err == nil && status.Active && status.Admissions == 0 {
|
||||
if err == nil && status.Active && status.Admissions == 0 && !status.RecoveryRequired {
|
||||
return nil
|
||||
}
|
||||
if err == nil && status.RecoveryRequired {
|
||||
return recoveryRequired("maintenance durability was explicitly not acknowledged", nil)
|
||||
}
|
||||
return setMaintenance(ctx, runner, true)
|
||||
}
|
||||
|
||||
@@ -539,13 +620,9 @@ func verifyCandidate(ctx context.Context, runner Runner, wanted string, previous
|
||||
if err != nil {
|
||||
return commandError("core health check", health, err)
|
||||
}
|
||||
version, err := Status(ctx, runner)
|
||||
if err != nil {
|
||||
if err := verifyCandidateVersionIdentity(ctx, runner, wanted); err != nil {
|
||||
return err
|
||||
}
|
||||
if version != wanted {
|
||||
return errors.New("candidate Pi version does not match requested pinned version")
|
||||
}
|
||||
if err := Test(ctx, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -566,6 +643,21 @@ func verifyCandidate(ctx context.Context, runner Runner, wanted string, previous
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyCandidateVersionIdentity(ctx context.Context, runner Runner, wanted string) error {
|
||||
executable, err := Status(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
environment, label, err := expectedVersions(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if executable != wanted || environment != wanted || label != wanted {
|
||||
return errors.New("candidate Pi executable, PI_VERSION, and image label do not all match the requested pinned version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func restore(ctx context.Context, runner Runner, previous Image) error {
|
||||
if err := tagImage(ctx, runner, previous.ID, previous.Reference, "rollback image restore"); err != nil {
|
||||
return err
|
||||
@@ -657,12 +749,19 @@ func writeLifecycleOverride(path, image string) error {
|
||||
}
|
||||
|
||||
func promoteLifecycleOverride(source, destination, expectedImage string) error {
|
||||
if err := durableReplace(source, destination, filepath.Dir(destination)); err != nil {
|
||||
return promoteLifecycleOverrideWith(source, destination, expectedImage, durableReplace)
|
||||
}
|
||||
|
||||
func promoteLifecycleOverrideWith(
|
||||
source, destination, expectedImage string,
|
||||
replace func(string, string, string) error,
|
||||
) error {
|
||||
if err := replace(source, destination, filepath.Dir(destination)); err != nil {
|
||||
selected, readErr := readLifecycleOverride(destination)
|
||||
if readErr == nil && selected == expectedImage {
|
||||
return nil
|
||||
return recoveryRequired("lifecycle image override changed but durability was not acknowledged", err)
|
||||
}
|
||||
return errors.New("lifecycle image override could not be promoted durably")
|
||||
return recoveryRequired("lifecycle image override could not be promoted durably", err)
|
||||
}
|
||||
selected, err := readLifecycleOverride(destination)
|
||||
if err != nil || selected != expectedImage {
|
||||
|
||||
Reference in New Issue
Block a user