Add install preflight safeguards (T-303)
This commit is contained in:
@@ -56,9 +56,10 @@ func TestInstallServiceInstallsVerifiedPackageAndRecordsPayloadFiles(t *testing.
|
||||
func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T) {
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||
tests := []struct {
|
||||
name string
|
||||
entry catalog.Entry
|
||||
wantErr error
|
||||
name string
|
||||
entry catalog.Entry
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "selected package differs from architecture package",
|
||||
@@ -69,7 +70,8 @@ func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T)
|
||||
entry.Package = &forged
|
||||
return entry
|
||||
}(),
|
||||
wantErr: ErrInstallRequestInvalid,
|
||||
wantErr: ErrInstallRequestInvalid,
|
||||
wantCode: FailureCodeInstallFailed,
|
||||
},
|
||||
{
|
||||
name: "download hash differs from Catalog",
|
||||
@@ -84,7 +86,8 @@ func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T)
|
||||
*entry.Package = entry.App.Packages[catalog.ArchitectureAMD64]
|
||||
return entry
|
||||
}(),
|
||||
wantErr: installer.ErrPackageHashMismatch,
|
||||
wantErr: installer.ErrPackageHashMismatch,
|
||||
wantCode: FailureCodeHashMismatch,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -105,6 +108,9 @@ func TestInstallServiceRejectsCatalogSelectionAndHashBeforeStaging(t *testing.T)
|
||||
if stage := installErrorStage(t, err); stage != InstallStageVerify {
|
||||
t.Fatalf("stage = %q, want %q", stage, InstallStageVerify)
|
||||
}
|
||||
if code := installErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(appsRoot, "test-app", "staging")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("rejected install left staging, stat error = %v", statErr)
|
||||
}
|
||||
@@ -177,6 +183,293 @@ func TestInstallServiceRollsBackHealthAndRecordWriteFailure(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
|
||||
extractor, err := installer.NewExtractor(installTestLimits())
|
||||
if err != nil {
|
||||
t.Fatalf("NewExtractor() error = %v", err)
|
||||
}
|
||||
store := storage.NewInstalledAppStore(filepath.Join(t.TempDir(), "apps"))
|
||||
config := InstallServiceConfig{
|
||||
Extractor: extractor,
|
||||
Records: store,
|
||||
Health: func(string) error { return nil },
|
||||
DiskSpace: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return StagingDiskReserveBytes, nil
|
||||
}),
|
||||
TargetState: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
modify func(*InstallServiceConfig)
|
||||
}{
|
||||
{
|
||||
name: "disk space checker",
|
||||
modify: func(config *InstallServiceConfig) {
|
||||
config.DiskSpace = nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "target state checker",
|
||||
modify: func(config *InstallServiceConfig) {
|
||||
config.TargetState = nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
candidate := config
|
||||
test.modify(&candidate)
|
||||
if _, err := NewInstallService(candidate); !errors.Is(err, ErrInstallServiceConfig) {
|
||||
t.Fatalf("NewInstallService() error = %v, want %v", err, ErrInstallServiceConfig)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureCodeForPackageFailures(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
code FailureCode
|
||||
}{
|
||||
{err: installer.ErrPackageHashMismatch, code: FailureCodeHashMismatch},
|
||||
{err: installer.ErrPathEscape, code: FailureCodeZIPPathEscape},
|
||||
{err: installer.ErrArchiveCorrupt, code: FailureCodeZIPCorrupt},
|
||||
{err: installer.ErrAppManifestInvalid, code: FailureCodePackageInvalid},
|
||||
{err: ErrInstallRequestInvalid, code: FailureCodeInstallFailed},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := failureCodeFor(test.err); got != test.code {
|
||||
t.Fatalf("failureCodeFor(%v) = %q, want %q", test.err, got, test.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceAcceptsExactStagingCapacity(t *testing.T) {
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
|
||||
var diskRoot string
|
||||
var targetAppID, targetEntrypoint string
|
||||
service := newInstallServiceWithCheckers(
|
||||
t,
|
||||
store,
|
||||
func(string) error { return nil },
|
||||
diskSpaceCheckerFunc(func(appRoot string) (int64, error) {
|
||||
diskRoot = appRoot
|
||||
return required, nil
|
||||
}),
|
||||
targetStateCheckerFunc(func(appID, entrypointPath string) (bool, error) {
|
||||
targetAppID = appID
|
||||
targetEntrypoint = entrypointPath
|
||||
return false, nil
|
||||
}),
|
||||
)
|
||||
|
||||
if _, err := service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.2.3"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
}); err != nil {
|
||||
t.Fatalf("Install() error = %v", err)
|
||||
}
|
||||
appRoot := filepath.Join(appsRoot, "test-app")
|
||||
if diskRoot != appRoot {
|
||||
t.Fatalf("disk check root = %q, want %q", diskRoot, appRoot)
|
||||
}
|
||||
if targetAppID != "test-app" || targetEntrypoint != filepath.Join(appRoot, "current", "bin", "App.exe") {
|
||||
t.Fatalf("target check = (%q, %q)", targetAppID, targetEntrypoint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceInsufficientDiskLeavesNoCurrent(t *testing.T) {
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
service := newInstallServiceWithCheckers(
|
||||
t,
|
||||
store,
|
||||
func(string) error { return nil },
|
||||
diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return 0, nil
|
||||
}),
|
||||
targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}),
|
||||
)
|
||||
|
||||
_, err := service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.2.3"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if !errors.Is(err, ErrDiskSpaceInsufficient) {
|
||||
t.Fatalf("Install() error = %v, want %v", err, ErrDiskSpaceInsufficient)
|
||||
}
|
||||
if code := installErrorCode(t, err); code != FailureCodeDiskFull {
|
||||
t.Fatalf("code = %q, want %q", code, FailureCodeDiskFull)
|
||||
}
|
||||
appRoot := filepath.Join(appsRoot, "test-app")
|
||||
for _, managed := range []string{"staging", "current"} {
|
||||
if _, statErr := os.Stat(filepath.Join(appRoot, managed)); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("first install left %s, stat error = %v", managed, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServicePreflightFailuresPreserveExistingVersion(t *testing.T) {
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
||||
initial := newInstallService(t, store, func(string) error { return nil })
|
||||
if _, err := initial.Install(InstallRequest{
|
||||
Entry: installEntry(oldPackage, "1.0.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: oldArchive,
|
||||
}); err != nil {
|
||||
t.Fatalf("initial Install() error = %v", err)
|
||||
}
|
||||
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
||||
required := StagingDiskReserveBytes + int64(len("new executable")+len("readme"))
|
||||
diskProbeErr := errors.New("disk probe unavailable")
|
||||
targetProbeErr := errors.New("target state unavailable")
|
||||
tests := []struct {
|
||||
name string
|
||||
disk DiskSpaceChecker
|
||||
target TargetStateChecker
|
||||
wantErr error
|
||||
wantCode FailureCode
|
||||
}{
|
||||
{
|
||||
name: "insufficient disk space",
|
||||
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return required - 1, nil
|
||||
}),
|
||||
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}),
|
||||
wantErr: ErrDiskSpaceInsufficient,
|
||||
wantCode: FailureCodeDiskFull,
|
||||
},
|
||||
{
|
||||
name: "disk capacity check fails",
|
||||
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return 0, diskProbeErr
|
||||
}),
|
||||
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}),
|
||||
wantErr: diskProbeErr,
|
||||
wantCode: FailureCodeDiskCheckFailed,
|
||||
},
|
||||
{
|
||||
name: "current app is running",
|
||||
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return required, nil
|
||||
}),
|
||||
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return true, nil
|
||||
}),
|
||||
wantErr: ErrTargetRunning,
|
||||
wantCode: FailureCodeAppRunning,
|
||||
},
|
||||
{
|
||||
name: "target state check fails",
|
||||
disk: diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return required, nil
|
||||
}),
|
||||
target: targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, targetProbeErr
|
||||
}),
|
||||
wantErr: targetProbeErr,
|
||||
wantCode: FailureCodeTargetStateUnavailable,
|
||||
},
|
||||
}
|
||||
|
||||
appRoot := filepath.Join(appsRoot, "test-app")
|
||||
oldRecord := mustReadFile(t, filepath.Join(appRoot, "installed-app.json"))
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
service := newInstallServiceWithCheckers(t, store, func(string) error { return nil }, test.disk, test.target)
|
||||
_, err := service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.1.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Install() error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
if stage := installErrorStage(t, err); stage != InstallStagePreflight {
|
||||
t.Fatalf("stage = %q, want %q", stage, InstallStagePreflight)
|
||||
}
|
||||
if code := installErrorCode(t, err); code != test.wantCode {
|
||||
t.Fatalf("code = %q, want %q", code, test.wantCode)
|
||||
}
|
||||
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
|
||||
t.Fatalf("current entrypoint after failure = %q", got)
|
||||
}
|
||||
if got := mustReadFile(t, filepath.Join(appRoot, "installed-app.json")); got != oldRecord {
|
||||
t.Fatal("installed-app record changed after preflight failure")
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("preflight failure left staging, stat error = %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceReportsCorruptPackageWithoutReplacingCurrent(t *testing.T) {
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
oldArchive, oldPackage := writeInstallPackage(t, "1.0.0", "old executable")
|
||||
initial := newInstallService(t, store, func(string) error { return nil })
|
||||
if _, err := initial.Install(InstallRequest{
|
||||
Entry: installEntry(oldPackage, "1.0.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: oldArchive,
|
||||
}); err != nil {
|
||||
t.Fatalf("initial Install() error = %v", err)
|
||||
}
|
||||
|
||||
archivePath, publishedPackage := writeInstallPackage(t, "1.1.0", "new executable")
|
||||
corruptInstallPackageEntry(t, archivePath, "payload/bin/App.exe")
|
||||
document, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read corrupted archive: %v", err)
|
||||
}
|
||||
hash := sha256.Sum256(document)
|
||||
publishedPackage.Size = int64(len(document))
|
||||
publishedPackage.SHA256 = hex.EncodeToString(hash[:])
|
||||
|
||||
service := newInstallService(t, store, func(string) error { return nil })
|
||||
_, err = service.Install(InstallRequest{
|
||||
Entry: installEntry(publishedPackage, "1.1.0"),
|
||||
Architecture: catalog.ArchitectureAMD64,
|
||||
DownloadPath: archivePath,
|
||||
})
|
||||
if !errors.Is(err, installer.ErrArchiveCorrupt) {
|
||||
t.Fatalf("Install() error = %v, want %v", err, installer.ErrArchiveCorrupt)
|
||||
}
|
||||
if stage := installErrorStage(t, err); stage != InstallStageExtract {
|
||||
t.Fatalf("stage = %q, want %q", stage, InstallStageExtract)
|
||||
}
|
||||
if code := installErrorCode(t, err); code != FailureCodeZIPCorrupt {
|
||||
t.Fatalf("code = %q, want %q", code, FailureCodeZIPCorrupt)
|
||||
}
|
||||
appRoot := filepath.Join(appsRoot, "test-app")
|
||||
if got := mustReadFile(t, filepath.Join(appRoot, "current", "bin", "App.exe")); got != "old executable" {
|
||||
t.Fatalf("current entrypoint after corruption = %q", got)
|
||||
}
|
||||
if _, statErr := os.Stat(filepath.Join(appRoot, "staging")); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("corrupt package left staging, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallServiceRecoversPreparedTransactionBeforeExtracting(t *testing.T) {
|
||||
appsRoot := filepath.Join(t.TempDir(), "apps")
|
||||
store := storage.NewInstalledAppStore(appsRoot)
|
||||
@@ -235,19 +528,57 @@ func newInstallService(
|
||||
t *testing.T,
|
||||
store InstallRecordStore,
|
||||
health installer.HealthCheck,
|
||||
) *InstallService {
|
||||
return newInstallServiceWithCheckers(
|
||||
t,
|
||||
store,
|
||||
health,
|
||||
diskSpaceCheckerFunc(func(string) (int64, error) {
|
||||
return StagingDiskReserveBytes + 64*1024, nil
|
||||
}),
|
||||
targetStateCheckerFunc(func(string, string) (bool, error) {
|
||||
return false, nil
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func newInstallServiceWithCheckers(
|
||||
t *testing.T,
|
||||
store InstallRecordStore,
|
||||
health installer.HealthCheck,
|
||||
diskSpace DiskSpaceChecker,
|
||||
targetState TargetStateChecker,
|
||||
) *InstallService {
|
||||
t.Helper()
|
||||
extractor, err := installer.NewExtractor(installTestLimits())
|
||||
if err != nil {
|
||||
t.Fatalf("NewExtractor() error = %v", err)
|
||||
}
|
||||
service, err := NewInstallService(extractor, store, health)
|
||||
service, err := NewInstallService(InstallServiceConfig{
|
||||
Extractor: extractor,
|
||||
Records: store,
|
||||
Health: health,
|
||||
DiskSpace: diskSpace,
|
||||
TargetState: targetState,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewInstallService() error = %v", err)
|
||||
}
|
||||
return service
|
||||
}
|
||||
|
||||
type diskSpaceCheckerFunc func(string) (int64, error)
|
||||
|
||||
func (check diskSpaceCheckerFunc) AvailableBytes(appRoot string) (int64, error) {
|
||||
return check(appRoot)
|
||||
}
|
||||
|
||||
type targetStateCheckerFunc func(string, string) (bool, error)
|
||||
|
||||
func (check targetStateCheckerFunc) IsRunning(appID string, entrypointPath string) (bool, error) {
|
||||
return check(appID, entrypointPath)
|
||||
}
|
||||
|
||||
func installTestLimits() installer.Limits {
|
||||
return installer.Limits{
|
||||
MaxEntries: 20,
|
||||
@@ -343,6 +674,50 @@ func installErrorStage(t *testing.T, err error) InstallStage {
|
||||
return installErr.Stage
|
||||
}
|
||||
|
||||
func installErrorCode(t *testing.T, err error) FailureCode {
|
||||
t.Helper()
|
||||
var installErr *InstallError
|
||||
if !errors.As(err, &installErr) {
|
||||
t.Fatalf("error %v is not InstallError", err)
|
||||
}
|
||||
return installErr.Code
|
||||
}
|
||||
|
||||
func corruptInstallPackageEntry(t *testing.T, archivePath, entryName string) {
|
||||
t.Helper()
|
||||
reader, err := zip.OpenReader(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("open ZIP for corruption: %v", err)
|
||||
}
|
||||
var offset int64 = -1
|
||||
for _, file := range reader.File {
|
||||
if file.Name != entryName {
|
||||
continue
|
||||
}
|
||||
offset, err = file.DataOffset()
|
||||
if err != nil {
|
||||
_ = reader.Close()
|
||||
t.Fatalf("entry data offset: %v", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
if err := reader.Close(); err != nil {
|
||||
t.Fatalf("close ZIP reader: %v", err)
|
||||
}
|
||||
if offset < 0 {
|
||||
t.Fatalf("entry %s not found", entryName)
|
||||
}
|
||||
|
||||
document, err := os.ReadFile(archivePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read ZIP for corruption: %v", err)
|
||||
}
|
||||
document[offset] ^= 0xff
|
||||
if err := os.WriteFile(archivePath, document, 0o600); err != nil {
|
||||
t.Fatalf("write corrupted ZIP: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func mustReadFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
document, err := os.ReadFile(path)
|
||||
|
||||
Reference in New Issue
Block a user