fix(cli): harden backup publication and quiescing
This commit is contained in:
@@ -176,7 +176,7 @@ func TestCreateCleansIncompleteArchiveAfterAtomicPublishFailure(t *testing.T) {
|
||||
runner := newBackupRunner(fixture.installation, false)
|
||||
directory := t.TempDir()
|
||||
dependencies := testDependencies(t, runner)
|
||||
dependencies.rename = func(string, string) error { return errors.New("publish failed") }
|
||||
dependencies.publishReserved = func(*archiveReservation, string) error { return errors.New("publish failed") }
|
||||
|
||||
if _, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: filepath.Join(directory, "failed.zip")}, dependencies); err == nil {
|
||||
t.Fatal("Create() succeeded after publish failure")
|
||||
@@ -184,6 +184,39 @@ func TestCreateCleansIncompleteArchiveAfterAtomicPublishFailure(t *testing.T) {
|
||||
assertNoBackupArtifacts(t, directory)
|
||||
}
|
||||
|
||||
func TestCreateDoesNotOverwriteOrDeleteAnOutputCreatedBeforeReservation(t *testing.T) {
|
||||
fixture := newBackupFixture(t, "local")
|
||||
runner := newBackupRunner(fixture.installation, false)
|
||||
directory := t.TempDir()
|
||||
output := filepath.Join(directory, "race.zip")
|
||||
dependencies := testDependencies(t, runner)
|
||||
reserve := dependencies.reserveOutput
|
||||
dependencies.reserveOutput = func(path string) (*archiveReservation, error) {
|
||||
if err := os.WriteFile(path, []byte("created-by-another-process"), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return reserve(path)
|
||||
}
|
||||
|
||||
if _, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: output}, dependencies); err == nil {
|
||||
t.Fatal("Create() succeeded after another process claimed the output path")
|
||||
}
|
||||
contents, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatalf("racing output was removed: %v", err)
|
||||
}
|
||||
if got := string(contents); got != "created-by-another-process" {
|
||||
t.Fatalf("racing output = %q, want unchanged content", got)
|
||||
}
|
||||
entries, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 1 || entries[0].Name() != "race.zip" {
|
||||
t.Fatalf("temporary backup artifacts remain after race: %v", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExcludesExternalSecretPayloadsByDefaultButRecordsDigests(t *testing.T) {
|
||||
fixture := newBackupFixture(t, "local")
|
||||
runner := newBackupRunner(fixture.installation, false)
|
||||
@@ -341,6 +374,26 @@ func TestCreateHonorsTheSharedInstallationLifecycleLock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefusesMutableNonRunningServiceStates(t *testing.T) {
|
||||
for _, state := range []string{"paused", "restarting", "created", "removing"} {
|
||||
t.Run(state, func(t *testing.T) {
|
||||
fixture := newBackupFixture(t, "local")
|
||||
runner := newBackupRunner(fixture.installation, false)
|
||||
runner.serviceStates = map[string]string{"core": "running", "frontend": state}
|
||||
dependencies := testDependencies(t, runner)
|
||||
dependencies.sleep = func(time.Duration) { t.Fatal("unsafe service state reached the drain loop") }
|
||||
|
||||
_, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: filepath.Join(t.TempDir(), "unsafe.zip")}, dependencies)
|
||||
if err == nil || !strings.Contains(err.Error(), "not safely quiesced") {
|
||||
t.Fatalf("Create() error = %v, want unsafe service-state refusal", err)
|
||||
}
|
||||
if runner.stopCount != 0 || runner.streams != 0 {
|
||||
t.Fatalf("unsafe state was not refused before snapshot: stops=%d streams=%d", runner.stopCount, runner.streams)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type backupFixture struct {
|
||||
root string
|
||||
installationID string
|
||||
@@ -437,6 +490,7 @@ type fakeBackupRunner struct {
|
||||
stopCount int
|
||||
startCount int
|
||||
healthChecks int
|
||||
serviceStates map[string]string
|
||||
}
|
||||
|
||||
func newBackupRunner(installation config.Installation, running bool) *fakeBackupRunner {
|
||||
@@ -474,16 +528,25 @@ func (runner *fakeBackupRunner) Run(_ context.Context, args []string, _ io.Reade
|
||||
return compose.Result{Stdout: string(encoded)}, nil
|
||||
case strings.Contains(command, " images --format json"):
|
||||
return compose.Result{Stdout: "{\"Service\":\"core\",\"Repository\":\"thothii-core\",\"Tag\":\"test\",\"ID\":\"sha256:core\"}\n{\"Service\":\"frontend\",\"Repository\":\"thothii-frontend\",\"Tag\":\"test\",\"ID\":\"sha256:frontend\"}\n"}, nil
|
||||
case strings.Contains(command, " ps --status running -q core"):
|
||||
if runner.coreRunning {
|
||||
return compose.Result{Stdout: "core-id\n"}, nil
|
||||
case strings.Contains(command, " ps --all --format json"):
|
||||
runner.healthChecks++
|
||||
states := runner.serviceStates
|
||||
if states == nil {
|
||||
if runner.running {
|
||||
return compose.Result{Stdout: healthyServicesPayload()}, nil
|
||||
}
|
||||
return compose.Result{}, nil
|
||||
}
|
||||
return compose.Result{}, nil
|
||||
case strings.Contains(command, " ps --status running -q"):
|
||||
if runner.running {
|
||||
return compose.Result{Stdout: "container-id\n"}, nil
|
||||
services := make([]string, 0, len(states))
|
||||
for service := range states {
|
||||
services = append(services, service)
|
||||
}
|
||||
return compose.Result{}, nil
|
||||
sort.Strings(services)
|
||||
lines := make([]string, 0, len(services))
|
||||
for _, service := range services {
|
||||
lines = append(lines, fmt.Sprintf(`{"Service":%q,"State":%q}`, service, states[service]))
|
||||
}
|
||||
return compose.Result{Stdout: strings.Join(lines, "\n")}, nil
|
||||
case strings.Contains(command, "/internal/maintenance/status"):
|
||||
return compose.Result{Stdout: fmt.Sprintf(`{"active":%t,"admissions":0,"recoveryRequired":false}`, runner.maintenance)}, nil
|
||||
case strings.Contains(command, "/internal/maintenance/activate"):
|
||||
@@ -553,12 +616,13 @@ func (runner *fakeBackupRunner) SessionInventoryScope() string {
|
||||
func testDependencies(t *testing.T, runner archiveRunner) dependencies {
|
||||
t.Helper()
|
||||
return dependencies{
|
||||
runner: runner,
|
||||
now: func() time.Time { return time.Date(2026, 8, 16, 8, 11, 12, 0, time.UTC) },
|
||||
homeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
revision: func(context.Context, string) (string, error) { return testRevision, nil },
|
||||
sleep: func(time.Duration) {},
|
||||
rename: os.Rename,
|
||||
runner: runner,
|
||||
now: func() time.Time { return time.Date(2026, 8, 16, 8, 11, 12, 0, time.UTC) },
|
||||
homeDir: func() (string, error) { return t.TempDir(), nil },
|
||||
revision: func(context.Context, string) (string, error) { return testRevision, nil },
|
||||
sleep: func(time.Duration) {},
|
||||
reserveOutput: reserveArchiveOutput,
|
||||
publishReserved: publishReservedArchive,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user