Report comparison cleanup recovery state

This commit is contained in:
2026-08-13 03:18:55 +00:00
parent 302f5aba2d
commit 79cba800ee
11 changed files with 228 additions and 51 deletions

View File

@@ -445,22 +445,44 @@ func unrecognizedBundleError(action string, err error) error {
return fmt.Errorf("%w: %s: %v", ErrUnrecognizedBundle, action, err)
}
// BackupRecoveryState describes what remains of a prior bundle after cleanup
// reports an error.
type BackupRecoveryState string
const (
BackupRecoveryComplete BackupRecoveryState = "complete"
BackupRecoveryPartial BackupRecoveryState = "partial"
BackupRecoveryAbsent BackupRecoveryState = "absent"
BackupRecoveryUnknown BackupRecoveryState = "unknown"
)
// PublicationResult describes the durable state of a publication attempt.
type PublicationResult struct {
Committed bool
RetainedBackupPath string
Committed bool
RecoveryState BackupRecoveryState
RecoveryPath string
}
// PublicationCleanupError reports that a committed bundle could not remove its
// prior sibling backup. The new bundle remains installed and the backup path
// is retained for operator recovery.
// PublicationCleanupError reports that a committed bundle could not completely
// remove its prior sibling backup. The new bundle remains installed; recovery
// fields describe the state observed after cleanup failed.
type PublicationCleanupError struct {
RetainedBackupPath string
Err error
RecoveryState BackupRecoveryState
RecoveryPath string
Err error
}
func (err *PublicationCleanupError) Error() string {
return fmt.Sprintf("remove comparison backup %q: %v", err.RetainedBackupPath, err.Err)
switch err.RecoveryState {
case BackupRecoveryComplete:
return fmt.Sprintf("remove comparison backup %q: %v; complete recovery bundle remains", err.RecoveryPath, err.Err)
case BackupRecoveryPartial:
return fmt.Sprintf("remove comparison backup %q: %v; partial remnants remain", err.RecoveryPath, err.Err)
case BackupRecoveryAbsent:
return fmt.Sprintf("remove comparison backup: %v; no recovery bundle remains", err.Err)
default:
return fmt.Sprintf("remove comparison backup: %v; recovery state is unknown", err.Err)
}
}
func (err *PublicationCleanupError) Unwrap() error {
@@ -558,12 +580,26 @@ func publish(ctx context.Context, plan DestinationPlan, bundle LogicalBundle, op
}
temporaryDirectory = ""
if err := operations.removeAll(backupDirectory); err != nil {
cleanupErr := &PublicationCleanupError{RetainedBackupPath: backupDirectory, Err: err}
return PublicationResult{Committed: true, RetainedBackupPath: backupDirectory}, cleanupErr
recoveryState, recoveryPath := inspectBackupRecovery(backupDirectory)
cleanupErr := &PublicationCleanupError{RecoveryState: recoveryState, RecoveryPath: recoveryPath, Err: err}
return PublicationResult{Committed: true, RecoveryState: recoveryState, RecoveryPath: recoveryPath}, cleanupErr
}
return PublicationResult{Committed: true}, nil
}
func inspectBackupRecovery(backupDirectory string) (BackupRecoveryState, string) {
if _, err := os.Lstat(backupDirectory); err != nil {
if errors.Is(err, os.ErrNotExist) {
return BackupRecoveryAbsent, ""
}
return BackupRecoveryUnknown, ""
}
if _, err := RecognizeBundle(backupDirectory); err == nil {
return BackupRecoveryComplete, backupDirectory
}
return BackupRecoveryPartial, backupDirectory
}
func authorizeMovedDestination(plan DestinationPlan, backupDirectory string) error {
backupPlan, err := PlanDestination(plan.WorkingDirectory, backupDirectory, plan.Replace)
if err != nil {

View File

@@ -577,7 +577,7 @@ func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
},
})
var cleanupErr *PublicationCleanupError
if !result.Committed || result.RetainedBackupPath != backupPath || !filepath.IsAbs(backupPath) || !errors.As(err, &cleanupErr) || cleanupErr.RetainedBackupPath != backupPath || !errors.Is(err, cleanupCause) {
if !result.Committed || result.RecoveryState != BackupRecoveryComplete || result.RecoveryPath != backupPath || !filepath.IsAbs(backupPath) || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryComplete || cleanupErr.RecoveryPath != backupPath || !errors.Is(err, cleanupCause) {
t.Fatalf("publish() result/error = %#v/%v", result, err)
}
if _, err := RecognizeBundle(target); err != nil {
@@ -592,6 +592,82 @@ func TestPublishReportsCommittedBundleWhenBackupCleanupFails(t *testing.T) {
}
}
func TestPublishReportsPartialRecoveryAfterBackupCleanupFailure(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
initialPlan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
t.Fatal(err)
}
plan, err := PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatal(err)
}
cleanupCause := errors.New("backup removal failed")
var backupPath string
result, err := publish(context.Background(), plan, testBundle(), publishOperations{
rename: os.Rename,
removeAll: func(path string) error {
backupPath = path
if err := os.Remove(filepath.Join(path, ManifestFilename)); err != nil {
t.Fatal(err)
}
return cleanupCause
},
})
var cleanupErr *PublicationCleanupError
if !result.Committed || result.RecoveryState != BackupRecoveryPartial || result.RecoveryPath != backupPath || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryPartial || cleanupErr.RecoveryPath != backupPath || !errors.Is(err, cleanupCause) {
t.Fatalf("publish() result/error = %#v/%v", result, err)
}
if _, err := RecognizeBundle(target); err != nil {
t.Fatalf("new bundle recognition error = %v", err)
}
if _, err := RecognizeBundle(backupPath); err == nil {
t.Fatal("partially removed backup was recognized")
}
}
func TestPublishReportsAbsentRecoveryAfterBackupCleanupFailure(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")
initialPlan, err := PlanDestination(workingDirectory, target, false)
if err != nil {
t.Fatal(err)
}
if _, err := Publish(context.Background(), initialPlan, testBundle()); err != nil {
t.Fatal(err)
}
plan, err := PlanDestination(workingDirectory, target, true)
if err != nil {
t.Fatal(err)
}
cleanupCause := errors.New("backup removal failed")
var backupPath string
result, err := publish(context.Background(), plan, testBundle(), publishOperations{
rename: os.Rename,
removeAll: func(path string) error {
backupPath = path
if err := os.RemoveAll(path); err != nil {
t.Fatal(err)
}
return cleanupCause
},
})
var cleanupErr *PublicationCleanupError
if !result.Committed || result.RecoveryState != BackupRecoveryAbsent || result.RecoveryPath != "" || !errors.As(err, &cleanupErr) || cleanupErr.RecoveryState != BackupRecoveryAbsent || cleanupErr.RecoveryPath != "" || !errors.Is(err, cleanupCause) {
t.Fatalf("publish() result/error = %#v/%v", result, err)
}
if _, err := RecognizeBundle(target); err != nil {
t.Fatalf("new bundle recognition error = %v", err)
}
if _, err := os.Lstat(backupPath); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("backup stat error = %v, want not exist", err)
}
}
func TestPublishRestoresExistingBundleAfterReplacementFailure(t *testing.T) {
workingDirectory := t.TempDir()
target := filepath.Join(workingDirectory, "comparison-daily")