detached b2d1306

chore(docs): remove migrated backup cron plan

commit
b2d130698f19af7acca5749febd5fcc9a48cca58
author
brice · · 2026-02-07 12:24 UTC
committer
brice · · 2026-02-07 14:40 UTC
parent
634f497
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +0 -986
D docs/backup-cron-implementation-plan.md +0 -986
@@ -1,986 +0,0 @@
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).
@@ -1,986 +0,0 @@
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