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) }