Files
soft_quay/core/installer/switcher.go
T

262 lines
7.0 KiB
Go

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