detached 639b629

docs: add backup & cron services implementation plan

commit
639b629b6f05f9d7be5e1917a3e79001df1fac07
author
brice · · 2026-02-07 14:40 UTC
parent
5b20b25
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +986 -0
A docs/backup-cron-implementation-plan.md +986 -0
@@ -0,0 +1,986 @@
1+# Backup & Cron Services - Implementation Plan
2+
3+## Overview
4+
5+Add automated database backup capabilities and a generic cron scheduler to Gordon.
6+The system auto-detects database attachments, deploys backup sidecar containers,
7+and manages scheduled backups with retention policies. PostgreSQL 17-18 first,
8+designed DB-agnostic from day one.
9+
10+## Architecture Decision: Sidecar Pattern (Approach B)
11+
12+For each DB attachment with backups enabled, Gordon deploys a **backup sidecar container**
13+on the same Docker network. The sidecar contains all backup tools and is orchestrated
14+by Gordon via `ExecInContainer`.
15+
16+**Why sidecar over exec-into-DB:**
17+- No dependency on tools being present in the DB image (works with minimal/custom images)
18+- Sidecar can bundle pgBackRest, pg_dump, pg_basebackup — one image, all tools
19+- Clean separation: DB container stays untouched, backup logic is isolated
20+- Same pattern scales to MySQL (xtrabackup sidecar), MongoDB (mongodump sidecar), etc.
21+- PITR setup doesn't require modifying the running DB container's config at runtime
22+- Sidecar shares the network → DNS access to the DB (e.g., `postgres:5432`)
23+
24+**Sidecar lifecycle:**
25+- Deployed as a special attachment type (`gordon.backup.sidecar=true`)
26+- Stays running alongside the DB container (long-lived, idle most of the time)
27+- Gordon triggers backup/restore operations via `ExecInContainer` into the sidecar
28+- Shares a backup volume for storing dumps and WAL archives
29+
30+---
31+
32+## File Structure
33+
34+```
35+internal/
36+├── domain/
37+│ ├── backup.go # BackupJob, BackupResult, DBType, DBInfo, Schedule
38+│ └── cron.go # CronEntry, CronSchedule
39+│
40+├── boundaries/
41+│ ├── in/
42+│ │ └── backup.go # BackupService interface
43+│ └── out/
44+│ ├── runtime.go # + ExecInContainer, CopyFromContainer (already exists)
45+│ └── backupstore.go # BackupStorage interface
46+│
47+├── usecase/
48+│ ├── cron/
49+│ │ ├── scheduler.go # Generic cron scheduler (Go native, no deps)
50+│ │ └── scheduler_test.go
51+│ └── backup/
52+│ ├── service.go # Backup orchestrator
53+│ ├── service_test.go
54+│ ├── detector.go # DB type auto-detection
55+│ ├── detector_test.go
56+│ ├── sidecar.go # Sidecar deployment & management
57+│ ├── sidecar_test.go
58+│ └── strategy/
59+│ ├── strategy.go # BackupStrategy interface
60+│ ├── postgres.go # PostgreSQL: pg_dump, pg_basebackup, WAL/pgBackRest
61+│ └── postgres_test.go
62+│
63+├── adapters/
64+│ ├── in/
65+│ │ ├── cli/
66+│ │ │ └── backup.go # gordon backup list|run|restore|schedule|status
67+│ │ └── http/
68+│ │ └── admin/handler.go # + backup API endpoints
69+│ └── out/
70+│ ├── docker/
71+│ │ └── runtime.go # + ExecInContainer implementation
72+│ └── filesystem/
73+│ ├── backup_storage.go # Backup file storage (local FS)
74+│ └── backup_storage_test.go
75+│
76+├── testutils/
77+│ └── fixtures/
78+│ ├── configs/
79+│ │ └── backup.toml # Test config with backup settings
80+│ └── docker/
81+│ └── docker-compose.test.yml # Integration test: PG + sidecar
82+```
83+
84+---
85+
86+## 1. Domain Models
87+
88+### `internal/domain/backup.go`
89+
90+```go
91+package domain
92+
93+import "time"
94+
95+// DBType identifies the database engine.
96+type DBType string
97+
98+const (
99+ DBTypePostgreSQL DBType = "postgresql"
100+ DBTypeMySQL DBType = "mysql"
101+ DBTypeMariaDB DBType = "mariadb"
102+ DBTypeMongoDB DBType = "mongodb"
103+ DBTypeUnknown DBType = "unknown"
104+)
105+
106+// DBInfo holds detected database information from an attachment container.
107+type DBInfo struct {
108+ Type DBType
109+ Version string // e.g., "17.2", "18.0"
110+ Host string // DNS hostname in the Docker network
111+ Port int // e.g., 5432
112+ ContainerID string // Docker container ID
113+ AttachedTo string // Domain or group the DB belongs to
114+ Credentials map[string]string // Extracted from env vars (user, password, dbname)
115+ ImageName string // Original image reference
116+}
117+
118+// BackupSchedule defines when backups run.
119+type BackupSchedule string
120+
121+const (
122+ ScheduleHourly BackupSchedule = "hourly"
123+ ScheduleDaily BackupSchedule = "daily"
124+ ScheduleWeekly BackupSchedule = "weekly"
125+ ScheduleMonthly BackupSchedule = "monthly"
126+)
127+
128+// BackupType distinguishes backup methods.
129+type BackupType string
130+
131+const (
132+ BackupTypeLogical BackupType = "logical" // pg_dump, mysqldump
133+ BackupTypePhysical BackupType = "physical" // pg_basebackup
134+ BackupTypeWAL BackupType = "wal" // WAL archive segment
135+ BackupTypePITR BackupType = "pitr" // Full PITR base backup (pgBackRest)
136+)
137+
138+// BackupJob represents a scheduled or manual backup operation.
139+type BackupJob struct {
140+ ID string
141+ Domain string // Which domain's DB
142+ DBInfo DBInfo
143+ Schedule BackupSchedule // "" for manual
144+ Type BackupType
145+ Status BackupJobStatus
146+ StartedAt time.Time
147+ CompletedAt time.Time
148+ SizeBytes int64
149+ FilePath string // Path in backup storage
150+ Error string // Error message if failed
151+ Metadata map[string]string
152+}
153+
154+type BackupJobStatus string
155+
156+const (
157+ BackupStatusPending BackupJobStatus = "pending"
158+ BackupStatusRunning BackupJobStatus = "running"
159+ BackupStatusCompleted BackupJobStatus = "completed"
160+ BackupStatusFailed BackupJobStatus = "failed"
161+)
162+
163+// BackupResult is returned after a backup operation completes.
164+type BackupResult struct {
165+ Job BackupJob
166+ Duration time.Duration
167+}
168+
169+// RetentionPolicy defines how many backups to keep per schedule tier.
170+type RetentionPolicy struct {
171+ Hourly int // Number of hourly backups to keep
172+ Daily int
173+ Weekly int
174+ Monthly int
175+}
176+
177+// PITRConfig holds PITR-specific settings.
178+type PITRConfig struct {
179+ Enabled bool
180+ WALStorageDir string
181+ Retention time.Duration // How long to keep WAL archives
182+}
183+
184+// BackupConfig is the top-level backup configuration.
185+type BackupConfig struct {
186+ Enabled bool
187+ StorageDir string
188+ Retention RetentionPolicy
189+ PITR PITRConfig
190+ Overrides map[string]BackupOverride // per domain/attachment override
191+}
192+
193+// BackupOverride allows per-attachment backup configuration.
194+type BackupOverride struct {
195+ Schedules []BackupSchedule // Which schedules to run
196+ Retention *RetentionPolicy // Override global retention
197+ PITR *bool // Override global PITR setting
198+}
199+
200+// Backup labels for container metadata.
201+const (
202+ LabelBackupEnabled = "gordon.backup" // "true"/"false"
203+ LabelBackupType = "gordon.backup.type" // "postgresql" — override auto-detect
204+ LabelBackupVersion = "gordon.backup.version" // "17" — override
205+ LabelBackupSchedule = "gordon.backup.schedule" // "hourly,daily,weekly"
206+ LabelBackupSidecar = "gordon.backup.sidecar" // "true" on sidecar containers
207+)
208+```
209+
210+### `internal/domain/cron.go`
211+
212+```go
213+package domain
214+
215+import "time"
216+
217+// CronSchedule represents a recurring schedule.
218+type CronSchedule struct {
219+ // Preset schedule (hourly, daily, weekly, monthly)
220+ // or custom cron expression.
221+ Preset BackupSchedule
222+ CronExpr string // Optional: "0 */6 * * *" style
223+}
224+
225+// CronEntry represents a registered cron job.
226+type CronEntry struct {
227+ ID string
228+ Name string
229+ Schedule CronSchedule
230+ LastRun time.Time
231+ NextRun time.Time
232+ Running bool
233+}
234+```
235+
236+---
237+
238+## 2. Boundaries (Interfaces)
239+
240+### `internal/boundaries/out/runtime.go` — additions
241+
242+```go
243+// ExecResult holds the result of executing a command in a container.
244+type ExecResult struct {
245+ ExitCode int
246+ Stdout []byte
247+ Stderr []byte
248+}
249+
250+// Add to ContainerRuntime interface:
251+
252+// ExecInContainer executes a command inside a running container.
253+ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)
254+```
255+
256+Note: `CopyFromContainer` already exists in the Docker adapter but is NOT in the
257+`ContainerRuntime` interface. It should be promoted to the interface as well since
258+the backup service needs it.
259+
260+### `internal/boundaries/out/backupstore.go` — new
261+
262+```go
263+type BackupStorage interface {
264+ // Store saves backup data and returns the storage path.
265+ Store(ctx context.Context, domain, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error)
266+
267+ // Get retrieves a backup by path.
268+ Get(ctx context.Context, path string) (io.ReadCloser, error)
269+
270+ // List returns backups for a domain, optionally filtered by schedule.
271+ List(ctx context.Context, domain string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error)
272+
273+ // Delete removes a backup.
274+ Delete(ctx context.Context, path string) error
275+
276+ // ApplyRetention removes old backups according to the retention policy.
277+ ApplyRetention(ctx context.Context, domain string, policy domain.RetentionPolicy) (deleted int, err error)
278+}
279+```
280+
281+### `internal/boundaries/in/backup.go` — new
282+
283+```go
284+type BackupService interface {
285+ // ListBackups returns all backups, optionally filtered by domain.
286+ ListBackups(ctx context.Context, domain string) ([]domain.BackupJob, error)
287+
288+ // RunBackup triggers an immediate backup for a domain's DB.
289+ RunBackup(ctx context.Context, domain string, dbName string) (*domain.BackupResult, error)
290+
291+ // Restore restores a backup by ID.
292+ Restore(ctx context.Context, domain string, backupID string) error
293+
294+ // RestorePITR restores to a specific point in time.
295+ RestorePITR(ctx context.Context, domain string, targetTime time.Time) error
296+
297+ // Status returns backup health info (last success, failures, next scheduled).
298+ Status(ctx context.Context) ([]domain.BackupJob, error)
299+
300+ // DetectDatabases scans attachments and returns detected DBs.
301+ DetectDatabases(ctx context.Context, domain string) ([]domain.DBInfo, error)
302+}
303+```
304+
305+---
306+
307+## 3. Cron Scheduler
308+
309+### `internal/usecase/cron/scheduler.go`
310+
311+Pure Go scheduler, no external dependencies. Ticks every minute, compares `NextRun` with
312+`time.Now()`.
313+
314+```go
315+type Scheduler struct {
316+ entries map[string]*entry
317+ mu sync.RWMutex
318+ stopCh chan struct{}
319+ log zerowrap.Logger
320+}
321+
322+type entry struct {
323+ id string
324+ name string
325+ schedule Schedule
326+ job func(ctx context.Context) error
327+ lastRun time.Time
328+ nextRun time.Time
329+ running atomic.Bool
330+}
331+
332+// Key methods:
333+func NewScheduler(log zerowrap.Logger) *Scheduler
334+func (s *Scheduler) Add(id, name string, sched Schedule, job func(ctx context.Context) error) error
335+func (s *Scheduler) Remove(id string)
336+func (s *Scheduler) Start(ctx context.Context)
337+func (s *Scheduler) Stop()
338+func (s *Scheduler) List() []domain.CronEntry
339+func (s *Scheduler) RunNow(id string) error // Manual trigger
340+```
341+
342+**Schedule resolution:**
343+- `hourly` → `:00` of every hour
344+- `daily` → `02:00` UTC
345+- `weekly` → Sunday `03:00` UTC
346+- `monthly` → 1st of month `04:00` UTC
347+- Custom cron expression support for future use
348+
349+---
350+
351+## 4. DB Auto-Detection
352+
353+### `internal/usecase/backup/detector.go`
354+
355+Detection pipeline (cascading, first match wins for type):
356+
357+```
358+1. Label override: gordon.backup.type on the container → forced type
359+2. Image name parsing: "postgres:17-alpine" → PostgreSQL 17
360+3. Env var inspection: POSTGRES_DB, MYSQL_DATABASE → type confirmation
361+4. Port inspection: 5432, 3306, 27017 → fallback hint
362+```
363+
364+```go
365+// Known image prefixes → DB type mapping
366+var imagePatterns = map[string]DBType{
367+ "postgres": DBTypePostgreSQL,
368+ "pgvector": DBTypePostgreSQL,
369+ "postgis": DBTypePostgreSQL,
370+ "timescale": DBTypePostgreSQL,
371+ "mysql": DBTypeMySQL,
372+ "mariadb": DBTypeMariaDB,
373+ "mongo": DBTypeMongoDB,
374+ "mongodb": DBTypeMongoDB,
375+}
376+
377+// Known env vars → DB type mapping
378+var envPatterns = map[string]DBType{
379+ "POSTGRES_DB": DBTypePostgreSQL,
380+ "POSTGRES_USER": DBTypePostgreSQL,
381+ "POSTGRES_PASSWORD": DBTypePostgreSQL,
382+ "PGDATA": DBTypePostgreSQL,
383+ "MYSQL_DATABASE": DBTypeMySQL,
384+ "MYSQL_ROOT_PASSWORD": DBTypeMySQL,
385+ "MONGO_INITDB_DATABASE": DBTypeMongoDB,
386+}
387+
388+// Known ports → DB type mapping
389+var portPatterns = map[int]DBType{
390+ 5432: DBTypePostgreSQL,
391+ 3306: DBTypeMySQL,
392+ 27017: DBTypeMongoDB,
393+}
394+
395+func (d *Detector) DetectAll(ctx context.Context, attachments []domain.Attachment) ([]domain.DBInfo, error)
396+func (d *Detector) Detect(ctx context.Context, attachment domain.Attachment) (*domain.DBInfo, error)
397+```
398+
399+**Credential extraction:**
400+- Reads env vars from the attachment container via `runtime.InspectImageEnv()`
401+- Also checks domain secrets (`DomainSecretStore`) for attachment secrets
402+- Maps to standard keys: `user`, `password`, `database`
403+
404+---
405+
406+## 5. Backup Strategy Interface & PostgreSQL Implementation
407+
408+### `internal/usecase/backup/strategy/strategy.go`
409+
410+```go
411+type BackupStrategy interface {
412+ // Type returns the DB type this strategy handles.
413+ Type() domain.DBType
414+
415+ // Dump performs a logical backup (pg_dump, mysqldump, etc.).
416+ // Executes inside the sidecar container.
417+ Dump(ctx context.Context, sidecarID string, db domain.DBInfo, opts DumpOptions) (*domain.BackupResult, error)
418+
419+ // Restore restores from a logical backup.
420+ Restore(ctx context.Context, sidecarID string, db domain.DBInfo, backupData io.Reader, opts RestoreOptions) error
421+
422+ // SupportsPITR returns whether this strategy supports point-in-time recovery.
423+ SupportsPITR() bool
424+
425+ // SetupPITR configures continuous archiving (WAL archiving, binlog, etc.).
426+ SetupPITR(ctx context.Context, sidecarID string, db domain.DBInfo, opts PITROptions) error
427+
428+ // BaseBackup creates a physical base backup for PITR.
429+ BaseBackup(ctx context.Context, sidecarID string, db domain.DBInfo, opts PITROptions) (*domain.BackupResult, error)
430+
431+ // RestorePITR restores to a specific point in time.
432+ RestorePITR(ctx context.Context, sidecarID string, db domain.DBInfo, targetTime time.Time) error
433+
434+ // SidecarImage returns the Docker image to use for the backup sidecar.
435+ SidecarImage() string
436+
437+ // SidecarEnv returns env vars to set on the sidecar container.
438+ SidecarEnv(db domain.DBInfo) []string
439+
440+ // SidecarVolumes returns volumes the sidecar needs.
441+ SidecarVolumes() map[string]string
442+}
443+```
444+
445+### `internal/usecase/backup/strategy/postgres.go`
446+
447+```go
448+type PostgresStrategy struct {
449+ runtime out.ContainerRuntime
450+ log zerowrap.Logger
451+}
452+
453+func (s *PostgresStrategy) Type() domain.DBType { return domain.DBTypePostgreSQL }
454+func (s *PostgresStrategy) SupportsPITR() bool { return true }
455+
456+// SidecarImage returns a lightweight image with PG client tools + pgBackRest.
457+// We use the official postgres image matching the DB version — it includes
458+// pg_dump, pg_restore, pg_basebackup. pgBackRest can be added as a layer.
459+func (s *PostgresStrategy) SidecarImage() string {
460+ return "ghcr.io/bnema/gordon-backup-postgres:latest"
461+ // This image contains: pg_dump, pg_restore, pg_basebackup, pgBackRest
462+ // Based on: postgres:<version>-alpine + pgbackrest
463+}
464+```
465+
466+**Dump flow:**
467+```
468+1. Gordon exec into sidecar: pg_dump -h <db-host> -U <user> -Fc <dbname> > /backups/dump.pgfc
469+2. Gordon CopyFromContainer(sidecarID, "/backups/dump.pgfc")
470+3. BackupStorage.Store(data)
471+4. Clean up temp file in sidecar
472+```
473+
474+**PITR flow (pgBackRest in sidecar):**
475+```
476+1. Sidecar runs pgBackRest configured to connect to the PG container
477+2. pgBackRest manages WAL archiving via archive_command (PG → shared volume → sidecar)
478+3. Scheduled: pgBackRest stanza create + full/incr backups
479+4. Restore: pgBackRest restore --target=<timestamp> --target-action=promote
480+```
481+
482+---
483+
484+## 6. Sidecar Management
485+
486+### `internal/usecase/backup/sidecar.go`
487+
488+```go
489+type SidecarManager struct {
490+ runtime out.ContainerRuntime
491+ strategy strategy.BackupStrategy
492+ log zerowrap.Logger
493+}
494+
495+// EnsureSidecar checks if a backup sidecar exists for the given DB, creates one if not.
496+func (m *SidecarManager) EnsureSidecar(ctx context.Context, db domain.DBInfo, networkName string) (string, error)
497+
498+// RemoveSidecar removes the backup sidecar for a DB.
499+func (m *SidecarManager) RemoveSidecar(ctx context.Context, db domain.DBInfo) error
500+
501+// GetSidecarID returns the container ID of the sidecar for a DB, if it exists.
502+func (m *SidecarManager) GetSidecarID(ctx context.Context, db domain.DBInfo) (string, error)
503+```
504+
505+**Sidecar container spec:**
506+- Name: `gordon-<domain>-backup-<dbname>` (e.g., `gordon-app-mydomain-com-backup-postgres`)
507+- Labels: `gordon.managed=true`, `gordon.backup.sidecar=true`, `gordon.attached-to=<domain>`
508+- Network: same as the DB attachment
509+- Volumes: backup storage volume + optional WAL archive volume
510+- Command: `sleep infinity` (idle — Gordon execs commands into it as needed)
511+- Image: strategy-specific (e.g., `gordon-backup-postgres:latest`)
512+
513+---
514+
515+## 7. Backup Service (Orchestrator)
516+
517+### `internal/usecase/backup/service.go`
518+
519+```go
520+type Service struct {
521+ runtime out.ContainerRuntime
522+ storage out.BackupStorage
523+ detector *Detector
524+ sidecars *SidecarManager
525+ strategies map[domain.DBType]strategy.BackupStrategy
526+ scheduler *cron.Scheduler
527+ config domain.BackupConfig
528+ containerSvc in.ContainerService // To query attachments
529+ log zerowrap.Logger
530+}
531+
532+func NewService(
533+ runtime out.ContainerRuntime,
534+ storage out.BackupStorage,
535+ containerSvc in.ContainerService,
536+ scheduler *cron.Scheduler,
537+ config domain.BackupConfig,
538+ log zerowrap.Logger,
539+) *Service
540+
541+// Start initializes the backup service: detects DBs, deploys sidecars, registers schedules.
542+func (s *Service) Start(ctx context.Context) error
543+
544+// Stop cleans up (but does NOT remove sidecars — they persist).
545+func (s *Service) Stop()
546+
547+// RunBackup triggers an immediate backup.
548+func (s *Service) RunBackup(ctx context.Context, domain, dbName string) (*domain.BackupResult, error)
549+
550+// RunScheduled is called by the cron scheduler.
551+func (s *Service) RunScheduled(ctx context.Context, schedule domain.BackupSchedule) error
552+
553+// ListBackups returns stored backups.
554+func (s *Service) ListBackups(ctx context.Context, domain string) ([]domain.BackupJob, error)
555+
556+// Restore restores from a specific backup.
557+func (s *Service) Restore(ctx context.Context, domain, backupID string) error
558+
559+// RestorePITR restores to a point in time.
560+func (s *Service) RestorePITR(ctx context.Context, domain string, targetTime time.Time) error
561+
562+// DetectDatabases scans attachments and returns detected DB info.
563+func (s *Service) DetectDatabases(ctx context.Context, domain string) ([]domain.DBInfo, error)
564+
565+// Status returns backup health summary.
566+func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error)
567+```
568+
569+**Startup flow:**
570+```
571+BackupService.Start()
572+ → For each domain with attachments:
573+ → detector.DetectAll(attachments) → []DBInfo
574+ → For each detected DB:
575+ → strategy = strategies[db.Type]
576+ → sidecar.EnsureSidecar(db, networkName) → sidecarContainerID
577+ → If PITR enabled: strategy.SetupPITR(sidecarID, db)
578+ → Register cron jobs:
579+ → scheduler.Add("backup-hourly", hourly, RunScheduled(hourly))
580+ → scheduler.Add("backup-daily", daily, RunScheduled(daily))
581+ → scheduler.Add("backup-weekly", weekly, RunScheduled(weekly))
582+ → scheduler.Add("backup-monthly", monthly, RunScheduled(monthly))
583+ → scheduler.Add("backup-retention", daily, ApplyRetention())
584+```
585+
586+---
587+
588+## 8. Configuration (TOML)
589+
590+```toml
591+[backups]
592+enabled = true
593+storage_dir = "/var/lib/gordon/backups"
594+
595+[backups.retention]
596+hourly = 24 # keep 24 hourly backups
597+daily = 7 # keep 7 daily backups
598+weekly = 4 # keep 4 weekly backups
599+monthly = 12 # keep 12 monthly backups
600+
601+[backups.pitr]
602+enabled = false # opt-in globally
603+wal_storage_dir = "/var/lib/gordon/wal-archive"
604+retention = "7d" # keep 7 days of WAL archives
605+
606+# Per-attachment overrides
607+[backups.overrides."app.example.com/postgres"]
608+schedules = ["daily", "weekly"] # only daily & weekly, no hourly
609+pitr = true # enable PITR for this one
610+
611+[backups.overrides."api.example.com/postgres"]
612+schedules = ["hourly", "daily", "weekly", "monthly"]
613+retention.daily = 14 # override: keep 14 daily
614+```
615+
616+---
617+
618+## 9. CLI Commands
619+
620+### `internal/adapters/in/cli/backup.go`
621+
622+```bash
623+# List backups
624+gordon backup list # all domains
625+gordon backup list app.example.com # specific domain
626+gordon backup list app.example.com --schedule daily # filter by schedule
627+
628+# Run manual backup
629+gordon backup run app.example.com # auto-detect DB, backup now
630+gordon backup run app.example.com --db postgres # specific DB if multiple
631+
632+# Restore
633+gordon backup restore app.example.com <backup-id>
634+gordon backup restore app.example.com --pitr "2025-01-15T14:30:00Z"
635+
636+# Schedule info
637+gordon backup schedule list # show all active schedules
638+gordon backup schedule list app.example.com # for a domain
639+
640+# Health/status
641+gordon backup status # last success/failure per domain
642+
643+# DB detection (debug/info)
644+gordon backup detect # show all detected DBs
645+gordon backup detect app.example.com # for a domain
646+
647+# Remote mode support
648+gordon backup list --remote https://gordon.example.com --token $TOKEN
649+```
650+
651+---
652+
653+## 10. Admin API Endpoints
654+
655+### Additions to `internal/adapters/in/http/admin/handler.go`
656+
657+```
658+GET /admin/backups # List all backups
659+GET /admin/backups/{domain} # List backups for domain
660+POST /admin/backups/{domain} # Trigger manual backup
661+POST /admin/backups/{domain}/restore/{id} # Restore from backup
662+POST /admin/backups/{domain}/pitr # PITR restore (body: {"target_time": "..."})
663+GET /admin/backups/status # Backup health summary
664+GET /admin/backups/{domain}/detect # Show detected DBs
665+GET /admin/backups/schedules # List active schedules
666+```
667+
668+---
669+
670+## 11. Docker Runtime Additions
671+
672+### `internal/adapters/out/docker/runtime.go`
673+
674+**New method — `ExecInContainer`:**
675+
676+```go
677+func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
678+ // 1. ContainerExecCreate — create the exec instance
679+ // 2. ContainerExecAttach — attach to get stdout/stderr
680+ // 3. ContainerExecInspect — get exit code
681+ // Uses Docker SDK: client.ContainerExecCreate, ContainerExecAttach, ContainerExecInspect
682+}
683+```
684+
685+**Promote to interface — `CopyFromContainer`:**
686+
687+Already implemented in the adapter (`runtime.go:1110`), but not declared in the
688+`ContainerRuntime` interface. Add it:
689+
690+```go
691+// In boundaries/out/runtime.go:
692+CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error)
693+```
694+
695+---
696+
697+## 12. Tests
698+
699+### Unit Tests (mockery mocks)
700+
701+#### Mockery config additions (`.mockery.yaml`)
702+
703+```yaml
704+# Add to existing packages:
705+github.com/bnema/gordon/internal/boundaries/out:
706+ interfaces:
707+ # ... existing ...
708+ BackupStorage: # NEW
709+
710+github.com/bnema/gordon/internal/boundaries/in:
711+ interfaces:
712+ # ... existing ...
713+ BackupService: # NEW
714+```
715+
716+#### Test files
717+
718+| File | Tests |
719+|------|-------|
720+| `usecase/cron/scheduler_test.go` | Scheduler tick, add/remove jobs, concurrent safety, manual trigger, preset schedules |
721+| `usecase/backup/detector_test.go` | Image name parsing (postgres:17, pgvector/pgvector:pg17, custom), env var detection, port fallback, label override, unknown images |
722+| `usecase/backup/service_test.go` | RunBackup flow (mock runtime + storage), RunScheduled, retention cleanup, error handling, PITR setup |
723+| `usecase/backup/sidecar_test.go` | EnsureSidecar create/reuse, RemoveSidecar, sidecar container config validation |
724+| `usecase/backup/strategy/postgres_test.go` | Dump command construction, restore command, PITR setup commands, version handling |
725+| `adapters/out/docker/runtime_exec_test.go` | ExecInContainer (requires Docker or mock client) |
726+| `adapters/out/filesystem/backup_storage_test.go` | Store, List, Get, Delete, ApplyRetention (uses temp dirs) |
727+
728+#### Mock usage pattern
729+
730+```go
731+// Example: service_test.go
732+func TestRunBackup_PostgreSQL(t *testing.T) {
733+ mockRuntime := mocks.NewMockContainerRuntime(t)
734+ mockStorage := mocks.NewMockBackupStorage(t)
735+ mockContainerSvc := mocks.NewMockContainerService(t)
736+
737+ // Setup expectations
738+ mockContainerSvc.EXPECT().
739+ ListAttachments(mock.Anything, "app.example.com").
740+ Return([]domain.Attachment{
741+ {Name: "postgres", Image: "postgres:17", ContainerID: "abc123", Status: "running"},
742+ }, nil)
743+
744+ mockRuntime.EXPECT().
745+ InspectImageEnv(mock.Anything, "postgres:17").
746+ Return([]string{"POSTGRES_DB=mydb", "POSTGRES_USER=app", "PGDATA=/var/lib/postgresql/data"}, nil)
747+
748+ mockRuntime.EXPECT().
749+ ExecInContainer(mock.Anything, "sidecar-id", mock.MatchedBy(func(cmd []string) bool {
750+ return cmd[0] == "pg_dump"
751+ })).
752+ Return(&out.ExecResult{ExitCode: 0, Stdout: []byte("dump data")}, nil)
753+
754+ mockStorage.EXPECT().
755+ Store(mock.Anything, "app.example.com", "postgres", mock.Anything, mock.Anything, mock.Anything).
756+ Return("/backups/app.example.com/postgres/2025-01-15T10-00-00.pgfc", nil)
757+
758+ // Create service and run
759+ svc := backup.NewService(mockRuntime, mockStorage, mockContainerSvc, scheduler, config, log)
760+ result, err := svc.RunBackup(ctx, "app.example.com", "postgres")
761+
762+ assert.NoError(t, err)
763+ assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status)
764+}
765+```
766+
767+### Integration Tests (local dev)
768+
769+#### `internal/testutils/fixtures/docker/docker-compose.test.yml`
770+
771+A docker-compose file for spinning up a real PostgreSQL + sidecar locally to test
772+the full backup/restore flow end-to-end.
773+
774+```yaml
775+services:
776+ postgres:
777+ image: postgres:17-alpine
778+ environment:
779+ POSTGRES_DB: testdb
780+ POSTGRES_USER: testuser
781+ POSTGRES_PASSWORD: testpass
782+ ports:
783+ - "15432:5432"
784+ volumes:
785+ - pgdata:/var/lib/postgresql/data
786+ networks:
787+ - gordon-test
788+
789+ backup-sidecar:
790+ image: postgres:17-alpine # or gordon-backup-postgres:latest once built
791+ command: ["sleep", "infinity"]
792+ environment:
793+ PGHOST: postgres
794+ PGUSER: testuser
795+ PGPASSWORD: testpass
796+ PGDATABASE: testdb
797+ volumes:
798+ - backups:/backups
799+ depends_on:
800+ - postgres
801+ networks:
802+ - gordon-test
803+
804+volumes:
805+ pgdata:
806+ backups:
807+
808+networks:
809+ gordon-test:
810+ driver: bridge
811+```
812+
813+#### `internal/usecase/backup/integration_test.go`
814+
815+```go
816+//go:build integration
817+
818+package backup_test
819+
820+// Tests tagged with `integration` build tag.
821+// Run with: go test -tags=integration -v ./internal/usecase/backup/
822+//
823+// Prerequisites:
824+// docker compose -f internal/testutils/fixtures/docker/docker-compose.test.yml up -d
825+//
826+// Test cases:
827+// - TestIntegration_DetectPostgres: detect PG from running container
828+// - TestIntegration_DumpAndRestore: full pg_dump → restore cycle
829+// - TestIntegration_PITRSetup: configure WAL archiving, verify segments
830+// - TestIntegration_RetentionCleanup: create N backups, verify retention
831+// - TestIntegration_SidecarLifecycle: deploy sidecar, exec, remove
832+```
833+
834+#### Makefile additions
835+
836+```makefile
837+# Integration test targets
838+test-integration-backup:
839+ docker compose -f internal/testutils/fixtures/docker/docker-compose.test.yml up -d --wait
840+ go test -tags=integration -v -count=1 ./internal/usecase/backup/...
841+ docker compose -f internal/testutils/fixtures/docker/docker-compose.test.yml down -v
842+
843+test-integration-backup-keep:
844+ docker compose -f internal/testutils/fixtures/docker/docker-compose.test.yml up -d --wait
845+ go test -tags=integration -v -count=1 ./internal/usecase/backup/... || true
846+ @echo "Containers still running — use 'make test-integration-backup-down' to clean up"
847+
848+test-integration-backup-down:
849+ docker compose -f internal/testutils/fixtures/docker/docker-compose.test.yml down -v
850+```
851+
852+---
853+
854+## 13. Documentation Updates
855+
856+### New docs
857+
858+| File | Content |
859+|------|---------|
860+| `docs/config/backups.md` | Full backup configuration reference (TOML options, retention, PITR, overrides) |
861+| `docs/cli/backup.md` | CLI command reference (list, run, restore, schedule, status, detect) |
862+
863+### Updated docs
864+
865+| File | Changes |
866+|------|---------|
867+| `docs/config/index.md` | Add "Backups" to config section list |
868+| `docs/config/attachments.md` | Add "Automatic Backups" section — link to backups.md, mention auto-detection |
869+| `docs/cli/index.md` | Add `gordon backup` to CLI commands list |
870+| `docs/reference/docker-labels.md` | Add backup labels table (`gordon.backup`, `gordon.backup.type`, etc.) |
871+| `docs/concepts.md` | Add "Backups & Recovery" to concepts overview |
872+| `docs/reference/index.md` | Add link to backup-related reference |
873+
874+---
875+
876+## 14. Dependency Injection Wiring
877+
878+### `internal/app/run.go` additions
879+
880+```go
881+// In createServices():
882+func createBackupService(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) (*backup.Service, error) {
883+ // 1. Parse backup config from viper
884+ // 2. Create BackupStorage (filesystem adapter)
885+ // 3. Create Scheduler
886+ // 4. Create Detector
887+ // 5. Register strategies (PostgresStrategy for now)
888+ // 6. Create BackupService
889+ // 7. If backups enabled: call backupSvc.Start()
890+ return svc, nil
891+}
892+
893+// Register in services struct:
894+type services struct {
895+ // ... existing fields ...
896+ backupSvc *backup.Service
897+ scheduler *cron.Scheduler
898+}
899+```
900+
901+---
902+
903+## 15. Events
904+
905+### New event types in `internal/domain/event.go`
906+
907+```go
908+const (
909+ EventBackupStarted EventType = "backup.started"
910+ EventBackupCompleted EventType = "backup.completed"
911+ EventBackupFailed EventType = "backup.failed"
912+ EventRestoreStarted EventType = "restore.started"
913+ EventRestoreCompleted EventType = "restore.completed"
914+)
915+```
916+
917+These integrate with the existing EventBus so that:
918+- Config reload re-scans attachments and adjusts backup schedules
919+- Container deploy events trigger sidecar deployment for new DBs
920+- Backup events can be consumed by future notification systems (webhook, etc.)
921+
922+---
923+
924+## 16. Implementation Order
925+
926+| Phase | Task | Depends On |
927+|-------|------|------------|
928+| **1** | `ExecInContainer` in runtime interface + Docker adapter | — |
929+| **1** | Promote `CopyFromContainer` to interface | — |
930+| **1** | Regenerate mocks (`make mocks`) | Phase 1 interface changes |
931+| **2** | Domain models (`backup.go`, `cron.go`) | — |
932+| **2** | `BackupStorage` interface + filesystem adapter | Domain models |
933+| **2** | `BackupStorage` adapter tests | Adapter |
934+| **3** | Cron scheduler + tests | — |
935+| **4** | DB detector + tests | Domain models, runtime interface |
936+| **5** | `BackupStrategy` interface + PostgreSQL strategy + tests | Runtime, domain |
937+| **6** | Sidecar manager + tests | Runtime, strategy |
938+| **7** | Backup service (orchestrator) + tests | All above |
939+| **8** | CLI commands (`gordon backup ...`) | Backup service |
940+| **8** | Admin API endpoints | Backup service |
941+| **9** | Config TOML integration (`[backups]` section) | Config service |
942+| **9** | DI wiring in `app/run.go` | All above |
943+| **10** | `.mockery.yaml` update + regenerate | New interfaces |
944+| **10** | Integration test infrastructure (docker-compose, Makefile targets) | All above |
945+| **10** | Integration tests | Infrastructure |
946+| **11** | Documentation (new + updates) | Feature complete |
947+| **12** | PITR implementation (pgBackRest in sidecar) | Logical backups working |
948+
949+---
950+
951+## 17. Sidecar Docker Image (future)
952+
953+The backup sidecar needs a dedicated image. For MVP, we can use the official
954+`postgres:<version>-alpine` image which includes `pg_dump`, `pg_restore`, `pg_basebackup`.
955+
956+For PITR (Phase 12), we'll need a custom image with pgBackRest:
957+
958+```dockerfile
959+# ghcr.io/bnema/gordon-backup-postgres
960+ARG PG_VERSION=17
961+FROM postgres:${PG_VERSION}-alpine
962+
963+RUN apk add --no-cache pgbackrest
964+
965+# Backup scripts (optional helpers)
966+COPY scripts/ /usr/local/bin/
967+
968+CMD ["sleep", "infinity"]
969+```
970+
971+Multi-version support: build matrix for PG 17 and 18 tags.
972+
973+---
974+
975+## Open Items (for later discussion)
976+
977+- **Remote storage (S3, etc.)**: The `BackupStorage` interface is ready for it —
978+ just add a new adapter. Not needed for MVP.
979+- **Compression**: `pg_dump -Fc` already uses zlib compression. For physical backups,
980+ consider zstd (configurable).
981+- **Notifications**: Events are in place (`EventBackupFailed`). Webhook adapter is a
982+ natural next step.
983+- **Backup encryption**: Encrypt at rest with a configurable key. Add to BackupStorage
984+ as a wrapping layer.
985+- **Concurrent backups**: The scheduler should NOT run the same domain's backup
986+ concurrently. Use per-domain locks (same pattern as deploy locks).
@@ -0,0 +1,986 @@
1+# Backup & Cron Services - Implementation Plan
2+
3+## Overview
4+
5+Add automated database backup capabilities and a generic cron scheduler to Gordon.
6+The system auto-detects database attachments, deploys backup sidecar containers,
7+and manages scheduled backups with retention policies. PostgreSQL 17-18 first,
8+designed DB-agnostic from day one.
9+
10+## Architecture Decision: Sidecar Pattern (Approach B)
11+
12+For each DB attachment with backups enabled, Gordon deploys a **backup sidecar container**
13+on the same Docker network. The sidecar contains all backup tools and is orchestrated
14+by Gordon via `ExecInContainer`.
15+
16+**Why sidecar over exec-into-DB:**
17+- No dependency on tools being present in the DB image (works with minimal/custom images)
18+- Sidecar can bundle pgBackRest, pg_dump, pg_basebackup — one image, all tools
19+- Clean separation: DB container stays untouched, backup logic is isolated
20+- Same pattern scales to MySQL (xtrabackup sidecar), MongoDB (mongodump sidecar), etc.
21+- PITR setup doesn't require modifying the running DB container's config at runtime
22+- Sidecar shares the network → DNS access to the DB (e.g., `postgres:5432`)
23+
24+**Sidecar lifecycle:**
25+- Deployed as a special attachment type (`gordon.backup.sidecar=true`)
26+- Stays running alongside the DB container (long-lived, idle most of the time)
27+- Gordon triggers backup/restore operations via `ExecInContainer` into the sidecar
28+- Shares a backup volume for storing dumps and WAL archives
29+
30+---
31+
32+## File Structure
33+
34+```
35+internal/
36+├── domain/
37+│ ├── backup.go # BackupJob, BackupResult, DBType, DBInfo, Schedule
38+│ └── cron.go # CronEntry, CronSchedule
39+│
40+├── boundaries/
41+│ ├── in/
42+│ │ └── backup.go # BackupService interface
43+│ └── out/
44+│ ├── runtime.go # + ExecInContainer, CopyFromContainer (already exists)
45+│ └── backupstore.go # BackupStorage interface
46+│
47+├── usecase/
48+│ ├── cron/
49+│ │ ├── scheduler.go # Generic cron scheduler (Go native, no deps)
50+│ │ └── scheduler_test.go
51+│ └── backup/
52+│ ├── service.go # Backup orchestrator
53+│ ├── service_test.go
54+│ ├── detector.go # DB type auto-detection
55+│ ├── detector_test.go
56+│ ├── sidecar.go # Sidecar deployment & management
57+│ ├── sidecar_test.go
58+│ └── strategy/
59+│ ├── strategy.go # BackupStrategy interface
60+│ ├── postgres.go # PostgreSQL: pg_dump, pg_basebackup, WAL/pgBackRest
61+│ └── postgres_test.go
62+│
63+├── adapters/
64+│ ├── in/
65+│ │ ├── cli/
66+│ │ │ └── backup.go # gordon backup list|run|restore|schedule|status
67+│ │ └── http/
68+│ │ └── admin/handler.go # + backup API endpoints
69+│ └── out/
70+│ ├── docker/
71+│ │ └── runtime.go # + ExecInContainer implementation
72+│ └── filesystem/
73+│ ├── backup_storage.go # Backup file storage (local FS)
74+│ └── backup_storage_test.go
75+│
76+├── testutils/
77+│ └── fixtures/
78+│ ├── configs/
79+│ │ └── backup.toml # Test config with backup settings
80+│ └── docker/
81+│ └── docker-compose.test.yml # Integration test: PG + sidecar
82+```
83+
84+---
85+
86+## 1. Domain Models
87+
88+### `internal/domain/backup.go`
89+
90+```go
91+package domain
92+
93+import "time"
94+
95+// DBType identifies the database engine.
96+type DBType string
97+
98+const (
99+ DBTypePostgreSQL DBType = "postgresql"
100+ DBTypeMySQL DBType = "mysql"
101+ DBTypeMariaDB DBType = "mariadb"
102+ DBTypeMongoDB DBType = "mongodb"
103+ DBTypeUnknown DBType = "unknown"
104+)
105+
106+// DBInfo holds detected database information from an attachment container.
107+type DBInfo struct {
108+ Type DBType
109+ Version string // e.g., "17.2", "18.0"
110+ Host string // DNS hostname in the Docker network
111+ Port int // e.g., 5432
112+ ContainerID string // Docker container ID
113+ AttachedTo string // Domain or group the DB belongs to
114+ Credentials map[string]string // Extracted from env vars (user, password, dbname)
115+ ImageName string // Original image reference
116+}
117+
118+// BackupSchedule defines when backups run.
119+type BackupSchedule string
120+
121+const (
122+ ScheduleHourly BackupSchedule = "hourly"
123+ ScheduleDaily BackupSchedule = "daily"
124+ ScheduleWeekly BackupSchedule = "weekly"
125+ ScheduleMonthly BackupSchedule = "monthly"
126+)
127+
128+// BackupType distinguishes backup methods.
129+type BackupType string
130+
131+const (
132+ BackupTypeLogical BackupType = "logical" // pg_dump, mysqldump
133+ BackupTypePhysical BackupType = "physical" // pg_basebackup
134+ BackupTypeWAL BackupType = "wal" // WAL archive segment
135+ BackupTypePITR BackupType = "pitr" // Full PITR base backup (pgBackRest)
136+)
137+
138+// BackupJob represents a scheduled or manual backup operation.
139+type BackupJob struct {
140+ ID string
141+ Domain string // Which domain's DB
142+ DBInfo DBInfo
143+ Schedule BackupSchedule // "" for manual
144+ Type BackupType
145+ Status BackupJobStatus
146+ StartedAt time.Time
147+ CompletedAt time.Time
148+ SizeBytes int64
149+ FilePath string // Path in backup storage
150+ Error string // Error message if failed
151+ Metadata map[string]string
152+}
153+
154+type BackupJobStatus string
155+
156+const (
157+ BackupStatusPending BackupJobStatus = "pending"
158+ BackupStatusRunning BackupJobStatus = "running"
159+ BackupStatusCompleted BackupJobStatus = "completed"
160+ BackupStatusFailed BackupJobStatus = "failed"
161+)
162+
163+// BackupResult is returned after a backup operation completes.
164+type BackupResult struct {
165+ Job BackupJob
166+ Duration time.Duration
167+}
168+
169+// RetentionPolicy defines how many backups to keep per schedule tier.
170+type RetentionPolicy struct {
171+ Hourly int // Number of hourly backups to keep
172+ Daily int
173+ Weekly int
174+ Monthly int
175+}
176+
177+// PITRConfig holds PITR-specific settings.
178+type PITRConfig struct {
179+ Enabled bool
180+ WALStorageDir string
181+ Retention time.Duration // How long to keep WAL archives
182+}
183+
184+// BackupConfig is the top-level backup configuration.
185+type BackupConfig struct {
186+ Enabled bool
187+ StorageDir string
188+ Retention RetentionPolicy
189+ PITR PITRConfig
190+ Overrides map[string]BackupOverride // per domain/attachment override
191+}
192+
193+// BackupOverride allows per-attachment backup configuration.
194+type BackupOverride struct {
195+ Schedules []BackupSchedule // Which schedules to run
196+ Retention *RetentionPolicy // Override global retention
197+ PITR *bool // Override global PITR setting
198+}
199+
200+// Backup labels for container metadata.
201+const (
202+ LabelBackupEnabled = "gordon.backup" // "true"/"false"
203+ LabelBackupType = "gordon.backup.type" // "postgresql" — override auto-detect
204+ LabelBackupVersion = "gordon.backup.version" // "17" — override
205+ LabelBackupSchedule = "gordon.backup.schedule" // "hourly,daily,weekly"
206+ LabelBackupSidecar = "gordon.backup.sidecar" // "true" on sidecar containers
207+)
208+```
209+
210+### `internal/domain/cron.go`
211+
212+```go
213+package domain
214+
215+import "time"
216+
217+// CronSchedule represents a recurring schedule.
218+type CronSchedule struct {
219+ // Preset schedule (hourly, daily, weekly, monthly)
220+ // or custom cron expression.
221+ Preset BackupSchedule
222+ CronExpr string // Optional: "0 */6 * * *" style
223+}
224+
225+// CronEntry represents a registered cron job.
226+type CronEntry struct {
227+ ID string
228+ Name string
229+ Schedule CronSchedule
230+ LastRun time.Time
231+ NextRun time.Time
232+ Running bool
233+}
234+```
235+
236+---
237+
238+## 2. Boundaries (Interfaces)
239+
240+### `internal/boundaries/out/runtime.go` — additions
241+
242+```go
243+// ExecResult holds the result of executing a command in a container.
244+type ExecResult struct {
245+ ExitCode int
246+ Stdout []byte
247+ Stderr []byte
248+}
249+
250+// Add to ContainerRuntime interface:
251+
252+// ExecInContainer executes a command inside a running container.
253+ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)
254+```
255+
256+Note: `CopyFromContainer` already exists in the Docker adapter but is NOT in the
257+`ContainerRuntime` interface. It should be promoted to the interface as well since
258+the backup service needs it.
259+
260+### `internal/boundaries/out/backupstore.go` — new
261+
262+```go
263+type BackupStorage interface {
264+ // Store saves backup data and returns the storage path.
265+ Store(ctx context.Context, domain, dbName string, schedule domain.BackupSchedule, timestamp time.Time, data io.Reader) (string, error)
266+
267+ // Get retrieves a backup by path.
268+ Get(ctx context.Context, path string) (io.ReadCloser, error)
269+
270+ // List returns backups for a domain, optionally filtered by schedule.
271+ List(ctx context.Context, domain string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error)
272+
273+ // Delete removes a backup.
274+ Delete(ctx context.Context, path string) error
275+
276+ // ApplyRetention removes old backups according to the retention policy.
277+ ApplyRetention(ctx context.Context, domain string, policy domain.RetentionPolicy) (deleted int, err error)
278+}
279+```
280+
281+### `internal/boundaries/in/backup.go` — new
282+
283+```go
284+type BackupService interface {
285+ // ListBackups returns all backups, optionally filtered by domain.
286+ ListBackups(ctx context.Context, domain string) ([]domain.BackupJob, error)
287+
288+ // RunBackup triggers an immediate backup for a domain's DB.
289+ RunBackup(ctx context.Context, domain string, dbName string) (*domain.BackupResult, error)
290+
291+ // Restore restores a backup by ID.
292+ Restore(ctx context.Context, domain string, backupID string) error
293+
294+ // RestorePITR restores to a specific point in time.
295+ RestorePITR(ctx context.Context, domain string, targetTime time.Time) error
296+
297+ // Status returns backup health info (last success, failures, next scheduled).
298+ Status(ctx context.Context) ([]domain.BackupJob, error)
299+
300+ // DetectDatabases scans attachments and returns detected DBs.
301+ DetectDatabases(ctx context.Context, domain string) ([]domain.DBInfo, error)
302+}
303+```
304+
305+---
306+
307+## 3. Cron Scheduler
308+
309+### `internal/usecase/cron/scheduler.go`
310+
311+Pure Go scheduler, no external dependencies. Ticks every minute, compares `NextRun` with
312+`time.Now()`.
313+
314+```go
315+type Scheduler struct {
316+ entries map[string]*entry
317+ mu sync.RWMutex
318+ stopCh chan struct{}
319+ log zerowrap.Logger
320+}
321+
322+type entry struct {
323+ id string
324+ name string
325+ schedule Schedule
326+ job func(ctx context.Context) error
327+ lastRun time.Time
328+ nextRun time.Time
329+ running atomic.Bool
330+}
331+
332+// Key methods:
333+func NewScheduler(log zerowrap.Logger) *Scheduler
334+func (s *Scheduler) Add(id, name string, sched Schedule, job func(ctx context.Context) error) error
335+func (s *Scheduler) Remove(id string)
336+func (s *Scheduler) Start(ctx context.Context)
337+func (s *Scheduler) Stop()
338+func (s *Scheduler) List() []domain.CronEntry
339+func (s *Scheduler) RunNow(id string) error // Manual trigger
340+```
341+
342+**Schedule resolution:**
343+- `hourly` → `:00` of every hour
344+- `daily` → `02:00` UTC
345+- `weekly` → Sunday `03:00` UTC
346+- `monthly` → 1st of month `04:00` UTC
347+- Custom cron expression support for future use
348+
349+---
350+
351+## 4. DB Auto-Detection
352+
353+### `internal/usecase/backup/detector.go`
354+
355+Detection pipeline (cascading, first match wins for type):
356+
357+```
358+1. Label override: gordon.backup.type on the container → forced type
359+2. Image name parsing: "postgres:17-alpine" → PostgreSQL 17
360+3. Env var inspection: POSTGRES_DB, MYSQL_DATABASE → type confirmation
361+4. Port inspection: 5432, 3306, 27017 → fallback hint
362+```
363+
364+```go
365+// Known image prefixes → DB type mapping
366+var imagePatterns = map[string]DBType{
367+ "postgres": DBTypePostgreSQL,
368+ "pgvector": DBTypePostgreSQL,
369+ "postgis": DBTypePostgreSQL,
370+ "timescale": DBTypePostgreSQL,
371+ "mysql": DBTypeMySQL,
372+ "mariadb": DBTypeMariaDB,
373+ "mongo": DBTypeMongoDB,
374+ "mongodb": DBTypeMongoDB,
375+}
376+
377+// Known env vars → DB type mapping
378+var envPatterns = map[string]DBType{
379+ "POSTGRES_DB": DBTypePostgreSQL,
380+ "POSTGRES_USER": DBTypePostgreSQL,
381+ "POSTGRES_PASSWORD": DBTypePostgreSQL,
382+ "PGDATA": DBTypePostgreSQL,
383+ "MYSQL_DATABASE": DBTypeMySQL,
384+ "MYSQL_ROOT_PASSWORD": DBTypeMySQL,
385+ "MONGO_INITDB_DATABASE": DBTypeMongoDB,
386+}
387+
388+// Known ports → DB type mapping
389+var portPatterns = map[int]DBType{
390+ 5432: DBTypePostgreSQL,
391+ 3306: DBTypeMySQL,
392+ 27017: DBTypeMongoDB,
393+}
394+
395+func (d *Detector) DetectAll(ctx context.Context, attachments []domain.Attachment) ([]domain.DBInfo, error)
396+func (d *Detector) Detect(ctx context.Context, attachment domain.Attachment) (*domain.DBInfo, error)
397+```
398+
399+**Credential extraction:**
400+- Reads env vars from the attachment container via `runtime.InspectImageEnv()`
401+- Also checks domain secrets (`DomainSecretStore`) for attachment secrets
402+- Maps to standard keys: `user`, `password`, `database`
403+
404+---
405+
406+## 5. Backup Strategy Interface & PostgreSQL Implementation
407+
408+### `internal/usecase/backup/strategy/strategy.go`
409+
410+```go
411+type BackupStrategy interface {
412+ // Type returns the DB type this strategy handles.
413+ Type() domain.DBType
414+
415+ // Dump performs a logical backup (pg_dump, mysqldump, etc.).
416+ // Executes inside the sidecar container.
417+ Dump(ctx context.Context, sidecarID string, db domain.DBInfo, opts DumpOptions) (*domain.BackupResult, error)
418+
419+ // Restore restores from a logical backup.
420+ Restore(ctx context.Context, sidecarID string, db domain.DBInfo, backupData io.Reader, opts RestoreOptions) error
421+
422+ // SupportsPITR returns whether this strategy supports point-in-time recovery.
423+ SupportsPITR() bool
424+
425+ // SetupPITR configures continuous archiving (WAL archiving, binlog, etc.).
426+ SetupPITR(ctx context.Context, sidecarID string, db domain.DBInfo, opts PITROptions) error
427+
428+ // BaseBackup creates a physical base backup for PITR.
429+ BaseBackup(ctx context.Context, sidecarID string, db domain.DBInfo, opts PITROptions) (*domain.BackupResult, error)
430+
431+ // RestorePITR restores to a specific point in time.
432+ RestorePITR(ctx context.Context, sidecarID string, db domain.DBInfo, targetTime time.Time) error
433+
434+ // SidecarImage returns the Docker image to use for the backup sidecar.
435+ SidecarImage() string
436+
437+ // SidecarEnv returns env vars to set on the sidecar container.
438+ SidecarEnv(db domain.DBInfo) []string
439+
440+ // SidecarVolumes returns volumes the sidecar needs.
441+ SidecarVolumes() map[string]string
442+}
443+```
444+
445+### `internal/usecase/backup/strategy/postgres.go`
446+
447+```go
448+type PostgresStrategy struct {
449+ runtime out.ContainerRuntime
450+ log zerowrap.Logger
451+}
452+
453+func (s *PostgresStrategy) Type() domain.DBType { return domain.DBTypePostgreSQL }
454+func (s *PostgresStrategy) SupportsPITR() bool { return true }
455+
456+// SidecarImage returns a lightweight image with PG client tools + pgBackRest.
457+// We use the official postgres image matching the DB version — it includes
458+// pg_dump, pg_restore, pg_basebackup. pgBackRest can be added as a layer.
459+func (s *PostgresStrategy) SidecarImage() string {
460+ return "ghcr.io/bnema/gordon-backup-postgres:latest"
461+ // This image contains: pg_dump, pg_restore, pg_basebackup, pgBackRest
462+ // Based on: postgres:<version>-alpine + pgbackrest
463+}
464+```
465+
466+**Dump flow:**
467+```
468+1. Gordon exec into sidecar: pg_dump -h <db-host> -U <user> -Fc <dbname> > /backups/dump.pgfc
469+2. Gordon CopyFromContainer(sidecarID, "/backups/dump.pgfc")
470+3. BackupStorage.Store(data)
471+4. Clean up temp file in sidecar
472+```
473+
474+**PITR flow (pgBackRest in sidecar):**
475+```
476+1. Sidecar runs pgBackRest configured to connect to the PG container
477+2. pgBackRest manages WAL archiving via archive_command (PG → shared volume → sidecar)
478+3. Scheduled: pgBackRest stanza create + full/incr backups
479+4. Restore: pgBackRest restore --target=<timestamp> --target-action=promote
480+```
481+
482+---
483+
484+## 6. Sidecar Management
485+
486+### `internal/usecase/backup/sidecar.go`
487+
488+```go
489+type SidecarManager struct {
490+ runtime out.ContainerRuntime
491+ strategy strategy.BackupStrategy
492+ log zerowrap.Logger
493+}
494+
495+// EnsureSidecar checks if a backup sidecar exists for the given DB, creates one if not.
496+func (m *SidecarManager) EnsureSidecar(ctx context.Context, db domain.DBInfo, networkName string) (string, error)
497+
498+// RemoveSidecar removes the backup sidecar for a DB.
499+func (m *SidecarManager) RemoveSidecar(ctx context.Context, db domain.DBInfo) error
500+
501+// GetSidecarID returns the container ID of the sidecar for a DB, if it exists.
502+func (m *SidecarManager) GetSidecarID(ctx context.Context, db domain.DBInfo) (string, error)
503+```
504+
505+**Sidecar container spec:**
506+- Name: `gordon-<domain>-backup-<dbname>` (e.g., `gordon-app-mydomain-com-backup-postgres`)
507+- Labels: `gordon.managed=true`, `gordon.backup.sidecar=true`, `gordon.attached-to=<domain>`
508+- Network: same as the DB attachment
509+- Volumes: backup storage volume + optional WAL archive volume
510+- Command: `sleep infinity` (idle — Gordon execs commands into it as needed)
511+- Image: strategy-specific (e.g., `gordon-backup-postgres:latest`)
512+
513+---
514+
515+## 7. Backup Service (Orchestrator)
516+
517+### `internal/usecase/backup/service.go`
518+
519+```go
520+type Service struct {
521+ runtime out.ContainerRuntime
522+ storage out.BackupStorage
523+ detector *Detector
524+ sidecars *SidecarManager
525+ strategies map[domain.DBType]strategy.BackupStrategy
526+ scheduler *cron.Scheduler
527+ config domain.BackupConfig
528+ containerSvc in.ContainerService // To query attachments
529+ log zerowrap.Logger
530+}
531+
532+func NewService(
533+ runtime out.ContainerRuntime,
534+ storage out.BackupStorage,
535+ containerSvc in.ContainerService,
536+ scheduler *cron.Scheduler,
537+ config domain.BackupConfig,
538+ log zerowrap.Logger,
539+) *Service
540+
541+// Start initializes the backup service: detects DBs, deploys sidecars, registers schedules.
542+func (s *Service) Start(ctx context.Context) error
543+
544+// Stop cleans up (but does NOT remove sidecars — they persist).
545+func (s *Service) Stop()
546+
547+// RunBackup triggers an immediate backup.
548+func (s *Service) RunBackup(ctx context.Context, domain, dbName string) (*domain.BackupResult, error)
549+
550+// RunScheduled is called by the cron scheduler.
551+func (s *Service) RunScheduled(ctx context.Context, schedule domain.BackupSchedule) error
552+
553+// ListBackups returns stored backups.
554+func (s *Service) ListBackups(ctx context.Context, domain string) ([]domain.BackupJob, error)
555+
556+// Restore restores from a specific backup.
557+func (s *Service) Restore(ctx context.Context, domain, backupID string) error
558+
559+// RestorePITR restores to a point in time.
560+func (s *Service) RestorePITR(ctx context.Context, domain string, targetTime time.Time) error
561+
562+// DetectDatabases scans attachments and returns detected DB info.
563+func (s *Service) DetectDatabases(ctx context.Context, domain string) ([]domain.DBInfo, error)
564+
565+// Status returns backup health summary.
566+func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error)
567+```
568+
569+**Startup flow:**
570+```
571+BackupService.Start()
572+ → For each domain with attachments:
573+ → detector.DetectAll(attachments) → []DBInfo
574+ → For each detected DB:
575+ → strategy = strategies[db.Type]
576+ → sidecar.EnsureSidecar(db, networkName) → sidecarContainerID
577+ → If PITR enabled: strategy.SetupPITR(sidecarID, db)
578+ → Register cron jobs:
579+ → scheduler.Add("backup-hourly", hourly, RunScheduled(hourly))
580+ → scheduler.Add("backup-daily", daily, RunScheduled(daily))
581+ → scheduler.Add("backup-weekly", weekly, RunScheduled(weekly))
582+ → scheduler.Add("backup-monthly", monthly, RunScheduled(monthly))
583+ → scheduler.Add("backup-retention", daily, ApplyRetention())
584+```
585+
586+---
587+
588+## 8. Configuration (TOML)
589+
590+```toml
591+[backups]
592+enabled = true
593+storage_dir = "/var/lib/gordon/backups"
594+
595+[backups.retention]
596+hourly = 24 # keep 24 hourly backups
597+daily = 7 # keep 7 daily backups
598+weekly = 4 # keep 4 weekly backups
599+monthly = 12 # keep 12 monthly backups
600+
601+[backups.pitr]
602+enabled = false # opt-in globally
603+wal_storage_dir = "/var/lib/gordon/wal-archive"
604+retention = "7d" # keep 7 days of WAL archives
605+
606+# Per-attachment overrides
607+[backups.overrides."app.example.com/postgres"]
608+schedules = ["daily", "weekly"] # only daily & weekly, no hourly
609+pitr = true # enable PITR for this one
610+
611+[backups.overrides."api.example.com/postgres"]
612+schedules = ["hourly", "daily", "weekly", "monthly"]
613+retention.daily = 14 # override: keep 14 daily
614+```
615+
616+---
617+
618+## 9. CLI Commands
619+
620+### `internal/adapters/in/cli/backup.go`
621+
622+```bash
623+# List backups
624+gordon backup list # all domains
625+gordon backup list app.example.com # specific domain
626+gordon backup list app.example.com --schedule daily # filter by schedule
627+
628+# Run manual backup
629+gordon backup run app.example.com # auto-detect DB, backup now
630+gordon backup run app.example.com --db postgres # specific DB if multiple
631+
632+# Restore
633+gordon backup restore app.example.com <backup-id>
634+gordon backup restore app.example.com --pitr "2025-01-15T14:30:00Z"
635+
636+# Schedule info
637+gordon backup schedule list # show all active schedules
638+gordon backup schedule list app.example.com # for a domain
639+
640+# Health/status
641+gordon backup status # last success/failure per domain
642+
643+# DB detection (debug/info)
644+gordon backup detect # show all detected DBs
645+gordon backup detect app.example.com # for a domain
646+
647+# Remote mode support
648+gordon backup list --remote https://gordon.example.com --token $TOKEN
649+```
650+
651+---
652+
653+## 10. Admin API Endpoints
654+
655+### Additions to `internal/adapters/in/http/admin/handler.go`
656+
657+```
658+GET /admin/backups # List all backups
659+GET /admin/backups/{domain} # List backups for domain
660+POST /admin/backups/{domain} # Trigger manual backup
661+POST /admin/backups/{domain}/restore/{id} # Restore from backup
662+POST /admin/backups/{domain}/pitr # PITR restore (body: {"target_time": "..."})
663+GET /admin/backups/status # Backup health summary
664+GET /admin/backups/{domain}/detect # Show detected DBs
665+GET /admin/backups/schedules # List active schedules
666+```
667+
668+---
669+
670+## 11. Docker Runtime Additions
671+
672+### `internal/adapters/out/docker/runtime.go`
673+
674+**New method — `ExecInContainer`:**
675+
676+```go
677+func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd []string) (*out.ExecResult, error) {
678+ // 1. ContainerExecCreate — create the exec instance
679+ // 2. ContainerExecAttach — attach to get stdout/stderr
680+ // 3. ContainerExecInspect — get exit code
681+ // Uses Docker SDK: client.ContainerExecCreate, ContainerExecAttach, ContainerExecInspect
682+}
683+```
684+
685+**Promote to interface — `CopyFromContainer`:**
686+
687+Already implemented in the adapter (`runtime.go:1110`), but not declared in the
688+`ContainerRuntime` interface. Add it:
689+
690+```go
691+// In boundaries/out/runtime.go:
692+CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error)
693+```
694+
695+---
696+
697+## 12. Tests
698+
699+### Unit Tests (mockery mocks)
700+
701+#### Mockery config additions (`.mockery.yaml`)
702+
703+```yaml
704+# Add to existing packages:
705+github.com/bnema/gordon/internal/boundaries/out:
706+ interfaces:
707+ # ... existing ...
708+ BackupStorage: # NEW
709+
710+github.com/bnema/gordon/internal/boundaries/in:
711+ interfaces:
712+ # ... existing ...
713+ BackupService: # NEW
714+```
715+
716+#### Test files
717+
718+| File | Tests |
719+|------|-------|
720+| `usecase/cron/scheduler_test.go` | Scheduler tick, add/remove jobs, concurrent safety, manual trigger, preset schedules |
721+| `usecase/backup/detector_test.go` | Image name parsing (postgres:17, pgvector/pgvector:pg17, custom), env var detection, port fallback, label override, unknown images |
722+| `usecase/backup/service_test.go` | RunBackup flow (mock runtime + storage), RunScheduled, retention cleanup, error handling, PITR setup |
723+| `usecase/backup/sidecar_test.go` | EnsureSidecar create/reuse, RemoveSidecar, sidecar container config validation |
724+| `usecase/backup/strategy/postgres_test.go` | Dump command construction, restore command, PITR setup commands, version handling |
725+| `adapters/out/docker/runtime_exec_test.go` | ExecInContainer (requires Docker or mock client) |
726+| `adapters/out/filesystem/backup_storage_test.go` | Store, List, Get, Delete, ApplyRetention (uses temp dirs) |
727+
728+#### Mock usage pattern
729+
730+```go
731+// Example: service_test.go
732+func TestRunBackup_PostgreSQL(t *testing.T) {
733+ mockRuntime := mocks.NewMockContainerRuntime(t)
734+ mockStorage := mocks.NewMockBackupStorage(t)
735+ mockContainerSvc := mocks.NewMockContainerService(t)
736+
737+ // Setup expectations
738+ mockContainerSvc.EXPECT().
739+ ListAttachments(mock.Anything, "app.example.com").
740+ Return([]domain.Attachment{
741+ {Name: "postgres", Image: "postgres:17", ContainerID: "abc123", Status: "running"},
742+ }, nil)
743+
744+ mockRuntime.EXPECT().
745+ InspectImageEnv(mock.Anything, "postgres:17").
746+ Return([]string{"POSTGRES_DB=mydb", "POSTGRES_USER=app", "PGDATA=/var/lib/postgresql/data"}, nil)
747+
748+ mockRuntime.EXPECT().
749+ ExecInContainer(mock.Anything, "sidecar-id", mock.MatchedBy(func(cmd []string) bool {
750+ return cmd[0] == "pg_dump"
751+ })).
752+ Return(&out.ExecResult{ExitCode: 0, Stdout: []byte("dump data")}, nil)
753+
754+ mockStorage.EXPECT().
755+ Store(mock.Anything, "app.example.com", "postgres", mock.Anything, mock.Anything, mock.Anything).
756+ Return("/backups/app.example.com/postgres/2025-01-15T10-00-00.pgfc", nil)
757+
758+ // Create service and run
759+ svc := backup.NewService(mockRuntime, mockStorage, mockContainerSvc, scheduler, config, log)
760+ result, err := svc.RunBackup(ctx, "app.example.com", "postgres")
761+
762+ assert.NoError(t, err)
763+ assert.Equal(t, domain.BackupStatusCompleted, result.Job.Status)
764+}
765+```
766+
767+### Integration Tests (local dev)
768+
769+#### `internal/testutils/fixtures/docker/docker-compose.test.yml`
770+
771+A docker-compose file for spinning up a real PostgreSQL + sidecar locally to test
772+the full backup/restore flow end-to-end.
773+
774+```yaml
775+services:
776+ postgres:
777+ image: postgres:17-alpine
778+ environment:
779+ POSTGRES_DB: testdb
780+ POSTGRES_USER: testuser
781+ POSTGRES_PASSWORD: testpass
782+ ports:
783+ - "15432:5432"
784+ volumes:
785+ - pgdata:/var/lib/postgresql/data
786+ networks:
787+ - gordon-test
788+
789+ backup-sidecar:
790+ image: postgres:17-alpine # or gordon-backup-postgres:latest once built
791+ command: ["sleep", "infinity"]
792+ environment:
793+ PGHOST: postgres
794+ PGUSER: testuser
795+ PGPASSWORD: testpass
796+ PGDATABASE: testdb
797+ volumes:
798+ - backups:/backups
799+ depends_on:
800+ - postgres
801+ networks:
802+ - gordon-test
803+
804+volumes:
805+ pgdata:
806+ backups:
807+
808+networks:
809+ gordon-test:
810+ driver: bridge
811+```
812+
813+#### `internal/usecase/backup/integration_test.go`
814+
815+```go
816+//go:build integration
817+
818+package backup_test
819+
820+// Tests tagged with `integration` build tag.
821+// Run with: go test -tags=integration -v ./internal/usecase/backup/
822+//
823+// Prerequisites:
824+// docker compose -f internal/testutils/fixtures/docker/docker-compose.test.yml up -d
825+//
826+// Test cases:
827+// - TestIntegration_DetectPostgres: detect PG from running container
828+// - TestIntegration_DumpAndRestore: full pg_dump → restore cycle
829+// - TestIntegration_PITRSetup: configure WAL archiving, verify segments
830+// - TestIntegration_RetentionCleanup: create N backups, verify retention
831+// - TestIntegration_SidecarLifecycle: deploy sidecar, exec, remove
832+```
833+
834+#### Makefile additions
835+
836+```makefile
837+# Integration test targets
838+test-integration-backup:
839+ docker compose -f internal/testutils/fixtures/docker/docker-compose.test.yml up -d --wait
840+ go test -tags=integration -v -count=1 ./internal/usecase/backup/...
841+ docker compose -f internal/testutils/fixtures/docker/docker-compose.test.yml down -v
842+
843+test-integration-backup-keep:
844+ docker compose -f internal/testutils/fixtures/docker/docker-compose.test.yml up -d --wait
845+ go test -tags=integration -v -count=1 ./internal/usecase/backup/... || true
846+ @echo "Containers still running — use 'make test-integration-backup-down' to clean up"
847+
848+test-integration-backup-down:
849+ docker compose -f internal/testutils/fixtures/docker/docker-compose.test.yml down -v
850+```
851+
852+---
853+
854+## 13. Documentation Updates
855+
856+### New docs
857+
858+| File | Content |
859+|------|---------|
860+| `docs/config/backups.md` | Full backup configuration reference (TOML options, retention, PITR, overrides) |
861+| `docs/cli/backup.md` | CLI command reference (list, run, restore, schedule, status, detect) |
862+
863+### Updated docs
864+
865+| File | Changes |
866+|------|---------|
867+| `docs/config/index.md` | Add "Backups" to config section list |
868+| `docs/config/attachments.md` | Add "Automatic Backups" section — link to backups.md, mention auto-detection |
869+| `docs/cli/index.md` | Add `gordon backup` to CLI commands list |
870+| `docs/reference/docker-labels.md` | Add backup labels table (`gordon.backup`, `gordon.backup.type`, etc.) |
871+| `docs/concepts.md` | Add "Backups & Recovery" to concepts overview |
872+| `docs/reference/index.md` | Add link to backup-related reference |
873+
874+---
875+
876+## 14. Dependency Injection Wiring
877+
878+### `internal/app/run.go` additions
879+
880+```go
881+// In createServices():
882+func createBackupService(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) (*backup.Service, error) {
883+ // 1. Parse backup config from viper
884+ // 2. Create BackupStorage (filesystem adapter)
885+ // 3. Create Scheduler
886+ // 4. Create Detector
887+ // 5. Register strategies (PostgresStrategy for now)
888+ // 6. Create BackupService
889+ // 7. If backups enabled: call backupSvc.Start()
890+ return svc, nil
891+}
892+
893+// Register in services struct:
894+type services struct {
895+ // ... existing fields ...
896+ backupSvc *backup.Service
897+ scheduler *cron.Scheduler
898+}
899+```
900+
901+---
902+
903+## 15. Events
904+
905+### New event types in `internal/domain/event.go`
906+
907+```go
908+const (
909+ EventBackupStarted EventType = "backup.started"
910+ EventBackupCompleted EventType = "backup.completed"
911+ EventBackupFailed EventType = "backup.failed"
912+ EventRestoreStarted EventType = "restore.started"
913+ EventRestoreCompleted EventType = "restore.completed"
914+)
915+```
916+
917+These integrate with the existing EventBus so that:
918+- Config reload re-scans attachments and adjusts backup schedules
919+- Container deploy events trigger sidecar deployment for new DBs
920+- Backup events can be consumed by future notification systems (webhook, etc.)
921+
922+---
923+
924+## 16. Implementation Order
925+
926+| Phase | Task | Depends On |
927+|-------|------|------------|
928+| **1** | `ExecInContainer` in runtime interface + Docker adapter | — |
929+| **1** | Promote `CopyFromContainer` to interface | — |
930+| **1** | Regenerate mocks (`make mocks`) | Phase 1 interface changes |
931+| **2** | Domain models (`backup.go`, `cron.go`) | — |
932+| **2** | `BackupStorage` interface + filesystem adapter | Domain models |
933+| **2** | `BackupStorage` adapter tests | Adapter |
934+| **3** | Cron scheduler + tests | — |
935+| **4** | DB detector + tests | Domain models, runtime interface |
936+| **5** | `BackupStrategy` interface + PostgreSQL strategy + tests | Runtime, domain |
937+| **6** | Sidecar manager + tests | Runtime, strategy |
938+| **7** | Backup service (orchestrator) + tests | All above |
939+| **8** | CLI commands (`gordon backup ...`) | Backup service |
940+| **8** | Admin API endpoints | Backup service |
941+| **9** | Config TOML integration (`[backups]` section) | Config service |
942+| **9** | DI wiring in `app/run.go` | All above |
943+| **10** | `.mockery.yaml` update + regenerate | New interfaces |
944+| **10** | Integration test infrastructure (docker-compose, Makefile targets) | All above |
945+| **10** | Integration tests | Infrastructure |
946+| **11** | Documentation (new + updates) | Feature complete |
947+| **12** | PITR implementation (pgBackRest in sidecar) | Logical backups working |
948+
949+---
950+
951+## 17. Sidecar Docker Image (future)
952+
953+The backup sidecar needs a dedicated image. For MVP, we can use the official
954+`postgres:<version>-alpine` image which includes `pg_dump`, `pg_restore`, `pg_basebackup`.
955+
956+For PITR (Phase 12), we'll need a custom image with pgBackRest:
957+
958+```dockerfile
959+# ghcr.io/bnema/gordon-backup-postgres
960+ARG PG_VERSION=17
961+FROM postgres:${PG_VERSION}-alpine
962+
963+RUN apk add --no-cache pgbackrest
964+
965+# Backup scripts (optional helpers)
966+COPY scripts/ /usr/local/bin/
967+
968+CMD ["sleep", "infinity"]
969+```
970+
971+Multi-version support: build matrix for PG 17 and 18 tags.
972+
973+---
974+
975+## Open Items (for later discussion)
976+
977+- **Remote storage (S3, etc.)**: The `BackupStorage` interface is ready for it —
978+ just add a new adapter. Not needed for MVP.
979+- **Compression**: `pg_dump -Fc` already uses zlib compression. For physical backups,
980+ consider zstd (configurable).
981+- **Notifications**: Events are in place (`EventBackupFailed`). Webhook adapter is a
982+ natural next step.
983+- **Backup encryption**: Encrypt at rest with a configurable key. Add to BackupStorage
984+ as a wrapping layer.
985+- **Concurrent backups**: The scheduler should NOT run the same domain's backup
986+ concurrently. Use per-domain locks (same pattern as deploy locks).

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close