v0.53.1: refresh recovery units on periodic cache cycle (idempotent)

CaptureRecoveryUnit now builds content in memory and skips writes when the unit
is already current (checksum + dump-set + version), so it can run from RefreshCache
(startup + every 5m) without thrashing the USB drive. Units now exist shortly after
startup and track config changes without waiting for the daily DB dump. +idempotency test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-13 10:27:35 +02:00
parent 70eb521cd0
commit eefeeabea3
4 changed files with 151 additions and 43 deletions
@@ -126,6 +126,52 @@ func TestCaptureRecoveryUnitIsSecretFree(t *testing.T) {
})
}
// TestCaptureRecoveryUnitIdempotent proves the checksum-skip guard: a second capture with unchanged
// config does NOT rewrite the manifest (CreatedAt stable), but a config change DOES.
func TestCaptureRecoveryUnitIdempotent(t *testing.T) {
tmp := t.TempDir()
stackDir := filepath.Join(tmp, "stack")
drive := filepath.Join(tmp, "drive")
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: ex/app:1\n")
mustWrite(t, filepath.Join(AppDBDumpPath(drive, "ex"), "ex.sql"), "d")
info := RecoveryInfo{StackDir: stackDir, DisplayName: "Ex", ImagePins: []string{"ex/app:1"},
NonSecretEnv: map[string]string{"SUBDOMAIN": "ex"}}
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(tmp, "sys"),
stackProvider: &fakeRecoveryProvider{info: info, hdd: drive}, version: "v1"}
manifestPath := RecoveryUnitManifestPath(drive, "ex")
if err := m.CaptureRecoveryUnit("ex"); err != nil {
t.Fatal(err)
}
first := readManifest(manifestPath)
if first == nil {
t.Fatal("manifest not written")
}
// Second capture, unchanged → skipped (manifest byte-identical incl. CreatedAt).
if err := m.CaptureRecoveryUnit("ex"); err != nil {
t.Fatal(err)
}
if again := readManifest(manifestPath); again.CreatedAt != first.CreatedAt {
t.Errorf("idempotent capture rewrote manifest: %q -> %q", first.CreatedAt, again.CreatedAt)
}
// Change the compose → must rewrite (config checksum differs).
mustWrite(t, filepath.Join(stackDir, "docker-compose.yml"), "services:\n app:\n image: ex/app:2\n")
m.stackProvider.(*fakeRecoveryProvider).info.ImagePins = []string{"ex/app:2"}
if err := m.CaptureRecoveryUnit("ex"); err != nil {
t.Fatal(err)
}
changed := readManifest(manifestPath)
if len(changed.ImagePins) != 1 || changed.ImagePins[0] != "ex/app:2" {
t.Errorf("config change not captured: %v", changed.ImagePins)
}
if changed.Checksums["docker-compose.yml"] == first.Checksums["docker-compose.yml"] {
t.Errorf("compose checksum should change after edit")
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {