Prototype atomic install recovery (T-103)
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrHealthCheckRequired = errors.New("health check is required")
|
||||
ErrHealthCheckFailed = errors.New("installed version failed health check")
|
||||
ErrRollbackFailed = errors.New("install rollback failed")
|
||||
)
|
||||
|
||||
type HealthCheck func(currentPath string) error
|
||||
|
||||
type switchStep string
|
||||
|
||||
const (
|
||||
stepPrepared switchStep = "prepared"
|
||||
stepCurrentRenamed switchStep = "current_renamed"
|
||||
stepCurrentBackedUp switchStep = "current_backed_up"
|
||||
stepStagingRenamed switchStep = "staging_renamed"
|
||||
stepStagingActivated switchStep = "staging_activated"
|
||||
stepRollbackRequired switchStep = "rollback_required"
|
||||
stepCommitted switchStep = "committed"
|
||||
)
|
||||
|
||||
// RollbackError reports both the health failure and rollback failure.
|
||||
type RollbackError struct {
|
||||
Health error
|
||||
Rollback error
|
||||
}
|
||||
|
||||
func (err *RollbackError) Error() string {
|
||||
return fmt.Sprintf("%s: health=%v; rollback=%v", ErrRollbackFailed, err.Health, err.Rollback)
|
||||
}
|
||||
|
||||
func (err *RollbackError) Unwrap() error {
|
||||
return ErrRollbackFailed
|
||||
}
|
||||
|
||||
// Switcher activates a verified staging directory and runs an injected check.
|
||||
type Switcher struct {
|
||||
health HealthCheck
|
||||
afterStep func(switchStep) error
|
||||
}
|
||||
|
||||
func NewSwitcher(health HealthCheck) *Switcher {
|
||||
return &Switcher{health: health}
|
||||
}
|
||||
|
||||
func (switcher *Switcher) Switch(root string) error {
|
||||
if switcher.health == nil {
|
||||
return ErrHealthCheckRequired
|
||||
}
|
||||
layout, err := inspectAppLayout(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, exists, err := loadTransaction(layout); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return ErrRecoveryRequired
|
||||
}
|
||||
state, err := inspectDirectories(layout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !state.staging {
|
||||
return ErrStagingMissing
|
||||
}
|
||||
if state.backup {
|
||||
return ErrBackupExists
|
||||
}
|
||||
|
||||
record := newTransaction(phasePrepared, state.current)
|
||||
if err := writeTransaction(layout, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := switcher.runStep(stepPrepared); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if state.current {
|
||||
if err := os.Rename(layout.current, layout.backup); err != nil {
|
||||
return fmt.Errorf("backup current directory: %w", err)
|
||||
}
|
||||
if err := switcher.runStep(stepCurrentRenamed); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
record.Phase = phaseCurrentBackedUp
|
||||
if err := writeTransaction(layout, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := switcher.runStep(stepCurrentBackedUp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.Rename(layout.staging, layout.current); err != nil {
|
||||
return fmt.Errorf("activate staging directory: %w", err)
|
||||
}
|
||||
if err := switcher.runStep(stepStagingRenamed); err != nil {
|
||||
return err
|
||||
}
|
||||
record.Phase = phaseStagingActivated
|
||||
if err := writeTransaction(layout, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := switcher.runStep(stepStagingActivated); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
healthErr := switcher.health(layout.current)
|
||||
if healthErr != nil {
|
||||
record.Phase = phaseRollbackRequired
|
||||
if err := writeTransaction(layout, record); err != nil {
|
||||
return &RollbackError{Health: healthErr, Rollback: err}
|
||||
}
|
||||
if err := switcher.runStep(stepRollbackRequired); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rollbackActivated(layout, record.HadCurrent); err != nil {
|
||||
return &RollbackError{Health: healthErr, Rollback: err}
|
||||
}
|
||||
if err := removeTransaction(layout); err != nil {
|
||||
return &RollbackError{Health: healthErr, Rollback: err}
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrHealthCheckFailed, healthErr)
|
||||
}
|
||||
|
||||
record.Phase = phaseCommitted
|
||||
if err := writeTransaction(layout, record); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := switcher.runStep(stepCommitted); err != nil {
|
||||
return err
|
||||
}
|
||||
if record.HadCurrent {
|
||||
if err := removeManagedDirectory(layout, layout.backup); err != nil {
|
||||
return fmt.Errorf("%w: cleanup committed backup: %v", ErrRecoveryRequired, err)
|
||||
}
|
||||
}
|
||||
if err := removeTransaction(layout); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrRecoveryRequired, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (switcher *Switcher) runStep(step switchStep) error {
|
||||
if switcher.afterStep == nil {
|
||||
return nil
|
||||
}
|
||||
return switcher.afterStep(step)
|
||||
}
|
||||
|
||||
func rollbackActivated(layout appLayout, hadCurrent bool) error {
|
||||
state, err := inspectDirectories(layout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hadCurrent {
|
||||
if !state.backup {
|
||||
return fmt.Errorf("%w: previous current backup is missing", ErrRollbackFailed)
|
||||
}
|
||||
if state.current {
|
||||
if state.staging {
|
||||
return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed)
|
||||
}
|
||||
if err := os.Rename(layout.current, layout.staging); err != nil {
|
||||
return fmt.Errorf("move failed current aside: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.Rename(layout.backup, layout.current); err != nil {
|
||||
if _, statErr := os.Stat(layout.staging); statErr == nil {
|
||||
_ = os.Rename(layout.staging, layout.current)
|
||||
}
|
||||
return fmt.Errorf("restore previous current: %w", err)
|
||||
}
|
||||
return removeManagedDirectory(layout, layout.staging)
|
||||
}
|
||||
|
||||
if state.backup {
|
||||
return fmt.Errorf("%w: unexpected backup without previous current", ErrRollbackFailed)
|
||||
}
|
||||
if state.current {
|
||||
if state.staging {
|
||||
return fmt.Errorf("%w: current and staging both exist", ErrRollbackFailed)
|
||||
}
|
||||
if err := os.Rename(layout.current, layout.staging); err != nil {
|
||||
return fmt.Errorf("move failed initial install aside: %w", err)
|
||||
}
|
||||
}
|
||||
return removeManagedDirectory(layout, layout.staging)
|
||||
}
|
||||
Reference in New Issue
Block a user