Implement app update orchestration (T-402)
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
package update
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"softbox.local/core/application/install"
|
||||
"softbox.local/core/catalog"
|
||||
"softbox.local/core/storage"
|
||||
)
|
||||
|
||||
func TestServiceUpdatesAfterNaturalExit(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t, "1.0.0")
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{result: install.InstallResult{AppID: "test-app", Version: "1.1.0"}}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
result, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
||||
if err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if result != (Result{AppID: "test-app", Version: "1.1.0"}) {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
if confirmation.calls != 1 || waiter.calls != 1 || installer.calls != 1 {
|
||||
t.Fatalf("calls confirmation=%d waiter=%d installer=%d, want 1 each", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
if waiter.appID != "test-app" || waiter.entrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") || waiter.timeout != 30*time.Second {
|
||||
t.Fatalf("waiter input = %#v", waiter)
|
||||
}
|
||||
if installer.request.Entry.App.Version != "1.1.0" {
|
||||
t.Fatalf("installer request = %#v", installer.request)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceSkipsCloseFlowWhenTargetAlreadyStopped(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{result: install.InstallResult{AppID: "test-app", Version: "1.1.0"}}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
if _, err := service.Update(context.Background(), updateRequest("1.1.0")); err != nil {
|
||||
t.Fatalf("Update() error = %v", err)
|
||||
}
|
||||
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 1 {
|
||||
t.Fatalf("calls confirmation=%d waiter=%d installer=%d, want 0/0/1", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceRejectsInvalidOrUnreadyUpdatesBeforeSideEffects(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(t *testing.T) (InstalledAppResolver, Request)
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "not installed",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
return storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps")), updateRequest("1.1.0")
|
||||
},
|
||||
wantErr: ErrAppNotInstalled,
|
||||
wantCode: FailureCodeNotInstalled,
|
||||
},
|
||||
{
|
||||
name: "legacy record lacks launch metadata",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
record, found, err := store.Read("test-app")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("Read() found=%t err=%v", found, err)
|
||||
}
|
||||
record.Entrypoint = ""
|
||||
if err := store.Write(record); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
return store, updateRequest("1.1.0")
|
||||
},
|
||||
wantErr: ErrUpdateMetadata,
|
||||
wantCode: FailureCodeUpdateMetadataInvalid,
|
||||
},
|
||||
{
|
||||
name: "same version",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
return store, updateRequest("1.0.0")
|
||||
},
|
||||
wantErr: ErrUpdateNotAvailable,
|
||||
wantCode: FailureCodeUpdateNotAvailable,
|
||||
},
|
||||
{
|
||||
name: "catalog package mismatches selected architecture",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
request := updateRequest("1.1.0")
|
||||
different := *request.Install.Entry.Package
|
||||
different.Size++
|
||||
request.Install.Entry.Package = &different
|
||||
return store, request
|
||||
},
|
||||
wantErr: ErrUpdateRequest,
|
||||
wantCode: FailureCodeInstallFailed,
|
||||
},
|
||||
{
|
||||
name: "unsafe current layout",
|
||||
prepare: func(t *testing.T) (InstalledAppResolver, Request) {
|
||||
return resolverFunc(func(string) (storage.InstalledApp, string, error) {
|
||||
return storage.InstalledApp{}, "", storage.ErrStorageLayoutUnsafe
|
||||
}), updateRequest("1.1.0")
|
||||
},
|
||||
wantErr: ErrUpdateTargetUnsafe,
|
||||
wantCode: FailureCodeUpdateTargetUnsafe,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
records, request := test.prepare(t)
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{}
|
||||
service := newService(t, records, targetStateFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
_, err := service.Update(context.Background(), request)
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Update() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 0 {
|
||||
t.Fatalf("side effects confirmation=%d waiter=%d installer=%d, want zero", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceCloseFailuresDoNotInstallOrModifyUserRoots(t *testing.T) {
|
||||
errConfirmation := errors.New("confirmation unavailable")
|
||||
errWait := errors.New("snapshot unavailable")
|
||||
tests := []struct {
|
||||
name string
|
||||
context func() context.Context
|
||||
confirm recordingConfirmation
|
||||
wait recordingExitWaiter
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "close declined",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: false},
|
||||
wantErr: ErrCloseDeclined,
|
||||
wantCode: FailureCodeCloseDeclined,
|
||||
},
|
||||
{
|
||||
name: "confirmation unavailable",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{err: errConfirmation},
|
||||
wantErr: errConfirmation,
|
||||
wantCode: FailureCodeCloseConfirmationFailed,
|
||||
},
|
||||
{
|
||||
name: "wait canceled",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: true},
|
||||
wait: recordingExitWaiter{err: context.Canceled},
|
||||
wantErr: context.Canceled,
|
||||
wantCode: FailureCodeExitWaitCanceled,
|
||||
},
|
||||
{
|
||||
name: "wait timed out",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: true},
|
||||
wait: recordingExitWaiter{err: context.DeadlineExceeded},
|
||||
wantErr: context.DeadlineExceeded,
|
||||
wantCode: FailureCodeExitWaitTimedOut,
|
||||
},
|
||||
{
|
||||
name: "wait unavailable",
|
||||
context: context.Background,
|
||||
confirm: recordingConfirmation{confirmed: true},
|
||||
wait: recordingExitWaiter{err: errWait},
|
||||
wantErr: errWait,
|
||||
wantCode: FailureCodeExitWaitFailed,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
store, appRoot := seedInstalledApp(t, "1.0.0")
|
||||
data := filepath.Join(filepath.Dir(appRoot), "..", "data", "test-app", "data.txt")
|
||||
license := filepath.Join(filepath.Dir(appRoot), "..", "licenses", "license.txt")
|
||||
for _, path := range []string{data, license} {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll(%q): %v", path, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(path), 0o600); err != nil {
|
||||
t.Fatalf("WriteFile(%q): %v", path, err)
|
||||
}
|
||||
}
|
||||
installer := &recordingInstaller{}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}), &test.confirm, &test.wait, installer)
|
||||
|
||||
_, err := service.Update(test.context(), updateRequest("1.1.0"))
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Update() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if installer.calls != 0 {
|
||||
t.Fatalf("installer calls = %d, want 0", installer.calls)
|
||||
}
|
||||
for _, path := range []string{data, license} {
|
||||
contents, readErr := os.ReadFile(path)
|
||||
if readErr != nil || string(contents) != path {
|
||||
t.Fatalf("protected file %q = %q, err=%v", path, contents, readErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceTargetStateFailureDoesNotRequestCloseOrInstall(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
expected := errors.New("Toolhelp unavailable")
|
||||
confirmation := &recordingConfirmation{confirmed: true}
|
||||
waiter := &recordingExitWaiter{}
|
||||
installer := &recordingInstaller{}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return false, expected
|
||||
}), confirmation, waiter, installer)
|
||||
|
||||
_, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
||||
if !errors.Is(err, expected) {
|
||||
t.Fatalf("Update() error = %v, want %v", err, expected)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != FailureCodeTargetStateUnavailable {
|
||||
t.Fatalf("code = %q, want %q", code, FailureCodeTargetStateUnavailable)
|
||||
}
|
||||
if confirmation.calls != 0 || waiter.calls != 0 || installer.calls != 0 {
|
||||
t.Fatalf("side effects confirmation=%d waiter=%d installer=%d, want zero", confirmation.calls, waiter.calls, installer.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePreservesInstallErrorForRestartRace(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
installErr := &install.InstallError{Stage: install.InstallStagePreflight, Code: install.FailureCodeAppRunning, Err: install.ErrTargetRunning}
|
||||
installer := &recordingInstaller{err: installErr}
|
||||
service := newService(t, store, targetStateFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}), &recordingConfirmation{}, &recordingExitWaiter{}, installer)
|
||||
|
||||
_, err := service.Update(context.Background(), updateRequest("1.1.0"))
|
||||
if !errors.Is(err, install.ErrTargetRunning) {
|
||||
t.Fatalf("Update() error = %v, want preserved %v", err, install.ErrTargetRunning)
|
||||
}
|
||||
if code := updateErrorCode(t, err); code != FailureCodeInstallFailed {
|
||||
t.Fatalf("code = %q, want %q", code, FailureCodeInstallFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewServiceRequiresAllDependenciesAndBoundedTimeout(t *testing.T) {
|
||||
store, _ := seedInstalledApp(t, "1.0.0")
|
||||
config := ServiceConfig{
|
||||
Records: store,
|
||||
TargetState: targetStateFunc(func(string, string) (bool, error) { return false, nil }),
|
||||
Confirmation: &recordingConfirmation{},
|
||||
ExitWaiter: &recordingExitWaiter{},
|
||||
Installer: &recordingInstaller{},
|
||||
CloseTimeout: 30 * time.Second,
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mutate func(*ServiceConfig)
|
||||
}{
|
||||
{"records", func(config *ServiceConfig) { config.Records = nil }},
|
||||
{"target state", func(config *ServiceConfig) { config.TargetState = nil }},
|
||||
{"confirmation", func(config *ServiceConfig) { config.Confirmation = nil }},
|
||||
{"exit waiter", func(config *ServiceConfig) { config.ExitWaiter = nil }},
|
||||
{"installer", func(config *ServiceConfig) { config.Installer = nil }},
|
||||
{"timeout too short", func(config *ServiceConfig) { config.CloseTimeout = time.Millisecond }},
|
||||
{"timeout too long", func(config *ServiceConfig) { config.CloseTimeout = 11 * time.Minute }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := config
|
||||
test.mutate(&candidate)
|
||||
if _, err := NewService(candidate); !errors.Is(err, ErrUpdateConfig) {
|
||||
t.Fatalf("NewService() error = %v, want %v", err, ErrUpdateConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func seedInstalledApp(t *testing.T, version string) (*storage.InstalledAppStore, string) {
|
||||
t.Helper()
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
record := storage.InstalledApp{
|
||||
SchemaVersion: 1,
|
||||
ID: "test-app",
|
||||
Version: version,
|
||||
Architecture: "amd64",
|
||||
Channel: "stable",
|
||||
Entrypoint: "bin/App.exe",
|
||||
WorkingDirectory: "bin",
|
||||
MinOS: "windows-10",
|
||||
Files: []storage.InstalledFile{{
|
||||
Path: "bin/App.exe",
|
||||
Size: 1,
|
||||
SHA256: "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
}},
|
||||
}
|
||||
if err := store.Write(record); err != nil {
|
||||
t.Fatalf("Write() error = %v", err)
|
||||
}
|
||||
appRoot := filepath.Join(appsRoot, record.ID)
|
||||
entrypoint := filepath.Join(appRoot, "current", "bin", "App.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(entrypoint), 0o700); err != nil {
|
||||
t.Fatalf("MkdirAll() error = %v", err)
|
||||
}
|
||||
if err := os.WriteFile(entrypoint, []byte("old executable"), 0o700); err != nil {
|
||||
t.Fatalf("WriteFile() error = %v", err)
|
||||
}
|
||||
return store, appRoot
|
||||
}
|
||||
|
||||
func updateRequest(version string) Request {
|
||||
pkg := catalog.Package{URL: "https://download.invalid/test-app.zip", Size: 1, SHA256: "0000000000000000000000000000000000000000000000000000000000000000", Signature: "placeholder"}
|
||||
return Request{Install: install.InstallRequest{
|
||||
Entry: catalog.Entry{
|
||||
Installable: true,
|
||||
Package: &pkg,
|
||||
App: catalog.App{
|
||||
ID: "test-app",
|
||||
Version: version,
|
||||
Packages: map[catalog.Architecture]catalog.Package{
|
||||
catalog.ArchitectureAMD64: pkg,
|
||||
},
|
||||
},
|
||||
},
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: "untrusted-candidate.download",
|
||||
}}
|
||||
}
|
||||
|
||||
func newService(t *testing.T, records InstalledAppResolver, target TargetStateChecker, confirmation CloseConfirmer, waiter ExitWaiter, installer InstallRunner) *Service {
|
||||
t.Helper()
|
||||
service, err := NewService(ServiceConfig{
|
||||
Records: records,
|
||||
TargetState: target,
|
||||
Confirmation: confirmation,
|
||||
ExitWaiter: waiter,
|
||||
Installer: installer,
|
||||
CloseTimeout: 30 * time.Second,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
func updateErrorCode(t *testing.T, err error) FailureCode {
|
||||
t.Helper()
|
||||
var updateErr *Error
|
||||
if !errors.As(err, &updateErr) {
|
||||
t.Fatalf("error = %v, want update Error", err)
|
||||
}
|
||||
return updateErr.Code
|
||||
}
|
||||
|
||||
type resolverFunc func(string) (storage.InstalledApp, string, error)
|
||||
|
||||
func (resolver resolverFunc) ResolveCurrent(appID string) (storage.InstalledApp, string, error) {
|
||||
return resolver(appID)
|
||||
}
|
||||
|
||||
type targetStateFunc func(string, string) (bool, error)
|
||||
|
||||
func (checker targetStateFunc) IsRunning(appID, entrypoint string) (bool, error) {
|
||||
return checker(appID, entrypoint)
|
||||
}
|
||||
|
||||
type recordingConfirmation struct {
|
||||
confirmed bool
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (confirmation *recordingConfirmation) ConfirmClose(context.Context, string) (bool, error) {
|
||||
confirmation.calls++
|
||||
return confirmation.confirmed, confirmation.err
|
||||
}
|
||||
|
||||
type recordingExitWaiter struct {
|
||||
err error
|
||||
calls int
|
||||
appID string
|
||||
entrypoint string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (waiter *recordingExitWaiter) WaitForExit(_ context.Context, appID, entrypoint string, timeout time.Duration) error {
|
||||
waiter.calls++
|
||||
waiter.appID = appID
|
||||
waiter.entrypoint = entrypoint
|
||||
waiter.timeout = timeout
|
||||
return waiter.err
|
||||
}
|
||||
|
||||
type recordingInstaller struct {
|
||||
result install.InstallResult
|
||||
err error
|
||||
calls int
|
||||
request install.InstallRequest
|
||||
}
|
||||
|
||||
func (installer *recordingInstaller) Install(request install.InstallRequest) (install.InstallResult, error) {
|
||||
installer.calls++
|
||||
installer.request = request
|
||||
return installer.result, installer.err
|
||||
}
|
||||
Reference in New Issue
Block a user