Files
soft_quay/core/application/install/service.go
T

372 lines
12 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)
}
// 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 and target-state checks are mandatory so no caller can silently bypass
// the pre-extract safety boundary.
type InstallServiceConfig struct {
Extractor installer.Extractor
Records InstallRecordStore
Health installer.HealthCheck
DiskSpace DiskSpaceChecker
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
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.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,
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{}, installError(InstallStageVerify, err)
}
appRoot, err := service.records.EnsureAppRoot(record.ID)
if err != nil {
return InstallResult{}, installError(InstallStageRecover, err)
}
recovery, err := installer.Recover(appRoot)
if err != nil {
return InstallResult{}, 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{}, 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,
})
}
var recordWriteErr error
switcher := installer.NewSwitcher(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
})
if err := switcher.Switch(appRoot); err != nil {
return InstallResult{}, 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) 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 installError(stage InstallStage, err error) error {
return &InstallError{Stage: stage, Code: failureCodeFor(err), Err: 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
}