Preserve staging I/O failure causes (T-615)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -39,14 +39,14 @@ func effectiveDurability(fence durabilityFence) durabilityFence {
|
||||
|
||||
func syncFileWithFence(fence durabilityFence, file *os.File, description string) error {
|
||||
if err := effectiveDurability(fence).syncFile(file); err != nil {
|
||||
return fmt.Errorf("%w: sync %s: %v", ErrDurability, description, err)
|
||||
return fmt.Errorf("%w: sync %s: %w", ErrDurability, description, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncDirectoryWithFence(fence durabilityFence, path, description string) error {
|
||||
if err := effectiveDurability(fence).syncDirectory(path); err != nil {
|
||||
return fmt.Errorf("%w: sync %s: %v", ErrDurability, description, err)
|
||||
return fmt.Errorf("%w: sync %s: %w", ErrDurability, description, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+85
-29
@@ -33,12 +33,15 @@ var (
|
||||
ErrAppManifestMissing = errors.New("package app.json is missing")
|
||||
ErrDestinationExists = errors.New("staging destination already exists")
|
||||
ErrArchiveCorrupt = errors.New("ZIP archive data is corrupt")
|
||||
ErrStagingOutput = errors.New("staging output failed")
|
||||
ErrStagingCleanup = errors.New("staging cleanup failed")
|
||||
)
|
||||
|
||||
// Extractor writes only payload/ contents from a pre-verified package ZIP.
|
||||
type Extractor struct {
|
||||
limits Limits
|
||||
durability durabilityFence
|
||||
files stagingFileOperations
|
||||
}
|
||||
|
||||
type ExtractResult struct {
|
||||
@@ -87,7 +90,11 @@ func NewExtractor(limits Limits) (Extractor, error) {
|
||||
if err := limits.validate(); err != nil {
|
||||
return Extractor{}, err
|
||||
}
|
||||
return Extractor{limits: limits, durability: defaultDurability()}, nil
|
||||
return Extractor{
|
||||
limits: limits,
|
||||
durability: defaultDurability(),
|
||||
files: defaultStagingFileOperations(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExtractFile requires expectedPackageSize from the verified Catalog package.
|
||||
@@ -136,6 +143,7 @@ func (extractor Extractor) extractPlan(
|
||||
plan []plannedEntry,
|
||||
) (result ExtractResult, err error) {
|
||||
fence := effectiveDurability(extractor.durability)
|
||||
files := effectiveStagingFileOperations(extractor.files)
|
||||
destinationRoot, entrypointPath, err := planOutputPaths(
|
||||
destination,
|
||||
entrypoint,
|
||||
@@ -145,19 +153,21 @@ func (extractor Extractor) extractPlan(
|
||||
return ExtractResult{}, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(destinationRoot), 0o700); err != nil {
|
||||
return ExtractResult{}, fmt.Errorf("create staging parent: %w", err)
|
||||
if err := files.mkdirAll(filepath.Dir(destinationRoot), 0o700); err != nil {
|
||||
return ExtractResult{}, stagingOutputError("create staging parent", err)
|
||||
}
|
||||
if err := os.Mkdir(destinationRoot, 0o700); err != nil {
|
||||
if err := files.mkdir(destinationRoot, 0o700); err != nil {
|
||||
if os.IsExist(err) {
|
||||
return ExtractResult{}, ErrDestinationExists
|
||||
}
|
||||
return ExtractResult{}, fmt.Errorf("create staging destination: %w", err)
|
||||
return ExtractResult{}, stagingOutputError("create staging destination", err)
|
||||
}
|
||||
complete := false
|
||||
defer func() {
|
||||
if !complete {
|
||||
_ = os.RemoveAll(destinationRoot)
|
||||
if cleanupErr := files.removeAll(destinationRoot); cleanupErr != nil {
|
||||
err = errors.Join(err, stagingCleanupError(cleanupErr))
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -167,31 +177,37 @@ func (extractor Extractor) extractPlan(
|
||||
if entry.outputPath == "" {
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(entry.targetPath, 0o700); err != nil {
|
||||
return ExtractResult{}, fmt.Errorf("create staging directory: %w", err)
|
||||
if err := files.mkdirAll(entry.targetPath, 0o700); err != nil {
|
||||
return ExtractResult{}, stagingOutputError("create staging directory", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(entry.targetPath), 0o700); err != nil {
|
||||
return ExtractResult{}, fmt.Errorf("create staging file parent: %w", err)
|
||||
if err := files.mkdirAll(filepath.Dir(entry.targetPath), 0o700); err != nil {
|
||||
return ExtractResult{}, stagingOutputError("create staging file parent", err)
|
||||
}
|
||||
|
||||
source, err := entry.file.Open()
|
||||
if err != nil {
|
||||
return ExtractResult{}, fmt.Errorf("%w: open %s: %v", ErrArchiveCorrupt, entry.archivePath, err)
|
||||
return ExtractResult{}, archiveInputError("open", entry.archivePath, err)
|
||||
}
|
||||
mode := os.FileMode(0o600)
|
||||
if entry.file.Mode().Perm()&0o111 != 0 {
|
||||
mode = 0o700
|
||||
}
|
||||
output, err := os.OpenFile(
|
||||
output, err := files.openFile(
|
||||
entry.targetPath,
|
||||
os.O_CREATE|os.O_EXCL|os.O_WRONLY,
|
||||
mode,
|
||||
)
|
||||
if err != nil {
|
||||
source.Close()
|
||||
return ExtractResult{}, fmt.Errorf("create staging file: %w", err)
|
||||
outputErr := stagingOutputError("create staging file", err)
|
||||
if closeErr := source.Close(); closeErr != nil {
|
||||
return ExtractResult{}, errors.Join(
|
||||
outputErr,
|
||||
archiveInputError("close", entry.archivePath, closeErr),
|
||||
)
|
||||
}
|
||||
return ExtractResult{}, outputErr
|
||||
}
|
||||
|
||||
remaining := extractor.limits.MaxUncompressedBytes - written
|
||||
@@ -200,39 +216,45 @@ func (extractor Extractor) extractPlan(
|
||||
readLimit++
|
||||
}
|
||||
digest := sha256.New()
|
||||
writer := stagingWriter{writer: output}
|
||||
copied, copyErr := io.Copy(
|
||||
io.MultiWriter(output, digest),
|
||||
io.MultiWriter(&writer, digest),
|
||||
io.LimitReader(source, readLimit),
|
||||
)
|
||||
closeSourceErr := source.Close()
|
||||
if copyErr != nil {
|
||||
_ = output.Close()
|
||||
return ExtractResult{}, fmt.Errorf("%w: read %s: %v", ErrArchiveCorrupt, entry.archivePath, copyErr)
|
||||
primary := archiveInputError("read", entry.archivePath, copyErr)
|
||||
if writer.err != nil {
|
||||
primary = stagingOutputError("write staging file", writer.err)
|
||||
}
|
||||
return ExtractResult{}, joinStagingCloseError(primary, output)
|
||||
}
|
||||
if closeSourceErr != nil {
|
||||
_ = output.Close()
|
||||
return ExtractResult{}, fmt.Errorf("%w: close %s: %v", ErrArchiveCorrupt, entry.archivePath, closeSourceErr)
|
||||
return ExtractResult{}, joinStagingCloseError(
|
||||
archiveInputError("close", entry.archivePath, closeSourceErr),
|
||||
output,
|
||||
)
|
||||
}
|
||||
if copied > remaining {
|
||||
_ = output.Close()
|
||||
return ExtractResult{}, ErrExpandedTooLarge
|
||||
return ExtractResult{}, joinStagingCloseError(ErrExpandedTooLarge, output)
|
||||
}
|
||||
if uint64(copied) != entry.file.UncompressedSize64 {
|
||||
_ = output.Close()
|
||||
return ExtractResult{}, fmt.Errorf(
|
||||
return ExtractResult{}, joinStagingCloseError(fmt.Errorf(
|
||||
"%w: %s expanded to %d bytes, header declares %d",
|
||||
ErrArchiveCorrupt,
|
||||
entry.archivePath,
|
||||
copied,
|
||||
entry.file.UncompressedSize64,
|
||||
), output)
|
||||
}
|
||||
if err := syncFileWithFence(fence, output.osFile(), "staging payload"); err != nil {
|
||||
return ExtractResult{}, joinStagingCloseError(
|
||||
stagingOutputError("sync staging file", err),
|
||||
output,
|
||||
)
|
||||
}
|
||||
if err := syncFileWithFence(fence, output, "staging payload"); err != nil {
|
||||
_ = output.Close()
|
||||
return ExtractResult{}, err
|
||||
}
|
||||
if err := output.Close(); err != nil {
|
||||
return ExtractResult{}, fmt.Errorf("close staging file: %w", err)
|
||||
return ExtractResult{}, stagingOutputError("close staging file", err)
|
||||
}
|
||||
written += copied
|
||||
result.Files++
|
||||
@@ -243,7 +265,7 @@ func (extractor Extractor) extractPlan(
|
||||
})
|
||||
}
|
||||
if err := syncStagingTree(fence, destinationRoot); err != nil {
|
||||
return ExtractResult{}, err
|
||||
return ExtractResult{}, stagingOutputError("sync staging tree", err)
|
||||
}
|
||||
|
||||
result.Bytes = written
|
||||
@@ -252,6 +274,40 @@ func (extractor Extractor) extractPlan(
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type stagingWriter struct {
|
||||
writer io.Writer
|
||||
err error
|
||||
}
|
||||
|
||||
func (writer *stagingWriter) Write(data []byte) (int, error) {
|
||||
written, err := writer.writer.Write(data)
|
||||
if err != nil {
|
||||
writer.err = err
|
||||
} else if written != len(data) {
|
||||
writer.err = io.ErrShortWrite
|
||||
}
|
||||
return written, err
|
||||
}
|
||||
|
||||
func archiveInputError(operation, path string, cause error) error {
|
||||
return fmt.Errorf("%w: %s %s: %w", ErrArchiveCorrupt, operation, path, cause)
|
||||
}
|
||||
|
||||
func stagingOutputError(operation string, cause error) error {
|
||||
return fmt.Errorf("%w: %s: %w", ErrStagingOutput, operation, cause)
|
||||
}
|
||||
|
||||
func stagingCleanupError(cause error) error {
|
||||
return fmt.Errorf("%w: remove staging: %w", ErrStagingCleanup, cause)
|
||||
}
|
||||
|
||||
func joinStagingCloseError(primary error, output stagingOutputFile) error {
|
||||
if closeErr := output.Close(); closeErr != nil {
|
||||
return errors.Join(primary, stagingOutputError("close staging file", closeErr))
|
||||
}
|
||||
return primary
|
||||
}
|
||||
|
||||
func planOutputPaths(
|
||||
destination string,
|
||||
entrypoint string,
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractorKeepsStagingOutputFailuresDistinctFromZIPInput(t *testing.T) {
|
||||
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||
{name: "app.json", body: []byte(`{}`)},
|
||||
{name: "payload/App.exe", body: []byte("executable")},
|
||||
})
|
||||
errDiskFull := errors.New("injected disk full")
|
||||
errOutput := errors.New("injected output failure")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
failure error
|
||||
configure func(*Extractor, error)
|
||||
}{
|
||||
{
|
||||
name: "write disk full",
|
||||
failure: errDiskFull,
|
||||
configure: func(extractor *Extractor, failure error) {
|
||||
extractor.files = outputFailureOperations{
|
||||
stagingFileOperations: defaultStagingFileOperations(),
|
||||
writeErr: failure,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "sync disk full",
|
||||
failure: errDiskFull,
|
||||
configure: func(extractor *Extractor, failure error) {
|
||||
extractor.durability = fileSyncFailureFence{err: failure}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "close disk full",
|
||||
failure: errDiskFull,
|
||||
configure: func(extractor *Extractor, failure error) {
|
||||
extractor.files = outputFailureOperations{
|
||||
stagingFileOperations: defaultStagingFileOperations(),
|
||||
closeErr: failure,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "generic output I O",
|
||||
failure: errOutput,
|
||||
configure: func(extractor *Extractor, failure error) {
|
||||
extractor.files = outputFailureOperations{
|
||||
stagingFileOperations: defaultStagingFileOperations(),
|
||||
writeErr: failure,
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
extractor := mustExtractor(t, testLimits())
|
||||
test.configure(&extractor, test.failure)
|
||||
destination := filepath.Join(t.TempDir(), "staging")
|
||||
|
||||
_, err := extractor.ExtractFile(
|
||||
archivePath,
|
||||
destination,
|
||||
"App.exe",
|
||||
archiveSize(t, archivePath),
|
||||
)
|
||||
if !errors.Is(err, ErrStagingOutput) {
|
||||
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrStagingOutput)
|
||||
}
|
||||
if errors.Is(err, ErrArchiveCorrupt) {
|
||||
t.Fatalf("ExtractFile() error = %v, must not be ZIP corruption", err)
|
||||
}
|
||||
if !errors.Is(err, test.failure) {
|
||||
t.Fatalf("ExtractFile() error = %v, want preserved %v", err, test.failure)
|
||||
}
|
||||
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("output failure left staging, stat error = %v", statErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractorReportsCleanupFailureAndRecoveryRemovesOnlyManagedStaging(t *testing.T) {
|
||||
archivePath := writeTestZIP(t, []testZIPEntry{
|
||||
{name: "app.json", body: []byte(`{}`)},
|
||||
{name: "payload/App.exe", body: []byte("verified bytes"), method: zip.Store},
|
||||
})
|
||||
corruptZIPEntryData(t, archivePath, "payload/App.exe")
|
||||
|
||||
root := t.TempDir()
|
||||
currentPath := filepath.Join(root, "current", "App.exe")
|
||||
if err := os.MkdirAll(filepath.Dir(currentPath), 0o700); err != nil {
|
||||
t.Fatalf("create current: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(currentPath, []byte("old executable"), 0o600); err != nil {
|
||||
t.Fatalf("write current: %v", err)
|
||||
}
|
||||
recordPath := filepath.Join(root, "installed-app.json")
|
||||
oldRecord := []byte(`{"version":"1.0.0"}`)
|
||||
if err := os.WriteFile(recordPath, oldRecord, 0o600); err != nil {
|
||||
t.Fatalf("write record: %v", err)
|
||||
}
|
||||
for _, preserved := range []string{"data", "licenses"} {
|
||||
path := filepath.Join(root, preserved, "keep.txt")
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatalf("create %s: %v", preserved, err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(preserved), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", preserved, err)
|
||||
}
|
||||
}
|
||||
|
||||
errCleanup := errors.New("injected cleanup failure")
|
||||
extractor := mustExtractor(t, testLimits())
|
||||
extractor.files = outputFailureOperations{
|
||||
stagingFileOperations: defaultStagingFileOperations(),
|
||||
cleanupErr: errCleanup,
|
||||
}
|
||||
destination := filepath.Join(root, "staging")
|
||||
_, err := extractor.ExtractFile(
|
||||
archivePath,
|
||||
destination,
|
||||
"App.exe",
|
||||
archiveSize(t, archivePath),
|
||||
)
|
||||
if !errors.Is(err, ErrArchiveCorrupt) || !errors.Is(err, ErrStagingCleanup) || !errors.Is(err, errCleanup) {
|
||||
t.Fatalf("ExtractFile() error = %v, want ZIP and cleanup causes", err)
|
||||
}
|
||||
if _, statErr := os.Stat(destination); statErr != nil {
|
||||
t.Fatalf("cleanup failure removed staging, stat error = %v", statErr)
|
||||
}
|
||||
|
||||
result, err := Recover(root)
|
||||
if err != nil {
|
||||
t.Fatalf("Recover() error = %v", err)
|
||||
}
|
||||
if result.Action != RecoveryAborted {
|
||||
t.Fatalf("recovery action = %q, want %q", result.Action, RecoveryAborted)
|
||||
}
|
||||
if got := mustReadTestFile(t, currentPath); got != "old executable" {
|
||||
t.Fatalf("current after recovery = %q", got)
|
||||
}
|
||||
if record, readErr := os.ReadFile(recordPath); readErr != nil || string(record) != string(oldRecord) {
|
||||
t.Fatalf("record after recovery = %q error = %v", record, readErr)
|
||||
}
|
||||
for _, preserved := range []string{"data", "licenses"} {
|
||||
if got := mustReadTestFile(t, filepath.Join(root, preserved, "keep.txt")); got != preserved {
|
||||
t.Fatalf("%s after recovery = %q", preserved, got)
|
||||
}
|
||||
}
|
||||
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("Recover() left staging, stat error = %v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
type outputFailureOperations struct {
|
||||
stagingFileOperations
|
||||
writeErr error
|
||||
closeErr error
|
||||
cleanupErr error
|
||||
}
|
||||
|
||||
func (operations outputFailureOperations) openFile(
|
||||
name string,
|
||||
flag int,
|
||||
perm os.FileMode,
|
||||
) (stagingOutputFile, error) {
|
||||
file, err := operations.stagingFileOperations.openFile(name, flag, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &outputFailureFile{
|
||||
stagingOutputFile: file,
|
||||
writeErr: operations.writeErr,
|
||||
closeErr: operations.closeErr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (operations outputFailureOperations) removeAll(path string) error {
|
||||
if operations.cleanupErr != nil {
|
||||
return operations.cleanupErr
|
||||
}
|
||||
return operations.stagingFileOperations.removeAll(path)
|
||||
}
|
||||
|
||||
type outputFailureFile struct {
|
||||
stagingOutputFile
|
||||
writeErr error
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (file *outputFailureFile) Write(data []byte) (int, error) {
|
||||
if file.writeErr != nil {
|
||||
return 0, file.writeErr
|
||||
}
|
||||
return file.stagingOutputFile.Write(data)
|
||||
}
|
||||
|
||||
func (file *outputFailureFile) Close() error {
|
||||
closeErr := file.stagingOutputFile.Close()
|
||||
if file.closeErr != nil {
|
||||
return file.closeErr
|
||||
}
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (file *outputFailureFile) osFile() *os.File {
|
||||
return file.stagingOutputFile.osFile()
|
||||
}
|
||||
|
||||
type fileSyncFailureFence struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (fence fileSyncFailureFence) syncFile(*os.File) error {
|
||||
return fence.err
|
||||
}
|
||||
|
||||
func (fence fileSyncFailureFence) syncDirectory(string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustReadTestFile(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -401,6 +401,9 @@ func TestExtractorRemovesDestinationAfterCopyFailure(t *testing.T) {
|
||||
if !errors.Is(err, ErrArchiveCorrupt) {
|
||||
t.Fatalf("ExtractFile() error = %v, want %v", err, ErrArchiveCorrupt)
|
||||
}
|
||||
if errors.Is(err, ErrStagingOutput) {
|
||||
t.Fatalf("ExtractFile() error = %v, must not be a staging output failure", err)
|
||||
}
|
||||
if _, statErr := os.Stat(destination); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("copy failure left staging, stat error = %v", statErr)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,12 @@ func recoverWithFence(root string, fence durabilityFence) (RecoveryResult, error
|
||||
ErrRecoveryInconsistent,
|
||||
)
|
||||
}
|
||||
if state.staging {
|
||||
if err := removeManagedDirectoryWithFence(layout, layout.staging, fence); err != nil {
|
||||
return RecoveryResult{}, err
|
||||
}
|
||||
return RecoveryResult{Action: RecoveryAborted}, nil
|
||||
}
|
||||
return RecoveryResult{Action: RecoveryNone}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package installer
|
||||
|
||||
import "os"
|
||||
|
||||
// stagingOutputFile is the narrow output seam used by extraction. Keeping it
|
||||
// internal lets tests inject write and close failures without exposing a file
|
||||
// abstraction to package consumers.
|
||||
type stagingOutputFile interface {
|
||||
Write([]byte) (int, error)
|
||||
Close() error
|
||||
osFile() *os.File
|
||||
}
|
||||
|
||||
type stagingFileOperations interface {
|
||||
mkdirAll(path string, perm os.FileMode) error
|
||||
mkdir(path string, perm os.FileMode) error
|
||||
openFile(name string, flag int, perm os.FileMode) (stagingOutputFile, error)
|
||||
removeAll(path string) error
|
||||
}
|
||||
|
||||
type filesystemStagingFileOperations struct{}
|
||||
|
||||
type filesystemStagingOutputFile struct {
|
||||
file *os.File
|
||||
}
|
||||
|
||||
func (operations filesystemStagingFileOperations) mkdirAll(path string, perm os.FileMode) error {
|
||||
return os.MkdirAll(path, perm)
|
||||
}
|
||||
|
||||
func (operations filesystemStagingFileOperations) mkdir(path string, perm os.FileMode) error {
|
||||
return os.Mkdir(path, perm)
|
||||
}
|
||||
|
||||
func (operations filesystemStagingFileOperations) openFile(
|
||||
name string,
|
||||
flag int,
|
||||
perm os.FileMode,
|
||||
) (stagingOutputFile, error) {
|
||||
file, err := os.OpenFile(name, flag, perm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return filesystemStagingOutputFile{file: file}, nil
|
||||
}
|
||||
|
||||
func (operations filesystemStagingFileOperations) removeAll(path string) error {
|
||||
return os.RemoveAll(path)
|
||||
}
|
||||
|
||||
func (file filesystemStagingOutputFile) Write(data []byte) (int, error) {
|
||||
return file.file.Write(data)
|
||||
}
|
||||
|
||||
func (file filesystemStagingOutputFile) Close() error {
|
||||
return file.file.Close()
|
||||
}
|
||||
|
||||
func (file filesystemStagingOutputFile) osFile() *os.File {
|
||||
return file.file
|
||||
}
|
||||
|
||||
func defaultStagingFileOperations() stagingFileOperations {
|
||||
return filesystemStagingFileOperations{}
|
||||
}
|
||||
|
||||
func effectiveStagingFileOperations(operations stagingFileOperations) stagingFileOperations {
|
||||
if operations == nil {
|
||||
return defaultStagingFileOperations()
|
||||
}
|
||||
return operations
|
||||
}
|
||||
Reference in New Issue
Block a user