Implement self-update recovery (T-403)
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// Package updater performs the constrained on-disk activation of a prepared
|
||||
// SoftBox self-update. It deliberately does not download, verify, or select an
|
||||
// update package.
|
||||
package updater
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// ProductExecutableName is the only executable the updater may start.
|
||||
ProductExecutableName = "SoftBox.exe"
|
||||
// InternalHealthFlag is accepted only by SoftBox itself after an update.
|
||||
InternalHealthFlag = "--softbox-update-health"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidRequest = errors.New("invalid self-update request")
|
||||
ErrUnsafeLayout = errors.New("unsafe self-update layout")
|
||||
ErrStagingMissing = errors.New("self-update staging directory is missing")
|
||||
ErrBackupExists = errors.New("self-update backup directory already exists")
|
||||
ErrTransactionCorrupt = errors.New("self-update transaction is corrupt")
|
||||
ErrRecoveryRequired = errors.New("self-update recovery is required")
|
||||
ErrParentWait = errors.New("wait for SoftBox parent process")
|
||||
ErrLaunch = errors.New("launch updated SoftBox")
|
||||
ErrHealthTimeout = errors.New("updated SoftBox health confirmation timed out")
|
||||
ErrHealthInvalid = errors.New("updated SoftBox health confirmation is invalid")
|
||||
)
|
||||
|
||||
// Request identifies one prepared self-update. StagingDir and TargetDir are
|
||||
// both checked against the fixed layout; callers cannot choose arbitrary move
|
||||
// endpoints.
|
||||
type Request struct {
|
||||
ParentPID int
|
||||
StagingDir string
|
||||
TargetDir string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
// StartCommand contains the only process launch allowed by this package.
|
||||
// Platform adapters must pass precisely the fixed internal health flag.
|
||||
type StartCommand struct {
|
||||
Entrypoint string
|
||||
WorkingDirectory string
|
||||
HealthRequestID string
|
||||
}
|
||||
|
||||
// ProcessWaiter waits for the old main-process PID to end naturally.
|
||||
type ProcessWaiter interface {
|
||||
WaitForProcessExit(context.Context, int, time.Duration) error
|
||||
}
|
||||
|
||||
// Launcher starts the verified new main executable without a shell.
|
||||
type Launcher interface {
|
||||
StartSelfUpdate(StartCommand) (int, error)
|
||||
}
|
||||
|
||||
// DirectorySyncer is implemented by the platform boundary. Directory flushing
|
||||
// requires a Windows handle implementation and must not leak into core.
|
||||
type DirectorySyncer interface {
|
||||
SyncDirectory(path string) error
|
||||
}
|
||||
|
||||
// HealthWaiter observes the minimal acknowledgement written by the new main
|
||||
// process. It must only accept the exact request ID.
|
||||
type HealthWaiter interface {
|
||||
WaitForHealth(context.Context, string, string, time.Duration) error
|
||||
}
|
||||
|
||||
// Timeouts controls externally-blocking update operations.
|
||||
type Timeouts struct {
|
||||
ParentExit time.Duration
|
||||
Health time.Duration
|
||||
}
|
||||
|
||||
// Service owns one constrained self-update orchestration.
|
||||
type Service struct {
|
||||
waiter ProcessWaiter
|
||||
launcher Launcher
|
||||
syncer DirectorySyncer
|
||||
health HealthWaiter
|
||||
timeouts Timeouts
|
||||
}
|
||||
|
||||
// NewService constructs an updater. Missing dependencies are reported by
|
||||
// Update, keeping command composition straightforward.
|
||||
func NewService(
|
||||
waiter ProcessWaiter,
|
||||
launcher Launcher,
|
||||
syncer DirectorySyncer,
|
||||
health HealthWaiter,
|
||||
timeouts Timeouts,
|
||||
) *Service {
|
||||
if timeouts.ParentExit <= 0 {
|
||||
timeouts.ParentExit = 2 * time.Minute
|
||||
}
|
||||
if timeouts.Health <= 0 {
|
||||
timeouts.Health = 45 * time.Second
|
||||
}
|
||||
return &Service{waiter: waiter, launcher: launcher, syncer: syncer, health: health, timeouts: timeouts}
|
||||
}
|
||||
|
||||
// Update waits for the old process, recovers a previous interrupted switch if
|
||||
// needed, and activates the prepared directory. It never kills a process.
|
||||
func (service *Service) Update(ctx context.Context, request Request) error {
|
||||
if service == nil || service.waiter == nil || service.launcher == nil || service.syncer == nil || service.health == nil {
|
||||
return fmt.Errorf("%w: updater dependencies are incomplete", ErrInvalidRequest)
|
||||
}
|
||||
layout, err := inspectRequest(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := service.waiter.WaitForProcessExit(ctx, request.ParentPID, service.timeouts.ParentExit); err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrParentWait, err)
|
||||
}
|
||||
if err := recoverLayout(layout, service.syncer); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := layout.validateReady(service.syncer); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeHealth(layout, service.syncer); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeTransaction(layout, phasePrepared, service.syncer); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := renameDirectory(layout.target, layout.backup, layout, service.syncer, "back up current app"); err != nil {
|
||||
return service.failBeforeActivation(err)
|
||||
}
|
||||
if err := writeTransaction(layout, phaseTargetBackedUp, service.syncer); err != nil {
|
||||
return service.rollback(layout, err)
|
||||
}
|
||||
if err := renameDirectory(layout.staging, layout.target, layout, service.syncer, "activate staged app"); err != nil {
|
||||
return service.rollback(layout, err)
|
||||
}
|
||||
if err := writeTransaction(layout, phaseStagingActivated, service.syncer); err != nil {
|
||||
return service.rollback(layout, err)
|
||||
}
|
||||
|
||||
entrypoint := layout.entrypoint()
|
||||
if err := validateRegularFile(entrypoint); err != nil {
|
||||
return service.rollback(layout, fmt.Errorf("%w: %v", ErrUnsafeLayout, err))
|
||||
}
|
||||
if _, err := service.launcher.StartSelfUpdate(StartCommand{
|
||||
Entrypoint: entrypoint, WorkingDirectory: layout.target, HealthRequestID: layout.requestID,
|
||||
}); err != nil {
|
||||
return service.rollback(layout, fmt.Errorf("%w: %w", ErrLaunch, err))
|
||||
}
|
||||
if err := writeTransaction(layout, phaseLaunched, service.syncer); err != nil {
|
||||
return service.rollback(layout, err)
|
||||
}
|
||||
if err := service.health.WaitForHealth(ctx, layout.target, layout.requestID, service.timeouts.Health); err != nil {
|
||||
return service.rollback(layout, err)
|
||||
}
|
||||
if err := writeTransaction(layout, phaseCommitted, service.syncer); err != nil {
|
||||
return service.rollback(layout, err)
|
||||
}
|
||||
if err := removeManagedTree(layout.backup, layout, service.syncer); err != nil {
|
||||
return fmt.Errorf("commit self-update: %w", err)
|
||||
}
|
||||
if err := removeTransaction(layout, service.syncer); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := removeHealth(layout, service.syncer); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) failBeforeActivation(cause error) error {
|
||||
return fmt.Errorf("prepare self-update: %w", cause)
|
||||
}
|
||||
|
||||
func (service *Service) rollback(layout updateLayout, cause error) error {
|
||||
rollbackErr := rollbackLayout(layout, service.syncer)
|
||||
if rollbackErr != nil {
|
||||
return errors.Join(cause, rollbackErr)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
|
||||
// Recover restores or finalizes a previously interrupted transaction for the
|
||||
// fixed target directory. Callers must wait for any old parent process first.
|
||||
func Recover(targetDir string, syncer DirectorySyncer) error {
|
||||
if syncer == nil {
|
||||
return fmt.Errorf("%w: directory syncer is required", ErrInvalidRequest)
|
||||
}
|
||||
layout, err := inspectTarget(targetDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return recoverLayout(layout, syncer)
|
||||
}
|
||||
Reference in New Issue
Block a user