262 lines
7.0 KiB
Go
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)
|
|
}
|