Files
soft_quay/core/updater/updater.go
T

199 lines
6.7 KiB
Go

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