Integrate verified installation flow (T-302)
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"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")
|
||||
)
|
||||
|
||||
// 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"
|
||||
InstallStageExtract InstallStage = "extract"
|
||||
InstallStageRecover InstallStage = "recover"
|
||||
InstallStageSwitch InstallStage = "switch"
|
||||
InstallStageHealth InstallStage = "health"
|
||||
InstallStageRecord InstallStage = "record"
|
||||
InstallStageRollback InstallStage = "rollback"
|
||||
)
|
||||
|
||||
// InstallError preserves a stable stage and its underlying cause.
|
||||
type InstallError struct {
|
||||
Stage InstallStage
|
||||
Err error
|
||||
}
|
||||
|
||||
func (err *InstallError) Error() string {
|
||||
return fmt.Sprintf("install %s: %v", err.Stage, 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
func NewInstallService(
|
||||
extractor installer.Extractor,
|
||||
records InstallRecordStore,
|
||||
health installer.HealthCheck,
|
||||
) (*InstallService, error) {
|
||||
if records == nil {
|
||||
return nil, fmt.Errorf("%w: record store is required", ErrInstallServiceConfig)
|
||||
}
|
||||
if health == nil {
|
||||
return nil, fmt.Errorf("%w: %w", ErrInstallServiceConfig, installer.ErrHealthCheckRequired)
|
||||
}
|
||||
return &InstallService{
|
||||
extractor: extractor,
|
||||
records: records,
|
||||
health: health,
|
||||
}, 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.ExtractVerifiedFile(
|
||||
request.DownloadPath,
|
||||
filepath.Join(appRoot, "staging"),
|
||||
expectation,
|
||||
)
|
||||
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", request.Entry.App.EntryEXE),
|
||||
Recovery: recovery,
|
||||
}, 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, 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.PackageStageExtract:
|
||||
return InstallStageExtract
|
||||
}
|
||||
}
|
||||
return InstallStageVerify
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user