415 lines
14 KiB
Go
415 lines
14 KiB
Go
package install
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"path/filepath"
|
|
|
|
"softbox.local/core/catalog"
|
|
"softbox.local/core/installer"
|
|
"softbox.local/core/storage"
|
|
)
|
|
|
|
var (
|
|
ErrInstallServiceConfig = errors.New("invalid install service configuration")
|
|
ErrInstallRequestInvalid = errors.New("invalid install request")
|
|
ErrInstallRecordWrite = errors.New("write installed app record")
|
|
ErrDiskSpaceInsufficient = errors.New("insufficient disk space for staging")
|
|
ErrDiskSpaceCheck = errors.New("disk space check failed")
|
|
ErrTargetRunning = errors.New("installed app is running")
|
|
ErrTargetStateCheck = errors.New("target state check failed")
|
|
)
|
|
|
|
const StagingDiskReserveBytes int64 = 64 * 1024 * 1024
|
|
|
|
// InstallStage makes the security-sensitive installation path observable to a
|
|
// background caller without giving the Gio layout any filesystem work.
|
|
type InstallStage string
|
|
|
|
const (
|
|
InstallStageVerify InstallStage = "verify"
|
|
InstallStageManifest InstallStage = "manifest"
|
|
InstallStagePreflight InstallStage = "preflight"
|
|
InstallStageExtract InstallStage = "extract"
|
|
InstallStageRecover InstallStage = "recover"
|
|
InstallStageSwitch InstallStage = "switch"
|
|
InstallStageHealth InstallStage = "health"
|
|
InstallStageRecord InstallStage = "record"
|
|
InstallStageRollback InstallStage = "rollback"
|
|
)
|
|
|
|
// FailureCode is the stable, non-localized result of an installation
|
|
// attempt. UI code may localize this code but must not display raw errors.
|
|
type FailureCode string
|
|
|
|
const (
|
|
FailureCodeHashMismatch FailureCode = "hash_mismatch"
|
|
FailureCodeZIPPathEscape FailureCode = "zip_path_escape"
|
|
FailureCodeZIPCorrupt FailureCode = "zip_corrupt"
|
|
FailureCodePackageInvalid FailureCode = "package_invalid"
|
|
FailureCodeDiskFull FailureCode = "disk_full"
|
|
FailureCodeDiskCheckFailed FailureCode = "disk_check_failed"
|
|
FailureCodeAppRunning FailureCode = "app_running"
|
|
FailureCodeTargetStateUnavailable FailureCode = "target_state_unavailable"
|
|
FailureCodeInstallFailed FailureCode = "install_failed"
|
|
)
|
|
|
|
// InstallError preserves a stable stage and its underlying cause.
|
|
type InstallError struct {
|
|
Stage InstallStage
|
|
Code FailureCode
|
|
Err error
|
|
}
|
|
|
|
func (err *InstallError) Error() string {
|
|
return fmt.Sprintf("install %s (%s): %v", err.Stage, err.Code, err.Err)
|
|
}
|
|
|
|
func (err *InstallError) Unwrap() error {
|
|
return err.Err
|
|
}
|
|
|
|
// InstallRecordStore provides the app-root and installed-app record boundary
|
|
// needed by an installation transaction.
|
|
type InstallRecordStore interface {
|
|
EnsureAppRoot(appID string) (string, error)
|
|
Write(record storage.InstalledApp) error
|
|
}
|
|
|
|
// DiskSpaceChecker reports bytes currently available on the volume that
|
|
// contains appRoot. Platform-specific implementations stay outside core.
|
|
type DiskSpaceChecker interface {
|
|
AvailableBytes(appRoot string) (int64, error)
|
|
}
|
|
|
|
// StorageFailureClassifier lets the platform identify a preserved staging I/O
|
|
// failure as insufficient storage without importing platform APIs into core.
|
|
type StorageFailureClassifier interface {
|
|
IsDiskFull(err error) bool
|
|
}
|
|
|
|
// TargetStateChecker reports whether the verified current entrypoint is still
|
|
// running. It never starts, waits for, or terminates a process.
|
|
type TargetStateChecker interface {
|
|
IsRunning(appID string, entrypointPath string) (bool, error)
|
|
}
|
|
|
|
// InstallRequest joins an untrusted completed download with the trusted
|
|
// Catalog selection that describes it.
|
|
type InstallRequest struct {
|
|
Entry catalog.Entry
|
|
Architecture catalog.Architecture
|
|
DownloadPath string
|
|
}
|
|
|
|
// InstallResult describes an installed version after the switch commits.
|
|
type InstallResult struct {
|
|
AppID string
|
|
Version string
|
|
EntrypointPath string
|
|
Recovery installer.RecoveryResult
|
|
}
|
|
|
|
// InstallServiceConfig makes all external installation dependencies explicit.
|
|
// Disk, storage-failure and target-state checks are mandatory so no caller can
|
|
// silently bypass the pre-extract safety boundary or disk-full diagnosis.
|
|
type InstallServiceConfig struct {
|
|
Extractor installer.Extractor
|
|
Records InstallRecordStore
|
|
Health installer.HealthCheck
|
|
DiskSpace DiskSpaceChecker
|
|
StorageFailures StorageFailureClassifier
|
|
TargetState TargetStateChecker
|
|
}
|
|
|
|
// InstallService implements the core-only verified package installation use
|
|
// case. The caller must supply entries produced by catalog.Client.
|
|
type InstallService struct {
|
|
extractor installer.Extractor
|
|
records InstallRecordStore
|
|
health installer.HealthCheck
|
|
diskSpace DiskSpaceChecker
|
|
storageFailures StorageFailureClassifier
|
|
targetState TargetStateChecker
|
|
}
|
|
|
|
func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
|
|
if config.Records == nil {
|
|
return nil, fmt.Errorf("%w: record store is required", ErrInstallServiceConfig)
|
|
}
|
|
if config.Health == nil {
|
|
return nil, fmt.Errorf("%w: %w", ErrInstallServiceConfig, installer.ErrHealthCheckRequired)
|
|
}
|
|
if config.DiskSpace == nil {
|
|
return nil, fmt.Errorf("%w: disk space checker is required", ErrInstallServiceConfig)
|
|
}
|
|
if config.StorageFailures == nil {
|
|
return nil, fmt.Errorf("%w: storage failure classifier is required", ErrInstallServiceConfig)
|
|
}
|
|
if config.TargetState == nil {
|
|
return nil, fmt.Errorf("%w: target state checker is required", ErrInstallServiceConfig)
|
|
}
|
|
return &InstallService{
|
|
extractor: config.Extractor,
|
|
records: config.Records,
|
|
health: config.Health,
|
|
diskSpace: config.DiskSpace,
|
|
storageFailures: config.StorageFailures,
|
|
targetState: config.TargetState,
|
|
}, nil
|
|
}
|
|
|
|
// Install verifies and extracts one completed download, then atomically
|
|
// switches staging into current. Metadata is written inside the Switcher
|
|
// health phase so a write failure follows the same rollback path as health.
|
|
func (service *InstallService) Install(request InstallRequest) (InstallResult, error) {
|
|
expectation, record, err := resolveInstallRequest(request)
|
|
if err != nil {
|
|
return InstallResult{}, service.installError(InstallStageVerify, err)
|
|
}
|
|
|
|
appRoot, err := service.records.EnsureAppRoot(record.ID)
|
|
if err != nil {
|
|
return InstallResult{}, service.installError(InstallStageRecover, err)
|
|
}
|
|
recovery, err := installer.Recover(appRoot)
|
|
if err != nil {
|
|
return InstallResult{}, service.installError(InstallStageRecover, err)
|
|
}
|
|
|
|
extracted, err := service.extractor.ExtractVerifiedFileWithCheck(
|
|
request.DownloadPath,
|
|
filepath.Join(appRoot, "staging"),
|
|
expectation,
|
|
service.preExtractCheck(appRoot, record.ID),
|
|
)
|
|
if err != nil {
|
|
return InstallResult{}, service.installError(stageForPackageError(err), err)
|
|
}
|
|
record.Files = make([]storage.InstalledFile, 0, len(extracted.PayloadFiles))
|
|
for _, file := range extracted.PayloadFiles {
|
|
record.Files = append(record.Files, storage.InstalledFile{
|
|
Path: file.Path,
|
|
Size: file.Size,
|
|
SHA256: file.SHA256,
|
|
})
|
|
}
|
|
record.Entrypoint = expectation.App.Entrypoint
|
|
record.WorkingDirectory = extracted.WorkingDir
|
|
record.MinOS = expectation.App.MinOS
|
|
record.RequiresAdmin = expectation.App.RequiresAdmin
|
|
|
|
var recordWriteErr error
|
|
switcher := installer.NewSwitcherWithPreSwitchCheck(func(currentPath string) error {
|
|
if err := service.health(currentPath); err != nil {
|
|
return err
|
|
}
|
|
if err := service.records.Write(record); err != nil {
|
|
recordWriteErr = err
|
|
return fmt.Errorf("%w: %w", ErrInstallRecordWrite, err)
|
|
}
|
|
return nil
|
|
}, service.preSwitchCheck(appRoot, record.ID, expectation.App.Entrypoint))
|
|
if err := switcher.Switch(appRoot); err != nil {
|
|
return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err)
|
|
}
|
|
return InstallResult{
|
|
AppID: record.ID,
|
|
Version: record.Version,
|
|
EntrypointPath: filepath.Join(appRoot, "current", expectation.App.Entrypoint),
|
|
Recovery: recovery,
|
|
}, nil
|
|
}
|
|
|
|
func (service *InstallService) preSwitchCheck(
|
|
appRoot string,
|
|
appID string,
|
|
entrypoint string,
|
|
) installer.PreSwitchCheck {
|
|
return func() error {
|
|
running, err := service.targetState.IsRunning(
|
|
appID,
|
|
filepath.Join(appRoot, "current", entrypoint),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %w", ErrTargetStateCheck, err)
|
|
}
|
|
if running {
|
|
return ErrTargetRunning
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func (service *InstallService) preExtractCheck(
|
|
appRoot string,
|
|
appID string,
|
|
) installer.PreExtractCheck {
|
|
return func(verified installer.VerifiedPackage) error {
|
|
required, err := requiredStagingBytes(verified.PayloadBytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
available, err := service.diskSpace.AvailableBytes(appRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %w", ErrDiskSpaceCheck, err)
|
|
}
|
|
if available < 0 {
|
|
return fmt.Errorf("%w: negative available bytes", ErrDiskSpaceCheck)
|
|
}
|
|
if available < required {
|
|
return fmt.Errorf("%w: available=%d required=%d", ErrDiskSpaceInsufficient, available, required)
|
|
}
|
|
|
|
running, err := service.targetState.IsRunning(
|
|
appID,
|
|
filepath.Join(appRoot, "current", verified.Entrypoint),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("%w: %w", ErrTargetStateCheck, err)
|
|
}
|
|
if running {
|
|
return ErrTargetRunning
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func requiredStagingBytes(payloadBytes int64) (int64, error) {
|
|
if payloadBytes < 0 || payloadBytes > math.MaxInt64-StagingDiskReserveBytes {
|
|
return 0, fmt.Errorf("%w: invalid payload size", ErrDiskSpaceCheck)
|
|
}
|
|
return payloadBytes + StagingDiskReserveBytes, nil
|
|
}
|
|
|
|
func resolveInstallRequest(request InstallRequest) (installer.PackageExpectation, storage.InstalledApp, error) {
|
|
if request.DownloadPath == "" {
|
|
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
|
"%w: completed download path is empty",
|
|
ErrInstallRequestInvalid,
|
|
)
|
|
}
|
|
if !request.Entry.Installable || request.Entry.Package == nil {
|
|
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
|
"%w: Catalog entry is not installable",
|
|
ErrInstallRequestInvalid,
|
|
)
|
|
}
|
|
if request.Architecture != catalog.Architecture386 && request.Architecture != catalog.ArchitectureAMD64 {
|
|
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
|
"%w: unsupported architecture %q",
|
|
ErrInstallRequestInvalid,
|
|
request.Architecture,
|
|
)
|
|
}
|
|
publishedPackage, exists := request.Entry.App.Packages[request.Architecture]
|
|
if !exists || publishedPackage != *request.Entry.Package {
|
|
return installer.PackageExpectation{}, storage.InstalledApp{}, fmt.Errorf(
|
|
"%w: selected package does not match app architecture",
|
|
ErrInstallRequestInvalid,
|
|
)
|
|
}
|
|
|
|
app := request.Entry.App
|
|
return installer.PackageExpectation{
|
|
Size: publishedPackage.Size,
|
|
SHA256: publishedPackage.SHA256,
|
|
App: installer.AppExpectation{
|
|
ID: app.ID,
|
|
Version: app.Version,
|
|
Channel: string(app.Channel),
|
|
MinOS: string(app.MinOS),
|
|
Architecture: string(request.Architecture),
|
|
Entrypoint: app.EntryEXE,
|
|
RequiresAdmin: app.RequiresAdmin,
|
|
},
|
|
}, storage.InstalledApp{
|
|
SchemaVersion: 1,
|
|
ID: app.ID,
|
|
Version: app.Version,
|
|
Architecture: string(request.Architecture),
|
|
Channel: string(app.Channel),
|
|
Files: []storage.InstalledFile{},
|
|
}, nil
|
|
}
|
|
|
|
func (service *InstallService) installError(stage InstallStage, err error) error {
|
|
return &InstallError{Stage: stage, Code: service.failureCodeFor(err), Err: err}
|
|
}
|
|
|
|
func (service *InstallService) failureCodeFor(err error) FailureCode {
|
|
if errors.Is(err, installer.ErrStagingOutput) && service.storageFailures.IsDiskFull(err) {
|
|
return FailureCodeDiskFull
|
|
}
|
|
return failureCodeFor(err)
|
|
}
|
|
|
|
func stageForPackageError(err error) InstallStage {
|
|
var packageErr *installer.PackageError
|
|
if errors.As(err, &packageErr) {
|
|
switch packageErr.Stage {
|
|
case installer.PackageStageManifest:
|
|
return InstallStageManifest
|
|
case installer.PackageStagePreflight:
|
|
return InstallStagePreflight
|
|
case installer.PackageStageExtract:
|
|
return InstallStageExtract
|
|
}
|
|
}
|
|
return InstallStageVerify
|
|
}
|
|
|
|
func failureCodeFor(err error) FailureCode {
|
|
switch {
|
|
case errors.Is(err, ErrDiskSpaceInsufficient):
|
|
return FailureCodeDiskFull
|
|
case errors.Is(err, ErrDiskSpaceCheck):
|
|
return FailureCodeDiskCheckFailed
|
|
case errors.Is(err, ErrTargetRunning):
|
|
return FailureCodeAppRunning
|
|
case errors.Is(err, ErrTargetStateCheck):
|
|
return FailureCodeTargetStateUnavailable
|
|
case errors.Is(err, installer.ErrPackageHashMismatch),
|
|
errors.Is(err, installer.ErrArchiveSizeMismatch):
|
|
return FailureCodeHashMismatch
|
|
case errors.Is(err, installer.ErrPathEscape),
|
|
errors.Is(err, installer.ErrEntrypointInvalid):
|
|
return FailureCodeZIPPathEscape
|
|
case errors.Is(err, installer.ErrPackageExpectationInvalid),
|
|
errors.Is(err, installer.ErrAppManifestTooLarge),
|
|
errors.Is(err, installer.ErrAppManifestInvalid),
|
|
errors.Is(err, installer.ErrAppManifestMissing),
|
|
errors.Is(err, installer.ErrPackageIdentityMismatch),
|
|
errors.Is(err, installer.ErrEntrypointMissing),
|
|
errors.Is(err, installer.ErrUnexpectedEntry),
|
|
errors.Is(err, installer.ErrUnsupportedEntry),
|
|
errors.Is(err, installer.ErrEncryptedEntry):
|
|
return FailureCodePackageInvalid
|
|
case errors.Is(err, installer.ErrInvalidArchive),
|
|
errors.Is(err, installer.ErrArchiveCorrupt),
|
|
errors.Is(err, installer.ErrArchiveTooLarge),
|
|
errors.Is(err, installer.ErrCentralDirectoryTooLarge),
|
|
errors.Is(err, installer.ErrTooManyEntries),
|
|
errors.Is(err, installer.ErrExpandedTooLarge),
|
|
errors.Is(err, installer.ErrCompressionRatio),
|
|
errors.Is(err, installer.ErrDuplicateEntry):
|
|
return FailureCodeZIPCorrupt
|
|
default:
|
|
return FailureCodeInstallFailed
|
|
}
|
|
}
|
|
|
|
func stageForSwitchError(err error, recordWriteErr error) InstallStage {
|
|
if errors.Is(err, installer.ErrRollbackFailed) {
|
|
return InstallStageRollback
|
|
}
|
|
if recordWriteErr != nil {
|
|
return InstallStageRecord
|
|
}
|
|
if errors.Is(err, installer.ErrHealthCheckFailed) {
|
|
return InstallStageHealth
|
|
}
|
|
return InstallStageSwitch
|
|
}
|