diff --git a/cmd/ob/doctor.go b/cmd/ob/doctor.go index 12d910c..e9da706 100644 --- a/cmd/ob/doctor.go +++ b/cmd/ob/doctor.go @@ -529,6 +529,13 @@ func inspectDoctorBackups(cfg *app.Spec, configPath string, deps doctorDependenc // command that reads the repository itself. for _, name := range cfg.ServiceNames() { service := cfg.Services[name] + if service.Persistence != nil && service.Persistence.Mode == "ephemeral" { + report.Checks = append(report.Checks, doctorBackupCheck{ + Status: doctorPass, Workload: name, Mechanism: "backup", Available: false, + Message: "declared ephemeral; Onebox renders no durable volume and no backup is required", + }) + continue + } if service.Backup == nil { report.Checks = append(report.Checks, doctorBackupCheck{ Status: doctorWarning, Workload: name, Mechanism: "backup", Available: false, @@ -590,7 +597,7 @@ func inspectDoctorBackups(cfg *app.Spec, configPath string, deps doctorDependenc } else if report.Status == doctorWarning { report.Message = "durable data is present and Onebox does not back it up" } else { - report.Message = "declared local backup mechanisms are available" + report.Message = "local backup and persistence checks pass" } return report } diff --git a/cmd/ob/doctor_persistence_test.go b/cmd/ob/doctor_persistence_test.go new file mode 100644 index 0000000..64eb9f6 --- /dev/null +++ b/cmd/ob/doctor_persistence_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/labstack/onebox/internal/app" + "github.com/labstack/onebox/internal/buildinfo" +) + +func doctorPersistenceProject(t *testing.T, services string) (string, *app.Spec) { + t.Helper() + path := filepath.Join(t.TempDir(), "onebox.yml") + manifest := `apiVersion: onebox.run/v1alpha2 +kind: Application +metadata: {name: shop} +spec: + environments: {production: {server: deploy@example.com}} + workloads: {web: {image: 'nginx:1.27', strategy: Recreate}} + services: +` + services + if err := os.WriteFile(path, []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + cfg, err := app.Load(path) + if err != nil { + t.Fatal(err) + } + return path, cfg +} + +func TestDoctorServiceBackupsRespectAuthoredPersistence(t *testing.T) { + for name, tc := range map[string]struct { + services string + status doctorStatus + checks int + }{ + "ephemeral": {" cache: {driver: redis, version: '8', persistence: {mode: Ephemeral}}\n", doctorPass, 1}, + "durable": {" cache: {driver: redis, version: '8', persistence: {mode: Durable}}\n", doctorWarning, 1}, + "default": {" cache: {driver: redis, version: '8'}\n", doctorWarning, 1}, + "mixed": {" cache: {driver: redis, version: '8', persistence: {mode: Ephemeral}}\n durable: {driver: valkey, version: '8'}\n", doctorWarning, 2}, + } { + t.Run(name, func(t *testing.T) { + path, cfg := doctorPersistenceProject(t, tc.services) + report := inspectDoctorBackups(cfg, path, doctorTestDependencies(t)) + if report.Status != tc.status || len(report.Checks) != tc.checks { + t.Fatalf("backups = %+v", report) + } + for _, check := range report.Checks { + ephemeral := name == "ephemeral" || name == "mixed" && check.Workload == "cache" + want := doctorWarning + if ephemeral { + want = doctorPass + if !strings.Contains(check.Message, "declared ephemeral") || strings.Contains(check.Message, ".backup") { + t.Fatalf("ephemeral service got misleading advice: %+v", check) + } + } + if check.Status != want || check.Available || check.Mechanism != "backup" { + t.Fatalf("service check = %+v, want %s without a backup", check, want) + } + } + if name == "ephemeral" && strings.Contains(report.Message, "mechanisms are available") { + t.Fatalf("summary claims an available backup: %s", report.Message) + } + }) + } +} + +func TestDoctorStillReportsDeclaredServiceBackup(t *testing.T) { + cfg := &app.Spec{Services: map[string]app.Service{ + "database": {Backup: &app.BackupPolicy{RecoveryKind: "pitr", Target: "offsite"}}, + }} + report := inspectDoctorBackups(cfg, "onebox.yml", doctorTestDependencies(t)) + if report.Status != doctorPass || len(report.Checks) != 1 || !report.Checks[0].Available || + !strings.Contains(report.Checks[0].Message, "declares pitr backup to target offsite") || + !strings.Contains(report.Checks[0].Message, "ob backup status database") { + t.Fatalf("declared backup = %+v", report) + } +} + +func TestDoctorEphemeralServicePassesHumanAndJSONReports(t *testing.T) { + path, _ := doctorPersistenceProject(t, " cache: {driver: redis, version: '8', persistence: {mode: Ephemeral}}\n") + deps := doctorTestDependencies(t) + current, err := deps.executable() + if err != nil { + t.Fatal(err) + } + deps.pathValue = filepath.Dir(current) + deps.lookPath = func(name string) (string, error) { + if name == "ob" { + return current, nil + } + return "", errors.New("not found") + } + deps.inspectBinary = func(string) (buildinfo.Info, error) { return deps.runner.Info, nil } + deps.loadConfig = app.Load + previous := newDoctorDependencies + newDoctorDependencies = func() doctorDependencies { return deps } + t.Cleanup(func() { newDoctorDependencies = previous }) + for _, format := range []string{"human", "json"} { + t.Run(format, func(t *testing.T) { + cmd := newRootCmd() + var out, stderr bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&stderr) + args := []string{"doctor", "-c", path} + if format == "json" { + args = append(args, "--output", "json") + } + cmd.SetArgs(args) + if err := cmd.Execute(); err != nil { + t.Fatalf("doctor: %v: %s", err, out.String()) + } + if format == "human" { + if !strings.Contains(out.String(), "Onebox doctor: PASS") || !strings.Contains(out.String(), "cache/backup") || !strings.Contains(out.String(), "declared ephemeral") { + t.Fatalf("human doctor = %s", out.String()) + } + return + } + var envelope struct { + Data doctorReport `json:"data"` + } + if err := json.Unmarshal(out.Bytes(), &envelope); err != nil { + t.Fatalf("decode doctor: %v: %s", err, out.String()) + } + if envelope.Data.Status != doctorPass || envelope.Data.Backups.Status != doctorPass || stderr.Len() != 0 { + t.Fatalf("JSON doctor = %+v, stderr = %s", envelope.Data, stderr.String()) + } + }) + } +} diff --git a/site/src/content/docs/guides/back-up-a-database.mdx b/site/src/content/docs/guides/back-up-a-database.mdx index 82ededb..6b313c4 100644 --- a/site/src/content/docs/guides/back-up-a-database.mdx +++ b/site/src/content/docs/guides/back-up-a-database.mdx @@ -275,7 +275,10 @@ first if what you want is the old history. Only the `postgres` driver has an executable contract, on versions 17 and 18. Every other driver refuses a `backup` policy at validate rather than accepting one it cannot honour. A workload's own volume is never copied anywhere — `ob -doctor` names every workload holding durable data. +doctor` names every workload holding durable data. A managed service declared +`persistence: {mode: Ephemeral}` is listed as a pass because its data is +disposable; doctor does not suggest adding a backup. Services with durable or +default persistence still warn when no backup is declared. Full field list: [`services`](/reference/fields/services) and [`backupTargets`](/reference/fields/backup-targets).