test: Phase 2b restore orchestration coverage + nil-safe isDebug
Adds an in-process orchestration test for RestoreFromRecoveryUnit: success path calls recreate with non-secret env + recovered secrets merged; data-key-missing path is REFUSED and recreate is never called. Makes Manager.isDebug nil-safe (behavior-neutral in prod; cfg is always set) so the gate/orchestration are testable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -594,7 +594,7 @@ func (m *Manager) GetFullStatus(nextDBDump time.Time) *FullBackupStatus {
|
|||||||
|
|
||||||
// isDebug returns true if logging level is "debug".
|
// isDebug returns true if logging level is "debug".
|
||||||
func (m *Manager) isDebug() bool {
|
func (m *Manager) isDebug() bool {
|
||||||
return m.cfg.Logging.Level == "debug"
|
return m.cfg != nil && m.cfg.Logging.Level == "debug"
|
||||||
}
|
}
|
||||||
|
|
||||||
func dbNames(dbs []DiscoveredDB) string {
|
func dbNames(dbs []DiscoveredDB) string {
|
||||||
|
|||||||
@@ -11,10 +11,14 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// fakeRecoveryProvider is a minimal StackDataProvider for the capture test.
|
// fakeRecoveryProvider is a configurable StackDataProvider for the capture + restore tests.
|
||||||
type fakeRecoveryProvider struct {
|
type fakeRecoveryProvider struct {
|
||||||
info RecoveryInfo
|
info RecoveryInfo
|
||||||
hdd string
|
hdd string
|
||||||
|
secrets map[string]string // returned by RecoverStackSecrets
|
||||||
|
gotEnv map[string]string // captured by RecreateStackFromUnit
|
||||||
|
running bool // returned by RefreshAndIsRunning
|
||||||
|
stopped bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) {
|
func (f *fakeRecoveryProvider) GetStackComposePath(string) (string, bool) {
|
||||||
@@ -24,14 +28,17 @@ func (f *fakeRecoveryProvider) ListDeployedStacks() []StackSummary { retur
|
|||||||
func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil }
|
func (f *fakeRecoveryProvider) GetStackHDDMounts(string) []string { return nil }
|
||||||
func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd }
|
func (f *fakeRecoveryProvider) GetStackHDDPath(string) string { return f.hdd }
|
||||||
func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil }
|
func (f *fakeRecoveryProvider) GetDockerVolumes(string) []string { return nil }
|
||||||
func (f *fakeRecoveryProvider) StopStack(string) error { return nil }
|
func (f *fakeRecoveryProvider) StopStack(string) error { f.stopped = true; return nil }
|
||||||
func (f *fakeRecoveryProvider) StartStack(string) error { return nil }
|
func (f *fakeRecoveryProvider) StartStack(string) error { return nil }
|
||||||
func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return false }
|
func (f *fakeRecoveryProvider) RefreshAndIsRunning(string) bool { return f.running }
|
||||||
func (f *fakeRecoveryProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
func (f *fakeRecoveryProvider) GetStackRecoveryInfo(string) (RecoveryInfo, bool) {
|
||||||
return f.info, true
|
return f.info, true
|
||||||
}
|
}
|
||||||
func (f *fakeRecoveryProvider) RecoverStackSecrets(string, []string) map[string]string { return nil }
|
func (f *fakeRecoveryProvider) RecoverStackSecrets(string, []string) map[string]string {
|
||||||
func (f *fakeRecoveryProvider) RecreateStackFromUnit(string, string, map[string]string) error {
|
return f.secrets
|
||||||
|
}
|
||||||
|
func (f *fakeRecoveryProvider) RecreateStackFromUnit(_, _ string, fullEnv map[string]string) error {
|
||||||
|
f.gotEnv = fullEnv
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,71 @@
|
|||||||
package backup
|
package backup
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestRestoreFromRecoveryUnitOrchestration exercises the full in-process flow: read manifest →
|
||||||
|
// recover secrets → apply gate → recreate with the reconciled env. It proves (a) on success the
|
||||||
|
// recreate is called with non-secret env + recovered secrets merged, and (b) on a missing data-key the
|
||||||
|
// restore is REFUSED and recreate is never called.
|
||||||
|
func TestRestoreFromRecoveryUnitOrchestration(t *testing.T) {
|
||||||
|
newUnit := func(t *testing.T) (drive string) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
drive = filepath.Join(tmp, "drive")
|
||||||
|
// stripped (secret-free) app.yaml in the unit
|
||||||
|
mustWrite(t, filepath.Join(RecoveryUnitComposePath(drive, "app"), "app.yaml"),
|
||||||
|
"deployed: true\nenv:\n SUBDOMAIN: trips\n")
|
||||||
|
man := &RecoveryManifest{SchemaVersion: 1, AppName: "app", ControllerVer: "v",
|
||||||
|
SecretEnvVars: []string{"DB_PASSWORD", "SECRET_KEY"}, DataKeyEnvVars: []string{"SECRET_KEY"}}
|
||||||
|
if err := writeManifest(RecoveryUnitManifestPath(drive, "app"), man); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return drive
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("success — recreate called with merged env", func(t *testing.T) {
|
||||||
|
drive := newUnit(t)
|
||||||
|
fake := &fakeRecoveryProvider{
|
||||||
|
hdd: drive,
|
||||||
|
running: true, // so the post-restore health wait returns promptly
|
||||||
|
secrets: map[string]string{"DB_PASSWORD": "pw", "SECRET_KEY": "deadbeef"},
|
||||||
|
}
|
||||||
|
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
|
||||||
|
stackProvider: fake}
|
||||||
|
if err := m.RestoreFromRecoveryUnit("app"); err != nil {
|
||||||
|
t.Fatalf("restore: %v", err)
|
||||||
|
}
|
||||||
|
if fake.gotEnv == nil {
|
||||||
|
t.Fatal("recreate was not called")
|
||||||
|
}
|
||||||
|
if fake.gotEnv["SUBDOMAIN"] != "trips" || fake.gotEnv["DB_PASSWORD"] != "pw" || fake.gotEnv["SECRET_KEY"] != "deadbeef" {
|
||||||
|
t.Errorf("recreate got wrong env: %v", fake.gotEnv)
|
||||||
|
}
|
||||||
|
if !fake.stopped {
|
||||||
|
t.Errorf("app should be stopped before restore")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("data-key unrecoverable — REFUSED, recreate not called", func(t *testing.T) {
|
||||||
|
drive := newUnit(t)
|
||||||
|
fake := &fakeRecoveryProvider{
|
||||||
|
hdd: drive,
|
||||||
|
secrets: map[string]string{"DB_PASSWORD": "pw"}, // SECRET_KEY (data_key) missing
|
||||||
|
}
|
||||||
|
m := &Manager{logger: log.New(io.Discard, "", 0), systemDataPath: filepath.Join(drive, "..", "sys"),
|
||||||
|
stackProvider: fake}
|
||||||
|
err := m.RestoreFromRecoveryUnit("app")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected fail-closed refusal, got nil")
|
||||||
|
}
|
||||||
|
if fake.gotEnv != nil {
|
||||||
|
t.Errorf("recreate must NOT be called on refusal, got %v", fake.gotEnv)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// TestReconcileRestoreSecrets covers the safety-critical fail-closed gate + secret reconciliation.
|
// TestReconcileRestoreSecrets covers the safety-critical fail-closed gate + secret reconciliation.
|
||||||
func TestReconcileRestoreSecrets(t *testing.T) {
|
func TestReconcileRestoreSecrets(t *testing.T) {
|
||||||
|
|||||||
Reference in New Issue
Block a user