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 // PreSwitchCheck runs immediately before replacing an existing current // directory. It is intentionally core-only so callers can inject process // state without bringing platform APIs into the transaction. type PreSwitchCheck func() 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 []error{ErrRollbackFailed, err.Health, err.Rollback} } // Switcher activates a verified staging directory and runs an injected check. type Switcher struct { health HealthCheck preSwitchCheck PreSwitchCheck afterStep func(switchStep) error durability durabilityFence } func NewSwitcher(health HealthCheck) *Switcher { return NewSwitcherWithPreSwitchCheck(health, nil) } // NewSwitcherWithPreSwitchCheck creates a switcher that performs the optional // check only for updates with an existing current directory. func NewSwitcherWithPreSwitchCheck( health HealthCheck, preSwitchCheck PreSwitchCheck, ) *Switcher { return &Switcher{ health: health, preSwitchCheck: preSwitchCheck, durability: defaultDurability(), } } func (switcher *Switcher) Switch(root string) error { if switcher.health == nil { return ErrHealthCheckRequired } fence := effectiveDurability(switcher.durability) 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 } if state.current && switcher.preSwitchCheck != nil { if err := switcher.preSwitchCheck(); err != nil { if cleanupErr := removeManagedDirectoryWithFence(layout, layout.staging, fence); cleanupErr != nil { return errors.Join(err, cleanupErr) } return err } } record := newTransaction(phasePrepared, state.current) if err := writeTransactionWithFence(layout, record, fence); err != nil { return err } if err := switcher.runStep(stepPrepared); err != nil { return err } if state.current { if err := renameManagedDirectory( layout, layout.current, layout.backup, fence, "app root after current backup rename", ); err != nil { return fmt.Errorf("backup current directory: %w", err) } if err := switcher.runStep(stepCurrentRenamed); err != nil { return err } } record.Phase = phaseCurrentBackedUp if err := writeTransactionWithFence(layout, record, fence); err != nil { return err } if err := switcher.runStep(stepCurrentBackedUp); err != nil { return err } if err := renameManagedDirectory( layout, layout.staging, layout.current, fence, "app root after staging activation rename", ); err != nil { return fmt.Errorf("activate staging directory: %w", err) } if err := switcher.runStep(stepStagingRenamed); err != nil { return err } record.Phase = phaseStagingActivated if err := writeTransactionWithFence(layout, record, fence); 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 := writeTransactionWithFence(layout, record, fence); err != nil { return &RollbackError{Health: healthErr, Rollback: err} } if err := switcher.runStep(stepRollbackRequired); err != nil { return err } if err := rollbackActivated(layout, record.HadCurrent, fence); err != nil { return &RollbackError{Health: healthErr, Rollback: err} } if err := removeTransactionWithFence(layout, fence); err != nil { return &RollbackError{Health: healthErr, Rollback: err} } return fmt.Errorf("%w: %w", ErrHealthCheckFailed, healthErr) } record.Phase = phaseCommitted if err := writeTransactionWithFence(layout, record, fence); err != nil { return err } if err := switcher.runStep(stepCommitted); err != nil { return err } if record.HadCurrent { if err := removeManagedDirectoryWithFence(layout, layout.backup, fence); err != nil { return fmt.Errorf("%w: cleanup committed backup: %w", ErrRecoveryRequired, err) } } if err := removeTransactionWithFence(layout, fence); err != nil { return fmt.Errorf("%w: %w", 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, fence durabilityFence) 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 := renameManagedDirectory( layout, layout.current, layout.staging, fence, "app root after rollback staging rename", ); err != nil { return fmt.Errorf("move failed current aside: %w", err) } } if err := renameManagedDirectory( layout, layout.backup, layout.current, fence, "app root after rollback current restore", ); err != nil { if _, statErr := os.Stat(layout.staging); statErr == nil { _ = renameManagedDirectory( layout, layout.staging, layout.current, fence, "app root after rollback restore", ) } return fmt.Errorf("restore previous current: %w", err) } return removeManagedDirectoryWithFence(layout, layout.staging, fence) } 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 := renameManagedDirectory( layout, layout.current, layout.staging, fence, "app root after initial rollback rename", ); err != nil { return fmt.Errorf("move failed initial install aside: %w", err) } } return removeManagedDirectoryWithFence(layout, layout.staging, fence) }