fix(cli): harden backup publication and quiescing

This commit is contained in:
2026-08-16 01:11:59 +02:00
parent 11fbf0a138
commit e4d0097639
4 changed files with 256 additions and 43 deletions
+172 -26
View File
@@ -70,12 +70,13 @@ type archiveRunner interface {
} }
type dependencies struct { type dependencies struct {
runner archiveRunner runner archiveRunner
now func() time.Time now func() time.Time
homeDir func() (string, error) homeDir func() (string, error)
revision func(context.Context, string) (string, error) revision func(context.Context, string) (string, error)
sleep func(time.Duration) sleep func(time.Duration)
rename func(string, string) error reserveOutput func(string) (*archiveReservation, error)
publishReserved func(*archiveReservation, string) error
} }
// Create creates an archive with the real Docker command boundary. It performs no shell // Create creates an archive with the real Docker command boundary. It performs no shell
@@ -94,13 +95,14 @@ func Create(ctx context.Context, installation config.Installation, request Creat
} }
return strings.TrimSpace(string(value)), nil return strings.TrimSpace(string(value)), nil
}, },
sleep: time.Sleep, sleep: time.Sleep,
rename: os.Rename, reserveOutput: reserveArchiveOutput,
publishReserved: publishReservedArchive,
}) })
} }
func createWithDependencies(ctx context.Context, installation config.Installation, request CreateRequest, dependencies dependencies) (result Result, resultErr error) { func createWithDependencies(ctx context.Context, installation config.Installation, request CreateRequest, dependencies dependencies) (result Result, resultErr error) {
if dependencies.runner == nil || dependencies.now == nil || dependencies.homeDir == nil || dependencies.revision == nil || dependencies.sleep == nil || dependencies.rename == nil { if dependencies.runner == nil || dependencies.now == nil || dependencies.homeDir == nil || dependencies.revision == nil || dependencies.sleep == nil || dependencies.reserveOutput == nil || dependencies.publishReserved == nil {
return Result{}, errors.New("backup dependencies are incomplete") return Result{}, errors.New("backup dependencies are incomplete")
} }
if request.IncludeSecrets && !request.Confirm { if request.IncludeSecrets && !request.Confirm {
@@ -142,6 +144,16 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
if err := ensureNewArchivePath(output); err != nil { if err := ensureNewArchivePath(output); err != nil {
return Result{}, err return Result{}, err
} }
reservation, err := dependencies.reserveOutput(output)
if err != nil {
return Result{}, err
}
published := false
defer func() {
if !published {
_ = reservation.RemoveIfOwned()
}
}()
rendered, err := renderedConfiguration(ctx, installation, dependencies.runner) rendered, err := renderedConfiguration(ctx, installation, dependencies.runner)
if err != nil { if err != nil {
@@ -201,9 +213,10 @@ func createWithDependencies(ctx context.Context, installation config.Installatio
Images: images, Images: images,
Volumes: volumes, Volumes: volumes,
} }
if err := writeArchive(ctx, output, installation, request, secretPaths, manifest, volumes, dependencies); err != nil { if err := writeArchive(ctx, output, reservation, installation, request, secretPaths, manifest, volumes, dependencies); err != nil {
return Result{}, err return Result{}, err
} }
published = true
if wasRunning { if wasRunning {
if err := composeStartAndVerify(ctx, installation, dependencies.runner); err != nil { if err := composeStartAndVerify(ctx, installation, dependencies.runner); err != nil {
return Result{}, err return Result{}, err
@@ -286,15 +299,90 @@ func ensureNewArchivePath(output string) error {
if err := os.MkdirAll(filepath.Dir(output), 0o700); err != nil { if err := os.MkdirAll(filepath.Dir(output), 0o700); err != nil {
return fmt.Errorf("create backup directory: %w", err) return fmt.Errorf("create backup directory: %w", err)
} }
if info, err := os.Lstat(output); err == nil || !errors.Is(err, os.ErrNotExist) { return nil
if err == nil && info.Mode().IsRegular() { }
return errors.New("backup output already exists")
} // archiveReservation claims an output path without replacing an existing archive. Its file
return errors.New("backup output path is unavailable") // identity is retained so cleanup never removes a path another process took over.
type archiveReservation struct {
path string
file *os.File
info os.FileInfo
}
func reserveArchiveOutput(output string) (*archiveReservation, error) {
file, err := os.OpenFile(output, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if errors.Is(err, os.ErrExist) {
return nil, errors.New("backup output already exists")
}
if err != nil {
return nil, fmt.Errorf("reserve backup output: %w", err)
}
info, statErr := file.Stat()
if statErr != nil {
_ = file.Close()
return nil, fmt.Errorf("inspect reserved backup output: %w", statErr)
}
return &archiveReservation{path: output, file: file, info: info}, nil
}
func publishReservedArchive(reservation *archiveReservation, temporaryPath string) error {
if reservation == nil || reservation.file == nil {
return errors.New("backup output reservation is unavailable")
}
temporary, err := os.Open(temporaryPath)
if err != nil {
return fmt.Errorf("open temporary backup archive: %w", err)
}
defer temporary.Close()
if err := reservation.file.Truncate(0); err != nil {
return fmt.Errorf("prepare reserved backup output: %w", err)
}
if _, err := reservation.file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("seek reserved backup output: %w", err)
}
if _, err := io.Copy(reservation.file, temporary); err != nil {
return fmt.Errorf("write reserved backup output: %w", err)
}
if err := reservation.file.Sync(); err != nil {
return fmt.Errorf("fsync reserved backup output: %w", err)
}
if err := reservation.file.Close(); err != nil {
return fmt.Errorf("close reserved backup output: %w", err)
}
reservation.file = nil
current, err := os.Stat(reservation.path)
if err != nil || !os.SameFile(reservation.info, current) {
return errors.New("backup output ownership changed before publication")
} }
return nil return nil
} }
// RemoveIfOwned removes the reservation only when the output path still names the exact file
// created by this invocation. It is safe to call after another process has claimed the path.
func (reservation *archiveReservation) RemoveIfOwned() error {
if reservation == nil {
return nil
}
if reservation.file != nil {
if err := reservation.file.Close(); err != nil {
return err
}
reservation.file = nil
}
current, err := os.Stat(reservation.path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if !os.SameFile(reservation.info, current) {
return nil
}
return os.Remove(reservation.path)
}
type renderedCompose struct { type renderedCompose struct {
Volumes map[string]struct { Volumes map[string]struct {
Name string `json:"name"` Name string `json:"name"`
@@ -398,11 +486,74 @@ func imageIdentities(ctx context.Context, installation config.Installation, runn
} }
func installationRunning(ctx context.Context, installation config.Installation, runner archiveRunner) (bool, error) { func installationRunning(ctx context.Context, installation config.Installation, runner archiveRunner) (bool, error) {
result, err := runner.Run(ctx, installation.ComposeArgs("ps", "--status", "running", "-q"), nil) result, err := runner.Run(ctx, installation.ComposeArgs("ps", "--all", "--format", "json"), nil)
if err != nil { if err != nil {
return false, dockerError("inspect running services", result, err) return false, dockerError("inspect service states", result, err)
} }
return strings.TrimSpace(result.Stdout) != "", nil states, err := backupServiceStates(result.Stdout)
if err != nil {
return false, err
}
running := false
coreRunning := false
for _, service := range states {
switch service.State {
case "exited", "dead":
continue
case "running":
running = true
if service.Service == "core" {
coreRunning = true
}
default:
return false, fmt.Errorf("service %q is %q and is not safely quiesced; stop the installation before backup", service.Service, service.State)
}
}
if running && !coreRunning {
return false, errors.New("core is not running while other services are active; stop the installation before backup")
}
return running, nil
}
type backupServiceState struct {
Service string `json:"Service"`
State string `json:"State"`
}
func backupServiceStates(value string) ([]backupServiceState, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" || trimmed == "[]" {
return nil, nil
}
var array []backupServiceState
if err := json.Unmarshal([]byte(trimmed), &array); err == nil {
return normalizeBackupServiceStates(array)
}
decoder := json.NewDecoder(strings.NewReader(trimmed))
var states []backupServiceState
for {
var state backupServiceState
err := decoder.Decode(&state)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, errors.New("Docker Compose returned invalid service states")
}
states = append(states, state)
}
return normalizeBackupServiceStates(states)
}
func normalizeBackupServiceStates(states []backupServiceState) ([]backupServiceState, error) {
for index := range states {
states[index].Service = strings.TrimSpace(states[index].Service)
states[index].State = strings.ToLower(strings.TrimSpace(states[index].State))
if states[index].Service == "" || states[index].State == "" {
return nil, errors.New("Docker Compose returned invalid service states")
}
}
return states, nil
} }
func maintenance(ctx context.Context, installation config.Installation, runner archiveRunner, activate bool) error { func maintenance(ctx context.Context, installation config.Installation, runner archiveRunner, activate bool) error {
@@ -480,14 +631,13 @@ func runCompose(ctx context.Context, installation config.Installation, runner ar
return nil return nil
} }
func writeArchive(ctx context.Context, output string, installation config.Installation, request CreateRequest, secretPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) { func writeArchive(ctx context.Context, output string, reservation *archiveReservation, installation config.Installation, request CreateRequest, secretPaths []string, manifest Manifest, volumes []VolumeMetadata, dependencies dependencies) (resultErr error) {
directory := filepath.Dir(output) directory := filepath.Dir(output)
temporary, err := os.CreateTemp(directory, ".tht-backup-*.tmp") temporary, err := os.CreateTemp(directory, ".tht-backup-*.tmp")
if err != nil { if err != nil {
return fmt.Errorf("create temporary backup archive: %w", err) return fmt.Errorf("create temporary backup archive: %w", err)
} }
temporaryPath := temporary.Name() temporaryPath := temporary.Name()
published := false
closedFile := false closedFile := false
defer func() { defer func() {
var closeErr error var closeErr error
@@ -497,10 +647,7 @@ func writeArchive(ctx context.Context, output string, installation config.Instal
if closeErr != nil && resultErr == nil { if closeErr != nil && resultErr == nil {
resultErr = closeErr resultErr = closeErr
} }
if !published { _ = os.Remove(temporaryPath)
_ = os.Remove(temporaryPath)
_ = os.Remove(output)
}
}() }()
if err := temporary.Chmod(0o600); err != nil { if err := temporary.Chmod(0o600); err != nil {
return fmt.Errorf("protect temporary backup archive: %w", err) return fmt.Errorf("protect temporary backup archive: %w", err)
@@ -632,10 +779,9 @@ func writeArchive(ctx context.Context, output string, installation config.Instal
return fmt.Errorf("close backup archive: %w", err) return fmt.Errorf("close backup archive: %w", err)
} }
closedFile = true closedFile = true
if err := dependencies.rename(temporaryPath, output); err != nil { if err := dependencies.publishReserved(reservation, temporaryPath); err != nil {
return fmt.Errorf("publish backup archive: %w", err) return fmt.Errorf("publish backup archive: %w", err)
} }
published = true
return nil return nil
} }
+79 -15
View File
@@ -176,7 +176,7 @@ func TestCreateCleansIncompleteArchiveAfterAtomicPublishFailure(t *testing.T) {
runner := newBackupRunner(fixture.installation, false) runner := newBackupRunner(fixture.installation, false)
directory := t.TempDir() directory := t.TempDir()
dependencies := testDependencies(t, runner) 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 { if _, err := createWithDependencies(context.Background(), fixture.installation, CreateRequest{Output: filepath.Join(directory, "failed.zip")}, dependencies); err == nil {
t.Fatal("Create() succeeded after publish failure") t.Fatal("Create() succeeded after publish failure")
@@ -184,6 +184,39 @@ func TestCreateCleansIncompleteArchiveAfterAtomicPublishFailure(t *testing.T) {
assertNoBackupArtifacts(t, directory) 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) { func TestCreateExcludesExternalSecretPayloadsByDefaultButRecordsDigests(t *testing.T) {
fixture := newBackupFixture(t, "local") fixture := newBackupFixture(t, "local")
runner := newBackupRunner(fixture.installation, false) 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 { type backupFixture struct {
root string root string
installationID string installationID string
@@ -437,6 +490,7 @@ type fakeBackupRunner struct {
stopCount int stopCount int
startCount int startCount int
healthChecks int healthChecks int
serviceStates map[string]string
} }
func newBackupRunner(installation config.Installation, running bool) *fakeBackupRunner { 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 return compose.Result{Stdout: string(encoded)}, nil
case strings.Contains(command, " images --format json"): 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 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"): case strings.Contains(command, " ps --all --format json"):
if runner.coreRunning { runner.healthChecks++
return compose.Result{Stdout: "core-id\n"}, nil states := runner.serviceStates
if states == nil {
if runner.running {
return compose.Result{Stdout: healthyServicesPayload()}, nil
}
return compose.Result{}, nil
} }
return compose.Result{}, nil services := make([]string, 0, len(states))
case strings.Contains(command, " ps --status running -q"): for service := range states {
if runner.running { services = append(services, service)
return compose.Result{Stdout: "container-id\n"}, nil
} }
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"): case strings.Contains(command, "/internal/maintenance/status"):
return compose.Result{Stdout: fmt.Sprintf(`{"active":%t,"admissions":0,"recoveryRequired":false}`, runner.maintenance)}, nil return compose.Result{Stdout: fmt.Sprintf(`{"active":%t,"admissions":0,"recoveryRequired":false}`, runner.maintenance)}, nil
case strings.Contains(command, "/internal/maintenance/activate"): case strings.Contains(command, "/internal/maintenance/activate"):
@@ -553,12 +616,13 @@ func (runner *fakeBackupRunner) SessionInventoryScope() string {
func testDependencies(t *testing.T, runner archiveRunner) dependencies { func testDependencies(t *testing.T, runner archiveRunner) dependencies {
t.Helper() t.Helper()
return dependencies{ return dependencies{
runner: runner, runner: runner,
now: func() time.Time { return time.Date(2026, 8, 16, 8, 11, 12, 0, time.UTC) }, now: func() time.Time { return time.Date(2026, 8, 16, 8, 11, 12, 0, time.UTC) },
homeDir: func() (string, error) { return t.TempDir(), nil }, homeDir: func() (string, error) { return t.TempDir(), nil },
revision: func(context.Context, string) (string, error) { return testRevision, nil }, revision: func(context.Context, string) (string, error) { return testRevision, nil },
sleep: func(time.Duration) {}, sleep: func(time.Duration) {},
rename: os.Rename, reserveOutput: reserveArchiveOutput,
publishReserved: publishReservedArchive,
} }
} }
+3 -1
View File
@@ -7,6 +7,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"path" "path"
"regexp" "regexp"
"sort" "sort"
@@ -193,7 +194,8 @@ func DecodeManifest(value []byte) (Manifest, error) {
if err := decoder.Decode(&manifest); err != nil { if err := decoder.Decode(&manifest); err != nil {
return Manifest{}, fmt.Errorf("decode backup manifest: %w", err) return Manifest{}, fmt.Errorf("decode backup manifest: %w", err)
} }
if decoder.More() { var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return Manifest{}, errors.New("backup manifest contains trailing data") return Manifest{}, errors.New("backup manifest contains trailing data")
} }
if err := manifest.Finalize(); err != nil { if err := manifest.Finalize(); err != nil {
+2 -1
View File
@@ -125,7 +125,8 @@ func TestDecodeManifestRejectsASecondDocument(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if _, err := DecodeManifest(append(encoded, encoded...)); err == nil { withSecondDocument := append(append([]byte(nil), encoded...), append([]byte(" \n"), encoded...)...)
if _, err := DecodeManifest(withSecondDocument); err == nil {
t.Fatal("DecodeManifest() accepted a second JSON document") t.Fatal("DecodeManifest() accepted a second JSON document")
} }
} }