323 lines
11 KiB
Go
323 lines
11 KiB
Go
// Package update contains the core-only application update orchestration.
|
|||
|
|
// It never obtains downloads, controls a window, or terminates a process.
|
||
|
|
package update
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"fmt"
|
||
|
|
"os"
|
||
|
|
"regexp"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"softbox.local/core/application/install"
|
||
|
|
"softbox.local/core/domain"
|
||
|
|
"softbox.local/core/internal/safepath"
|
||
|
|
"softbox.local/core/storage"
|
||
|
|
)
|
||
|
|
|
||
|
|
const (
|
||
|
|
minCloseTimeout = time.Second
|
||
|
|
maxCloseTimeout = 10 * time.Minute
|
||
|
|
)
|
||
|
|
|
||
|
|
var (
|
||
|
|
ErrUpdateConfig = errors.New("invalid update service configuration")
|
||
|
|
ErrUpdateRequest = errors.New("invalid update request")
|
||
|
|
ErrAppNotInstalled = errors.New("app is not installed for update")
|
||
|
|
ErrUpdateMetadata = errors.New("installed update metadata is invalid")
|
||
|
|
ErrUpdateTargetUnsafe = errors.New("installed update target is unsafe")
|
||
|
|
ErrUpdateNotAvailable = errors.New("catalog target is not newer than installed version")
|
||
|
|
ErrTargetStateCheck = errors.New("update target state check failed")
|
||
|
|
ErrCloseConfirmation = errors.New("update close confirmation failed")
|
||
|
|
ErrCloseDeclined = errors.New("update close request was declined")
|
||
|
|
ErrExitWait = errors.New("wait for update target exit failed")
|
||
|
|
updateAppIDPattern = regexp.MustCompile(`^[a-z0-9-]+$`)
|
||
|
|
)
|
||
|
|
|
||
|
|
// FailureCode is the stable, non-localized result of a failed update attempt.
|
||
|
|
type FailureCode string
|
||
|
|
|
||
|
|
const (
|
||
|
|
FailureCodeNotInstalled FailureCode = "not_installed"
|
||
|
|
FailureCodeUpdateMetadataInvalid FailureCode = "update_metadata_invalid"
|
||
|
|
FailureCodeUpdateTargetUnsafe FailureCode = "update_target_unsafe"
|
||
|
|
FailureCodeUpdateNotAvailable FailureCode = "update_not_available"
|
||
|
|
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
|
||
|
|
FailureCodeCloseConfirmationFailed FailureCode = "close_confirmation_unavailable"
|
||
|
|
FailureCodeCloseDeclined FailureCode = "close_declined"
|
||
|
|
FailureCodeExitWaitCanceled FailureCode = "exit_wait_canceled"
|
||
|
|
FailureCodeExitWaitTimedOut FailureCode = "exit_wait_timeout"
|
||
|
|
FailureCodeExitWaitFailed FailureCode = "exit_wait_unavailable"
|
||
|
|
FailureCodeInstallFailed FailureCode = "install_failed"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Error preserves a stable update code and the diagnostic cause. Installation
|
||
|
|
// errors remain in the unwrap chain so their stage and code stay observable.
|
||
|
|
type Error struct {
|
||
|
|
Code FailureCode
|
||
|
|
Err error
|
||
|
|
}
|
||
|
|
|
||
|
|
func (err *Error) Error() string {
|
||
|
|
return fmt.Sprintf("update (%s): %v", err.Code, err.Err)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (err *Error) Unwrap() error {
|
||
|
|
return err.Err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Request carries the trusted Catalog selection and completed-file candidate
|
||
|
|
// already required by install.InstallService. It accepts no process path,
|
||
|
|
// command, URL, or UI-provided version.
|
||
|
|
type Request struct {
|
||
|
|
Install install.InstallRequest
|
||
|
|
}
|
||
|
|
|
||
|
|
// Result reports the version committed by the existing installer.
|
||
|
|
type Result struct {
|
||
|
|
AppID string
|
||
|
|
Version string
|
||
|
|
}
|
||
|
|
|
||
|
|
// InstalledAppResolver supplies one verified record and its real current root.
|
||
|
|
type InstalledAppResolver interface {
|
||
|
|
ResolveCurrent(appID string) (storage.InstalledApp, string, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TargetStateChecker observes the precise old entrypoint before a close
|
||
|
|
// request. It must not turn a query failure into a stopped result.
|
||
|
|
type TargetStateChecker interface {
|
||
|
|
IsRunning(appID string, entrypointPath string) (bool, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// CloseConfirmer obtains the user's decision outside of Gio Layout. It must
|
||
|
|
// not close or terminate the process itself.
|
||
|
|
type CloseConfirmer interface {
|
||
|
|
ConfirmClose(ctx context.Context, appID string) (bool, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ExitWaiter waits only for natural exit of one precise entrypoint. A timeout
|
||
|
|
// is reported as context.DeadlineExceeded and cancellation retains ctx.Err().
|
||
|
|
type ExitWaiter interface {
|
||
|
|
WaitForExit(ctx context.Context, appID, entrypointPath string, timeout time.Duration) error
|
||
|
|
}
|
||
|
|
|
||
|
|
// InstallRunner is the verified installation boundary. The update service
|
||
|
|
// delegates all staging, switch, rollback and second running-state checks to it.
|
||
|
|
type InstallRunner interface {
|
||
|
|
Install(request install.InstallRequest) (install.InstallResult, error)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ServiceConfig makes the close confirmation and exit-wait policy explicit.
|
||
|
|
type ServiceConfig struct {
|
||
|
|
Records InstalledAppResolver
|
||
|
|
TargetState TargetStateChecker
|
||
|
|
Confirmation CloseConfirmer
|
||
|
|
ExitWaiter ExitWaiter
|
||
|
|
Installer InstallRunner
|
||
|
|
CloseTimeout time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
// Service implements a safe, non-destructive update flow.
|
||
|
|
type Service struct {
|
||
|
|
records InstalledAppResolver
|
||
|
|
targetState TargetStateChecker
|
||
|
|
confirmation CloseConfirmer
|
||
|
|
exitWaiter ExitWaiter
|
||
|
|
installer InstallRunner
|
||
|
|
closeTimeout time.Duration
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewService validates every dependency. There is deliberately no default
|
||
|
|
// confirmation, wait policy, or installer implementation.
|
||
|
|
func NewService(config ServiceConfig) (*Service, error) {
|
||
|
|
if config.Records == nil {
|
||
|
|
return nil, fmt.Errorf("%w: installed app resolver is required", ErrUpdateConfig)
|
||
|
|
}
|
||
|
|
if config.TargetState == nil {
|
||
|
|
return nil, fmt.Errorf("%w: target state checker is required", ErrUpdateConfig)
|
||
|
|
}
|
||
|
|
if config.Confirmation == nil {
|
||
|
|
return nil, fmt.Errorf("%w: close confirmer is required", ErrUpdateConfig)
|
||
|
|
}
|
||
|
|
if config.ExitWaiter == nil {
|
||
|
|
return nil, fmt.Errorf("%w: exit waiter is required", ErrUpdateConfig)
|
||
|
|
}
|
||
|
|
if config.Installer == nil {
|
||
|
|
return nil, fmt.Errorf("%w: install runner is required", ErrUpdateConfig)
|
||
|
|
}
|
||
|
|
if config.CloseTimeout < minCloseTimeout || config.CloseTimeout > maxCloseTimeout {
|
||
|
|
return nil, fmt.Errorf("%w: close timeout must be between %s and %s", ErrUpdateConfig, minCloseTimeout, maxCloseTimeout)
|
||
|
|
}
|
||
|
|
return &Service{
|
||
|
|
records: config.Records,
|
||
|
|
targetState: config.TargetState,
|
||
|
|
confirmation: config.Confirmation,
|
||
|
|
exitWaiter: config.ExitWaiter,
|
||
|
|
installer: config.Installer,
|
||
|
|
closeTimeout: config.CloseTimeout,
|
||
|
|
}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// Update confirms a running app may be closed, waits for its natural exit, and
|
||
|
|
// then delegates to the verified installer. The installer repeats target-state
|
||
|
|
// checks before extraction and immediately before replacing current.
|
||
|
|
func (service *Service) Update(ctx context.Context, request Request) (Result, error) {
|
||
|
|
if ctx == nil {
|
||
|
|
return Result{}, updateError(ErrUpdateRequest)
|
||
|
|
}
|
||
|
|
if err := ctx.Err(); err != nil {
|
||
|
|
return Result{}, updateError(err)
|
||
|
|
}
|
||
|
|
appID := request.Install.Entry.App.ID
|
||
|
|
if !updateAppIDPattern.MatchString(appID) {
|
||
|
|
return Result{}, updateError(ErrUpdateRequest)
|
||
|
|
}
|
||
|
|
record, current, err := service.records.ResolveCurrent(appID)
|
||
|
|
if err != nil {
|
||
|
|
return Result{}, updateResolverError(err)
|
||
|
|
}
|
||
|
|
entrypoint, err := updateEntrypoint(record, current)
|
||
|
|
if err != nil {
|
||
|
|
return Result{}, updateError(err)
|
||
|
|
}
|
||
|
|
if err := validateTargetVersion(record, request.Install); err != nil {
|
||
|
|
return Result{}, updateError(err)
|
||
|
|
}
|
||
|
|
|
||
|
|
running, err := service.targetState.IsRunning(record.ID, entrypoint)
|
||
|
|
if err != nil {
|
||
|
|
return Result{}, updateError(fmt.Errorf("%w: %w", ErrTargetStateCheck, err))
|
||
|
|
}
|
||
|
|
if running {
|
||
|
|
confirmed, err := service.confirmation.ConfirmClose(ctx, record.ID)
|
||
|
|
if err != nil {
|
||
|
|
return Result{}, updateError(fmt.Errorf("%w: %w", ErrCloseConfirmation, err))
|
||
|
|
}
|
||
|
|
if !confirmed {
|
||
|
|
return Result{}, updateError(ErrCloseDeclined)
|
||
|
|
}
|
||
|
|
if err := service.exitWaiter.WaitForExit(ctx, record.ID, entrypoint, service.closeTimeout); err != nil {
|
||
|
|
return Result{}, updateError(fmt.Errorf("%w: %w", ErrExitWait, err))
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
installed, err := service.installer.Install(request.Install)
|
||
|
|
if err != nil {
|
||
|
|
return Result{}, updateError(err)
|
||
|
|
}
|
||
|
|
return Result{AppID: installed.AppID, Version: installed.Version}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateTargetVersion(record storage.InstalledApp, request install.InstallRequest) error {
|
||
|
|
app := request.Entry.App
|
||
|
|
if request.DownloadPath == "" || app.ID != record.ID || request.Entry.Package == nil || !request.Entry.Installable {
|
||
|
|
return ErrUpdateRequest
|
||
|
|
}
|
||
|
|
if request.Architecture != "386" && request.Architecture != "amd64" {
|
||
|
|
return ErrUpdateRequest
|
||
|
|
}
|
||
|
|
publishedPackage, exists := app.Packages[request.Architecture]
|
||
|
|
if !exists || publishedPackage != *request.Entry.Package {
|
||
|
|
return ErrUpdateRequest
|
||
|
|
}
|
||
|
|
comparison, err := domain.CompareSemVer(app.Version, record.Version)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("%w: %v", ErrUpdateRequest, err)
|
||
|
|
}
|
||
|
|
if comparison <= 0 {
|
||
|
|
return ErrUpdateNotAvailable
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func updateEntrypoint(record storage.InstalledApp, current string) (string, error) {
|
||
|
|
if record.Entrypoint == "" || record.WorkingDirectory == "" || record.MinOS == "" {
|
||
|
|
return "", ErrUpdateMetadata
|
||
|
|
}
|
||
|
|
if err := safepath.ValidateRelative(record.Entrypoint); err != nil {
|
||
|
|
return "", fmt.Errorf("%w: entrypoint: %v", ErrUpdateMetadata, err)
|
||
|
|
}
|
||
|
|
if record.WorkingDirectory != "." {
|
||
|
|
if err := safepath.ValidateRelative(record.WorkingDirectory); err != nil {
|
||
|
|
return "", fmt.Errorf("%w: working directory: %v", ErrUpdateMetadata, err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !validMinOS(record.MinOS) {
|
||
|
|
return "", ErrUpdateMetadata
|
||
|
|
}
|
||
|
|
if !containsEntrypoint(record.Files, record.Entrypoint) {
|
||
|
|
return "", fmt.Errorf("%w: entrypoint is not in installed files", ErrUpdateMetadata)
|
||
|
|
}
|
||
|
|
entrypoint, err := safepath.JoinUnder(current, record.Entrypoint)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("%w: entrypoint: %v", ErrUpdateTargetUnsafe, err)
|
||
|
|
}
|
||
|
|
info, err := os.Lstat(entrypoint)
|
||
|
|
if os.IsNotExist(err) {
|
||
|
|
return "", fmt.Errorf("%w: entrypoint is missing", ErrUpdateTargetUnsafe)
|
||
|
|
}
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("%w: inspect entrypoint: %w", ErrUpdateTargetUnsafe, err)
|
||
|
|
}
|
||
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
||
|
|
return "", fmt.Errorf("%w: entrypoint is not a regular file", ErrUpdateTargetUnsafe)
|
||
|
|
}
|
||
|
|
return entrypoint, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func containsEntrypoint(files []storage.InstalledFile, entrypoint string) bool {
|
||
|
|
for _, file := range files {
|
||
|
|
if file.Path == entrypoint {
|
||
|
|
return true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
func validMinOS(minOS string) bool {
|
||
|
|
return minOS == "windows-7-sp1" || minOS == "windows-10" || minOS == "windows-11"
|
||
|
|
}
|
||
|
|
|
||
|
|
func updateResolverError(err error) error {
|
||
|
|
if errors.Is(err, os.ErrNotExist) {
|
||
|
|
return updateError(ErrAppNotInstalled)
|
||
|
|
}
|
||
|
|
if errors.Is(err, storage.ErrStorageLayoutUnsafe) {
|
||
|
|
return updateError(fmt.Errorf("%w: %w", ErrUpdateTargetUnsafe, err))
|
||
|
|
}
|
||
|
|
return updateError(fmt.Errorf("resolve installed app: %w", err))
|
||
|
|
}
|
||
|
|
|
||
|
|
func updateError(err error) error {
|
||
|
|
return &Error{Code: failureCodeFor(err), Err: err}
|
||
|
|
}
|
||
|
|
|
||
|
|
func failureCodeFor(err error) FailureCode {
|
||
|
|
switch {
|
||
|
|
case errors.Is(err, ErrAppNotInstalled):
|
||
|
|
return FailureCodeNotInstalled
|
||
|
|
case errors.Is(err, ErrUpdateMetadata):
|
||
|
|
return FailureCodeUpdateMetadataInvalid
|
||
|
|
case errors.Is(err, ErrUpdateTargetUnsafe):
|
||
|
|
return FailureCodeUpdateTargetUnsafe
|
||
|
|
case errors.Is(err, ErrUpdateNotAvailable):
|
||
|
|
return FailureCodeUpdateNotAvailable
|
||
|
|
case errors.Is(err, ErrTargetStateCheck):
|
||
|
|
return FailureCodeTargetStateUnavailable
|
||
|
|
case errors.Is(err, ErrCloseDeclined):
|
||
|
|
return FailureCodeCloseDeclined
|
||
|
|
case errors.Is(err, context.Canceled):
|
||
|
|
return FailureCodeExitWaitCanceled
|
||
|
|
case errors.Is(err, context.DeadlineExceeded):
|
||
|
|
return FailureCodeExitWaitTimedOut
|
||
|
|
case errors.Is(err, ErrCloseConfirmation):
|
||
|
|
return FailureCodeCloseConfirmationFailed
|
||
|
|
case errors.Is(err, ErrExitWait):
|
||
|
|
return FailureCodeExitWaitFailed
|
||
|
|
default:
|
||
|
|
return FailureCodeInstallFailed
|
||
|
|
}
|
||
|
|
}
|