Preserve staging I/O failure causes (T-615)

This commit is contained in:
ila
2026-07-19 20:28:56 +08:00
parent d1603c52b6
commit df6c243b21
14 changed files with 551 additions and 76 deletions
+43 -24
View File
@@ -83,6 +83,12 @@ 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 {
@@ -106,24 +112,26 @@ type InstallResult struct {
}
// 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.
// 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
TargetState TargetStateChecker
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
targetState TargetStateChecker
extractor installer.Extractor
records InstallRecordStore
health installer.HealthCheck
diskSpace DiskSpaceChecker
storageFailures StorageFailureClassifier
targetState TargetStateChecker
}
func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
@@ -136,15 +144,19 @@ func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
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,
targetState: config.TargetState,
extractor: config.Extractor,
records: config.Records,
health: config.Health,
diskSpace: config.DiskSpace,
storageFailures: config.StorageFailures,
targetState: config.TargetState,
}, nil
}
@@ -154,16 +166,16 @@ func NewInstallService(config InstallServiceConfig) (*InstallService, error) {
func (service *InstallService) Install(request InstallRequest) (InstallResult, error) {
expectation, record, err := resolveInstallRequest(request)
if err != nil {
return InstallResult{}, installError(InstallStageVerify, err)
return InstallResult{}, service.installError(InstallStageVerify, err)
}
appRoot, err := service.records.EnsureAppRoot(record.ID)
if err != nil {
return InstallResult{}, installError(InstallStageRecover, err)
return InstallResult{}, service.installError(InstallStageRecover, err)
}
recovery, err := installer.Recover(appRoot)
if err != nil {
return InstallResult{}, installError(InstallStageRecover, err)
return InstallResult{}, service.installError(InstallStageRecover, err)
}
extracted, err := service.extractor.ExtractVerifiedFileWithCheck(
@@ -173,7 +185,7 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
service.preExtractCheck(appRoot, record.ID),
)
if err != nil {
return InstallResult{}, installError(stageForPackageError(err), err)
return InstallResult{}, service.installError(stageForPackageError(err), err)
}
record.Files = make([]storage.InstalledFile, 0, len(extracted.PayloadFiles))
for _, file := range extracted.PayloadFiles {
@@ -196,7 +208,7 @@ func (service *InstallService) Install(request InstallRequest) (InstallResult, e
return nil
})
if err := switcher.Switch(appRoot); err != nil {
return InstallResult{}, installError(stageForSwitchError(err, recordWriteErr), err)
return InstallResult{}, service.installError(stageForSwitchError(err, recordWriteErr), err)
}
return InstallResult{
AppID: record.ID,
@@ -298,8 +310,15 @@ func resolveInstallRequest(request InstallRequest) (installer.PackageExpectation
}, nil
}
func installError(stage InstallStage, err error) error {
return &InstallError{Stage: stage, Code: failureCodeFor(err), Err: err}
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 {
+79 -4
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
@@ -196,6 +197,9 @@ func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
DiskSpace: diskSpaceCheckerFunc(func(string) (int64, error) {
return StagingDiskReserveBytes, nil
}),
StorageFailures: storageFailureClassifierFunc(func(error) bool {
return false
}),
TargetState: targetStateCheckerFunc(func(string, string) (bool, error) {
return false, nil
}),
@@ -217,6 +221,12 @@ func TestNewInstallServiceRequiresPreflightCheckers(t *testing.T) {
config.TargetState = nil
},
},
{
name: "storage failure classifier",
modify: func(config *InstallServiceConfig) {
config.StorageFailures = nil
},
},
}
for _, test := range tests {
@@ -248,6 +258,62 @@ func TestFailureCodeForPackageFailures(t *testing.T) {
}
}
func TestInstallServiceClassifiesStagingOutputFailures(t *testing.T) {
errDiskFull := errors.New("injected disk full")
errOutput := errors.New("injected output failure")
service := &InstallService{
storageFailures: storageFailureClassifierFunc(func(err error) bool {
return errors.Is(err, errDiskFull)
}),
}
for _, test := range []struct {
name string
err error
code FailureCode
}{
{
name: "write disk full",
err: fmt.Errorf("%w: write staging file: %w", installer.ErrStagingOutput, errDiskFull),
code: FailureCodeDiskFull,
},
{
name: "sync disk full",
err: fmt.Errorf("%w: sync staging file: %w", installer.ErrStagingOutput, errDiskFull),
code: FailureCodeDiskFull,
},
{
name: "close disk full",
err: fmt.Errorf("%w: close staging file: %w", installer.ErrStagingOutput, errDiskFull),
code: FailureCodeDiskFull,
},
{
name: "generic output I O",
err: fmt.Errorf("%w: write staging file: %w", installer.ErrStagingOutput, errOutput),
code: FailureCodeInstallFailed,
},
{
name: "ZIP input remains corrupt",
err: fmt.Errorf("%w: %w", installer.ErrArchiveCorrupt, errDiskFull),
code: FailureCodeZIPCorrupt,
},
} {
t.Run(test.name, func(t *testing.T) {
wrapped := service.installError(InstallStageExtract, test.err)
var installErr *InstallError
if !errors.As(wrapped, &installErr) {
t.Fatalf("install error = %v, want InstallError", wrapped)
}
if installErr.Code != test.code {
t.Fatalf("code = %q, want %q", installErr.Code, test.code)
}
if !errors.Is(wrapped, test.err) {
t.Fatalf("install error = %v, want preserved cause", wrapped)
}
})
}
}
func TestInstallServiceAcceptsExactStagingCapacity(t *testing.T) {
archivePath, publishedPackage := writeInstallPackage(t, "1.2.3", "new executable")
appsRoot := filepath.Join(t.TempDir(), "apps")
@@ -555,10 +621,13 @@ func newInstallServiceWithCheckers(
t.Fatalf("NewExtractor() error = %v", err)
}
service, err := NewInstallService(InstallServiceConfig{
Extractor: extractor,
Records: store,
Health: health,
DiskSpace: diskSpace,
Extractor: extractor,
Records: store,
Health: health,
DiskSpace: diskSpace,
StorageFailures: storageFailureClassifierFunc(func(error) bool {
return false
}),
TargetState: targetState,
})
if err != nil {
@@ -573,6 +642,12 @@ func (check diskSpaceCheckerFunc) AvailableBytes(appRoot string) (int64, error)
return check(appRoot)
}
type storageFailureClassifierFunc func(error) bool
func (classifier storageFailureClassifierFunc) IsDiskFull(err error) bool {
return classifier(err)
}
type targetStateCheckerFunc func(string, string) (bool, error)
func (check targetStateCheckerFunc) IsRunning(appID string, entrypointPath string) (bool, error) {