detached 679c7dc

fix(backup): address review hardening and edge cases

Apply CodeRabbit follow-ups across backup, scheduler, runtime, and docs: stream copy-from-container reads, tighten path and file handling, add timeout/cancellation protections, improve detection/version tests, and expand admin backup endpoint coverage. Clarify retention and timezone notes in docs while keeping final checks green.
commit
679c7dc1da77a760e475a9165c4e04f57430c3e8
author
brice · · 2026-02-07 13:00 UTC
committer
brice · · 2026-02-07 14:40 UTC
parent
b01432f
signature
unsigned
23 files · +584 -138
M docs/config/backups.md +3 -2
@@ -46,13 +46,13 @@ monthly = 12
4646 Backups are stored under:
4747
4848 ```text
49-<storage_dir>/backups/<domain>/<db>/<schedule>/<timestamp>.bak
49+<storage_dir>/<domain>/<db>/<schedule>/<timestamp>.bak
5050 ```
5151
5252 Example:
5353
5454 ```text
55-~/.gordon/backups/backups/app.example.com/postgres/daily/20260207T110000Z.bak
55+~/.gordon/backups/app.example.com/postgres/daily/20260207T110000Z.bak
5656 ```
5757
5858 ## Notes
@@ -60,6 +60,7 @@ Example:
6060 - Backups require PostgreSQL attachment containers to be running.
6161 - The backup command executes inside the database container using Gordon runtime APIs.
6262 - Retention applies per database and per schedule.
63+- `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`.
6364
6465 ## Related
6566
@@ -46,13 +46,13 @@ monthly = 12
46 Backups are stored under:46 Backups are stored under:
47 47
48 ```text48 ```text
49-<storage_dir>/backups/<domain>/<db>/<schedule>/<timestamp>.bak49+<storage_dir>/<domain>/<db>/<schedule>/<timestamp>.bak
50 ```50 ```
51 51
52 Example:52 Example:
53 53
54 ```text54 ```text
55-~/.gordon/backups/backups/app.example.com/postgres/daily/20260207T110000Z.bak55+~/.gordon/backups/app.example.com/postgres/daily/20260207T110000Z.bak
56 ```56 ```
57 57
58 ## Notes58 ## Notes
@@ -60,6 +60,7 @@ Example:
60 - Backups require PostgreSQL attachment containers to be running.60 - Backups require PostgreSQL attachment containers to be running.
61 - The backup command executes inside the database container using Gordon runtime APIs.61 - The backup command executes inside the database container using Gordon runtime APIs.
62 - Retention applies per database and per schedule.62 - Retention applies per database and per schedule.
63+- `backups.retention.hourly`, `daily`, `weekly`, and `monthly` set to `0` means no backups are retained for that tier (new backups are immediately eligible for cleanup). For practical defaults, set at least `daily = 7`.
63 64
64 ## Related65 ## Related
65 66
M docs/config/reference.md +9 -4
@@ -134,6 +134,9 @@ hourly = 0 # Keep N hourly backups per DB
134134 daily = 0 # Keep N daily backups per DB
135135 weekly = 0 # Keep N weekly backups per DB
136136 monthly = 0 # Keep N monthly backups per DB
137+
138+# Note: retention values set to 0 keep no backups for that tier.
139+# For practical defaults, consider setting daily = 7.
137140 ```
138141
139142 ## Default Values Summary
@@ -179,10 +182,12 @@ monthly = 0 # Keep N monthly backups per DB
179182 | `volumes.preserve` | `true` | Keep volumes |
180183 | `backups.enabled` | `false` | Backup service disabled |
181184 | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
182-| `backups.retention.hourly` | `0` | Keep 0 hourly backups by default |
183-| `backups.retention.daily` | `0` | Keep 0 daily backups by default |
184-| `backups.retention.weekly` | `0` | Keep 0 weekly backups by default |
185-| `backups.retention.monthly` | `0` | Keep 0 monthly backups by default |
185+| `backups.retention.hourly` | `0` | Keep no hourly backups by default |
186+| `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
187+| `backups.retention.weekly` | `0` | Keep no weekly backups by default |
188+| `backups.retention.monthly` | `0` | Keep no monthly backups by default |
189+
190+Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.
186191
187192 ## Environment Variables
188193
@@ -134,6 +134,9 @@ hourly = 0 # Keep N hourly backups per DB
134 daily = 0 # Keep N daily backups per DB134 daily = 0 # Keep N daily backups per DB
135 weekly = 0 # Keep N weekly backups per DB135 weekly = 0 # Keep N weekly backups per DB
136 monthly = 0 # Keep N monthly backups per DB136 monthly = 0 # Keep N monthly backups per DB
137+
138+# Note: retention values set to 0 keep no backups for that tier.
139+# For practical defaults, consider setting daily = 7.
137 ```140 ```
138 141
139 ## Default Values Summary142 ## Default Values Summary
@@ -179,10 +182,12 @@ monthly = 0 # Keep N monthly backups per DB
179 | `volumes.preserve` | `true` | Keep volumes |182 | `volumes.preserve` | `true` | Keep volumes |
180 | `backups.enabled` | `false` | Backup service disabled |183 | `backups.enabled` | `false` | Backup service disabled |
181 | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |184 | `backups.storage_dir` | `""` | Uses `{server.data_dir}/backups` when empty |
182-| `backups.retention.hourly` | `0` | Keep 0 hourly backups by default |185+| `backups.retention.hourly` | `0` | Keep no hourly backups by default |
183-| `backups.retention.daily` | `0` | Keep 0 daily backups by default |186+| `backups.retention.daily` | `0` | Keep no daily backups by default (recommend `7`) |
184-| `backups.retention.weekly` | `0` | Keep 0 weekly backups by default |187+| `backups.retention.weekly` | `0` | Keep no weekly backups by default |
185-| `backups.retention.monthly` | `0` | Keep 0 monthly backups by default |188+| `backups.retention.monthly` | `0` | Keep no monthly backups by default |
189+
190+Note: for all `backups.retention.*` keys, `0` means keep no backups for that retention tier.
186 191
187 ## Environment Variables192 ## Environment Variables
188 193
M internal/adapters/dto/admin_backups.go +11 -11
@@ -4,17 +4,17 @@ import "time"
44
55 // BackupJob represents backup metadata in admin API responses.
66 type BackupJob struct {
7- ID string `json:"id"`
8- Domain string `json:"domain"`
9- DBName string `json:"db_name"`
10- Schedule string `json:"schedule,omitempty"`
11- Type string `json:"type"`
12- Status string `json:"status"`
13- StartedAt time.Time `json:"started_at"`
14- CompletedAt time.Time `json:"completed_at,omitempty"`
15- SizeBytes int64 `json:"size_bytes"`
16- FilePath string `json:"file_path"`
17- Error string `json:"error,omitempty"`
7+ ID string `json:"id"`
8+ Domain string `json:"domain"`
9+ DBName string `json:"db_name"`
10+ Schedule string `json:"schedule,omitempty"`
11+ Type string `json:"type"`
12+ Status string `json:"status"`
13+ StartedAt *time.Time `json:"started_at,omitempty"`
14+ CompletedAt *time.Time `json:"completed_at,omitempty"`
15+ SizeBytes int64 `json:"size_bytes"`
16+ FilePath string `json:"file_path"`
17+ Error string `json:"error,omitempty"`
1818 }
1919
2020 // BackupsResponse is returned by backup listing endpoints.
@@ -4,17 +4,17 @@ import "time"
4 4
5 // BackupJob represents backup metadata in admin API responses.5 // BackupJob represents backup metadata in admin API responses.
6 type BackupJob struct {6 type BackupJob struct {
7- ID string `json:"id"`7+ ID string `json:"id"`
8- Domain string `json:"domain"`8+ Domain string `json:"domain"`
9- DBName string `json:"db_name"`9+ DBName string `json:"db_name"`
10- Schedule string `json:"schedule,omitempty"`10+ Schedule string `json:"schedule,omitempty"`
11- Type string `json:"type"`11+ Type string `json:"type"`
12- Status string `json:"status"`12+ Status string `json:"status"`
13- StartedAt time.Time `json:"started_at"`13+ StartedAt *time.Time `json:"started_at,omitempty"`
14- CompletedAt time.Time `json:"completed_at,omitempty"`14+ CompletedAt *time.Time `json:"completed_at,omitempty"`
15- SizeBytes int64 `json:"size_bytes"`15+ SizeBytes int64 `json:"size_bytes"`
16- FilePath string `json:"file_path"`16+ FilePath string `json:"file_path"`
17- Error string `json:"error,omitempty"`17+ Error string `json:"error,omitempty"`
18 }18 }
19 19
20 // BackupsResponse is returned by backup listing endpoints.20 // BackupsResponse is returned by backup listing endpoints.
M internal/adapters/in/cli/backup.go +20 -9
@@ -3,6 +3,7 @@ package cli
33 import (
44 "context"
55 "fmt"
6+ "time"
67
78 "github.com/spf13/cobra"
89 )
@@ -14,8 +15,7 @@ func newBackupCmd() *cobra.Command {
1415 Short: "Manage database backups",
1516 Long: `Manage database backups through Gordon's admin API.
1617
17-These commands currently require remote mode with --remote/--token
18-or configured remotes.`,
18+These commands currently require remote mode with a configured target.`,
1919 }
2020
2121 cmd.AddCommand(newBackupListCmd())
@@ -34,7 +34,7 @@ func newBackupListCmd() *cobra.Command {
3434 RunE: func(cmd *cobra.Command, args []string) error {
3535 client, isRemote := GetRemoteClient()
3636 if !isRemote {
37- return fmt.Errorf("backup commands currently require --remote and --token")
37+ return fmt.Errorf("backup commands require a configured remote target")
3838 }
3939
4040 domainName := ""
@@ -51,9 +51,10 @@ func newBackupListCmd() *cobra.Command {
5151 fmt.Println("No backups found")
5252 return nil
5353 }
54+ fmt.Println("DOMAIN\tDB\tSTATUS\tSTARTED_AT\tFILE_PATH")
5455
5556 for _, job := range jobs {
56- fmt.Printf("%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, job.StartedAt.Format("2006-01-02T15:04:05Z"), job.FilePath)
57+ fmt.Printf("%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.FilePath)
5758 }
5859
5960 return nil
@@ -71,7 +72,7 @@ func newBackupRunCmd() *cobra.Command {
7172 RunE: func(cmd *cobra.Command, args []string) error {
7273 client, isRemote := GetRemoteClient()
7374 if !isRemote {
74- return fmt.Errorf("backup commands currently require --remote and --token")
75+ return fmt.Errorf("backup commands require a configured remote target")
7576 }
7677
7778 result, err := client.RunBackup(context.Background(), args[0], dbName)
@@ -79,7 +80,8 @@ func newBackupRunCmd() *cobra.Command {
7980 return fmt.Errorf("failed to run backup: %w", err)
8081 }
8182
82- fmt.Printf("Backup completed: domain=%s db=%s path=%s size=%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.FilePath, result.Backup.SizeBytes)
83+ fmt.Println("DOMAIN\tDB\tSTATUS\tSTARTED_AT\tFILE_PATH\tSIZE_BYTES")
84+ fmt.Printf("%s\t%s\t%s\t%s\t%s\t%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.FilePath, result.Backup.SizeBytes)
8385 return nil
8486 },
8587 }
@@ -96,7 +98,7 @@ func newBackupDetectCmd() *cobra.Command {
9698 RunE: func(cmd *cobra.Command, args []string) error {
9799 client, isRemote := GetRemoteClient()
98100 if !isRemote {
99- return fmt.Errorf("backup commands currently require --remote and --token")
101+ return fmt.Errorf("backup commands require a configured remote target")
100102 }
101103
102104 dbs, err := client.DetectDatabases(context.Background(), args[0])
@@ -108,6 +110,7 @@ func newBackupDetectCmd() *cobra.Command {
108110 fmt.Println("No supported databases detected")
109111 return nil
110112 }
113+ fmt.Println("NAME\tTYPE\tHOST\tPORT\tIMAGE")
111114
112115 for _, db := range dbs {
113116 fmt.Printf("%s\t%s\t%s\t%d\t%s\n", db.Name, db.Type, db.Host, db.Port, db.ImageName)
@@ -125,7 +128,7 @@ func newBackupStatusCmd() *cobra.Command {
125128 RunE: func(cmd *cobra.Command, args []string) error {
126129 client, isRemote := GetRemoteClient()
127130 if !isRemote {
128- return fmt.Errorf("backup commands currently require --remote and --token")
131+ return fmt.Errorf("backup commands require a configured remote target")
129132 }
130133
131134 jobs, err := client.BackupStatus(context.Background())
@@ -137,12 +140,20 @@ func newBackupStatusCmd() *cobra.Command {
137140 fmt.Println("No backup status available")
138141 return nil
139142 }
143+ fmt.Println("DOMAIN\tDB\tSTATUS\tSTARTED_AT")
140144
141145 for _, job := range jobs {
142- fmt.Printf("%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, job.StartedAt.Format("2006-01-02T15:04:05Z"))
146+ fmt.Printf("%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt))
143147 }
144148
145149 return nil
146150 },
147151 }
148152 }
153+
154+func formatBackupTime(t *time.Time) string {
155+ if t == nil {
156+ return ""
157+ }
158+ return t.UTC().Format("2006-01-02T15:04:05Z")
159+}
@@ -3,6 +3,7 @@ package cli
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "time"
6 7
7 "github.com/spf13/cobra"8 "github.com/spf13/cobra"
8 )9 )
@@ -14,8 +15,7 @@ func newBackupCmd() *cobra.Command {
14 Short: "Manage database backups",15 Short: "Manage database backups",
15 Long: `Manage database backups through Gordon's admin API.16 Long: `Manage database backups through Gordon's admin API.
16 17
17-These commands currently require remote mode with --remote/--token18+These commands currently require remote mode with a configured target.`,
18-or configured remotes.`,
19 }19 }
20 20
21 cmd.AddCommand(newBackupListCmd())21 cmd.AddCommand(newBackupListCmd())
@@ -34,7 +34,7 @@ func newBackupListCmd() *cobra.Command {
34 RunE: func(cmd *cobra.Command, args []string) error {34 RunE: func(cmd *cobra.Command, args []string) error {
35 client, isRemote := GetRemoteClient()35 client, isRemote := GetRemoteClient()
36 if !isRemote {36 if !isRemote {
37- return fmt.Errorf("backup commands currently require --remote and --token")37+ return fmt.Errorf("backup commands require a configured remote target")
38 }38 }
39 39
40 domainName := ""40 domainName := ""
@@ -51,9 +51,10 @@ func newBackupListCmd() *cobra.Command {
51 fmt.Println("No backups found")51 fmt.Println("No backups found")
52 return nil52 return nil
53 }53 }
54+ fmt.Println("DOMAIN\tDB\tSTATUS\tSTARTED_AT\tFILE_PATH")
54 55
55 for _, job := range jobs {56 for _, job := range jobs {
56- fmt.Printf("%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, job.StartedAt.Format("2006-01-02T15:04:05Z"), job.FilePath)57+ fmt.Printf("%s\t%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt), job.FilePath)
57 }58 }
58 59
59 return nil60 return nil
@@ -71,7 +72,7 @@ func newBackupRunCmd() *cobra.Command {
71 RunE: func(cmd *cobra.Command, args []string) error {72 RunE: func(cmd *cobra.Command, args []string) error {
72 client, isRemote := GetRemoteClient()73 client, isRemote := GetRemoteClient()
73 if !isRemote {74 if !isRemote {
74- return fmt.Errorf("backup commands currently require --remote and --token")75+ return fmt.Errorf("backup commands require a configured remote target")
75 }76 }
76 77
77 result, err := client.RunBackup(context.Background(), args[0], dbName)78 result, err := client.RunBackup(context.Background(), args[0], dbName)
@@ -79,7 +80,8 @@ func newBackupRunCmd() *cobra.Command {
79 return fmt.Errorf("failed to run backup: %w", err)80 return fmt.Errorf("failed to run backup: %w", err)
80 }81 }
81 82
82- fmt.Printf("Backup completed: domain=%s db=%s path=%s size=%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.FilePath, result.Backup.SizeBytes)83+ fmt.Println("DOMAIN\tDB\tSTATUS\tSTARTED_AT\tFILE_PATH\tSIZE_BYTES")
84+ fmt.Printf("%s\t%s\t%s\t%s\t%s\t%d\n", result.Backup.Domain, result.Backup.DBName, result.Backup.Status, formatBackupTime(result.Backup.StartedAt), result.Backup.FilePath, result.Backup.SizeBytes)
83 return nil85 return nil
84 },86 },
85 }87 }
@@ -96,7 +98,7 @@ func newBackupDetectCmd() *cobra.Command {
96 RunE: func(cmd *cobra.Command, args []string) error {98 RunE: func(cmd *cobra.Command, args []string) error {
97 client, isRemote := GetRemoteClient()99 client, isRemote := GetRemoteClient()
98 if !isRemote {100 if !isRemote {
99- return fmt.Errorf("backup commands currently require --remote and --token")101+ return fmt.Errorf("backup commands require a configured remote target")
100 }102 }
101 103
102 dbs, err := client.DetectDatabases(context.Background(), args[0])104 dbs, err := client.DetectDatabases(context.Background(), args[0])
@@ -108,6 +110,7 @@ func newBackupDetectCmd() *cobra.Command {
108 fmt.Println("No supported databases detected")110 fmt.Println("No supported databases detected")
109 return nil111 return nil
110 }112 }
113+ fmt.Println("NAME\tTYPE\tHOST\tPORT\tIMAGE")
111 114
112 for _, db := range dbs {115 for _, db := range dbs {
113 fmt.Printf("%s\t%s\t%s\t%d\t%s\n", db.Name, db.Type, db.Host, db.Port, db.ImageName)116 fmt.Printf("%s\t%s\t%s\t%d\t%s\n", db.Name, db.Type, db.Host, db.Port, db.ImageName)
@@ -125,7 +128,7 @@ func newBackupStatusCmd() *cobra.Command {
125 RunE: func(cmd *cobra.Command, args []string) error {128 RunE: func(cmd *cobra.Command, args []string) error {
126 client, isRemote := GetRemoteClient()129 client, isRemote := GetRemoteClient()
127 if !isRemote {130 if !isRemote {
128- return fmt.Errorf("backup commands currently require --remote and --token")131+ return fmt.Errorf("backup commands require a configured remote target")
129 }132 }
130 133
131 jobs, err := client.BackupStatus(context.Background())134 jobs, err := client.BackupStatus(context.Background())
@@ -137,12 +140,20 @@ func newBackupStatusCmd() *cobra.Command {
137 fmt.Println("No backup status available")140 fmt.Println("No backup status available")
138 return nil141 return nil
139 }142 }
143+ fmt.Println("DOMAIN\tDB\tSTATUS\tSTARTED_AT")
140 144
141 for _, job := range jobs {145 for _, job := range jobs {
142- fmt.Printf("%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, job.StartedAt.Format("2006-01-02T15:04:05Z"))146+ fmt.Printf("%s\t%s\t%s\t%s\n", job.Domain, job.DBName, job.Status, formatBackupTime(job.StartedAt))
143 }147 }
144 148
145 return nil149 return nil
146 },150 },
147 }151 }
148 }152 }
153+
154+func formatBackupTime(t *time.Time) string {
155+ if t == nil {
156+ return ""
157+ }
158+ return t.UTC().Format("2006-01-02T15:04:05Z")
159+}
M internal/adapters/in/http/admin/handler.go +21 -10
@@ -9,6 +9,7 @@ import (
99 "net/http"
1010 "strconv"
1111 "strings"
12+ "time"
1213
1314 "github.com/bnema/zerowrap"
1415
@@ -82,6 +83,17 @@ func toRouteResponse(r domain.Route) dto.Route {
8283 }
8384
8485 func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
86+ var startedAt *time.Time
87+ if !job.StartedAt.IsZero() {
88+ t := job.StartedAt
89+ startedAt = &t
90+ }
91+ var completedAt *time.Time
92+ if !job.CompletedAt.IsZero() {
93+ t := job.CompletedAt
94+ completedAt = &t
95+ }
96+
8597 return dto.BackupJob{
8698 ID: job.ID,
8799 Domain: job.Domain,
@@ -89,8 +101,8 @@ func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
89101 Schedule: string(job.Schedule),
90102 Type: string(job.Type),
91103 Status: string(job.Status),
92- StartedAt: job.StartedAt,
93- CompletedAt: job.CompletedAt,
104+ StartedAt: startedAt,
105+ CompletedAt: completedAt,
94106 SizeBytes: job.SizeBytes,
95107 FilePath: job.FilePath,
96108 Error: job.Error,
@@ -120,18 +132,13 @@ func NewHandler(
120132 registrySvc in.RegistryService,
121133 eventBus out.EventPublisher,
122134 log zerowrap.Logger,
123- backupSvc ...in.BackupService,
135+ backupSvc in.BackupService,
124136 ) *Handler {
125- var backup in.BackupService
126- if len(backupSvc) > 0 {
127- backup = backupSvc[0]
128- }
129-
130137 return &Handler{
131138 configSvc: configSvc,
132139 authSvc: authSvc,
133140 containerSvc: containerSvc,
134- backupSvc: backup,
141+ backupSvc: backupSvc,
135142 healthSvc: healthSvc,
136143 secretSvc: secretSvc,
137144 logSvc: logSvc,
@@ -853,9 +860,13 @@ func (h *Handler) handleBackupsDomainRun(w http.ResponseWriter, r *http.Request,
853860
854861 result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB)
855862 if err != nil {
856- h.sendError(w, http.StatusInternalServerError, err.Error())
863+ log := zerowrap.FromCtx(ctx)
864+ log.Error().Err(err).Str("domain", backupDomain).Msg("backup run failed")
865+ h.sendError(w, http.StatusInternalServerError, "failed to run backup")
857866 return
858867 }
868+ log := zerowrap.FromCtx(ctx)
869+ log.Info().Str("domain", backupDomain).Str("db", req.DB).Str("job_id", result.Job.ID).Msg("backup completed via admin API")
859870
860871 h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{
861872 Status: "completed",
@@ -9,6 +9,7 @@ import (
9 "net/http"9 "net/http"
10 "strconv"10 "strconv"
11 "strings"11 "strings"
12+ "time"
12 13
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 15
@@ -82,6 +83,17 @@ func toRouteResponse(r domain.Route) dto.Route {
82 }83 }
83 84
84 func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {85 func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
86+ var startedAt *time.Time
87+ if !job.StartedAt.IsZero() {
88+ t := job.StartedAt
89+ startedAt = &t
90+ }
91+ var completedAt *time.Time
92+ if !job.CompletedAt.IsZero() {
93+ t := job.CompletedAt
94+ completedAt = &t
95+ }
96+
85 return dto.BackupJob{97 return dto.BackupJob{
86 ID: job.ID,98 ID: job.ID,
87 Domain: job.Domain,99 Domain: job.Domain,
@@ -89,8 +101,8 @@ func toBackupJobResponse(job domain.BackupJob) dto.BackupJob {
89 Schedule: string(job.Schedule),101 Schedule: string(job.Schedule),
90 Type: string(job.Type),102 Type: string(job.Type),
91 Status: string(job.Status),103 Status: string(job.Status),
92- StartedAt: job.StartedAt,104+ StartedAt: startedAt,
93- CompletedAt: job.CompletedAt,105+ CompletedAt: completedAt,
94 SizeBytes: job.SizeBytes,106 SizeBytes: job.SizeBytes,
95 FilePath: job.FilePath,107 FilePath: job.FilePath,
96 Error: job.Error,108 Error: job.Error,
@@ -120,18 +132,13 @@ func NewHandler(
120 registrySvc in.RegistryService,132 registrySvc in.RegistryService,
121 eventBus out.EventPublisher,133 eventBus out.EventPublisher,
122 log zerowrap.Logger,134 log zerowrap.Logger,
123- backupSvc ...in.BackupService,135+ backupSvc in.BackupService,
124 ) *Handler {136 ) *Handler {
125- var backup in.BackupService
126- if len(backupSvc) > 0 {
127- backup = backupSvc[0]
128- }
129-
130 return &Handler{137 return &Handler{
131 configSvc: configSvc,138 configSvc: configSvc,
132 authSvc: authSvc,139 authSvc: authSvc,
133 containerSvc: containerSvc,140 containerSvc: containerSvc,
134- backupSvc: backup,141+ backupSvc: backupSvc,
135 healthSvc: healthSvc,142 healthSvc: healthSvc,
136 secretSvc: secretSvc,143 secretSvc: secretSvc,
137 logSvc: logSvc,144 logSvc: logSvc,
@@ -853,9 +860,13 @@ func (h *Handler) handleBackupsDomainRun(w http.ResponseWriter, r *http.Request,
853 860
854 result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB)861 result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB)
855 if err != nil {862 if err != nil {
856- h.sendError(w, http.StatusInternalServerError, err.Error())863+ log := zerowrap.FromCtx(ctx)
864+ log.Error().Err(err).Str("domain", backupDomain).Msg("backup run failed")
865+ h.sendError(w, http.StatusInternalServerError, "failed to run backup")
857 return866 return
858 }867 }
868+ log := zerowrap.FromCtx(ctx)
869+ log.Info().Str("domain", backupDomain).Str("db", req.DB).Str("job_id", result.Job.ID).Msg("backup completed via admin API")
859 870
860 h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{871 h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{
861 Status: "completed",872 Status: "completed",
M internal/adapters/in/http/admin/handler_test.go +119 -34
@@ -34,7 +34,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
3434 containerSvc := inmocks.NewMockContainerService(t)
3535 secretSvc := inmocks.NewMockSecretService(t)
3636
37- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
37+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
3838
3939 tests := []struct {
4040 name string
@@ -97,7 +97,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
9797 secretSvc := inmocks.NewMockSecretService(t)
9898 registrySvc := inmocks.NewMockRegistryService(t)
9999
100- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
100+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
101101
102102 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
103103
@@ -158,7 +158,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
158158 secretSvc := inmocks.NewMockSecretService(t)
159159 registrySvc := inmocks.NewMockRegistryService(t)
160160
161- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
161+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
162162
163163 routeJSON := `{"image": "myapp:v2"}`
164164
@@ -203,7 +203,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
203203 containerSvc := inmocks.NewMockContainerService(t)
204204 secretSvc := inmocks.NewMockSecretService(t)
205205
206- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
206+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
207207
208208 tests := []struct {
209209 name string
@@ -247,7 +247,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
247247 containerSvc := inmocks.NewMockContainerService(t)
248248 secretSvc := inmocks.NewMockSecretService(t)
249249
250- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
250+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
251251
252252 tests := []struct {
253253 name string
@@ -304,7 +304,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
304304 containerSvc := inmocks.NewMockContainerService(t)
305305 secretSvc := inmocks.NewMockSecretService(t)
306306
307- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
307+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
308308
309309 secretsJSON := `{"API_KEY": "secret123"}`
310310
@@ -353,7 +353,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
353353 containerSvc := inmocks.NewMockContainerService(t)
354354 secretSvc := inmocks.NewMockSecretService(t)
355355
356- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
356+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
357357
358358 tests := []struct {
359359 name string
@@ -397,7 +397,7 @@ func TestHandler_AttachmentSecretsPost(t *testing.T) {
397397 containerSvc := inmocks.NewMockContainerService(t)
398398 secretSvc := inmocks.NewMockSecretService(t)
399399
400- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
400+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
401401
402402 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
403403
@@ -423,7 +423,7 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) {
423423 containerSvc := inmocks.NewMockContainerService(t)
424424 secretSvc := inmocks.NewMockSecretService(t)
425425
426- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
426+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
427427
428428 secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
429429
@@ -450,7 +450,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
450450 containerSvc := inmocks.NewMockContainerService(t)
451451 secretSvc := inmocks.NewMockSecretService(t)
452452
453- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
453+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
454454
455455 tests := []struct {
456456 name string
@@ -504,7 +504,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
504504 containerSvc := inmocks.NewMockContainerService(t)
505505 secretSvc := inmocks.NewMockSecretService(t)
506506
507- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
507+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
508508
509509 tests := []struct {
510510 name string
@@ -561,7 +561,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
561561 containerSvc := inmocks.NewMockContainerService(t)
562562 secretSvc := inmocks.NewMockSecretService(t)
563563
564- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
564+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
565565
566566 tests := []struct {
567567 name string
@@ -615,7 +615,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
615615 containerSvc := inmocks.NewMockContainerService(t)
616616 secretSvc := inmocks.NewMockSecretService(t)
617617
618- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
618+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
619619
620620 expectedRoutes := []domain.Route{
621621 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -650,7 +650,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
650650 containerSvc := inmocks.NewMockContainerService(t)
651651 secretSvc := inmocks.NewMockSecretService(t)
652652
653- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
653+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
654654
655655 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
656656 Domain: "app.example.com", Image: "app:latest",
@@ -681,7 +681,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
681681 containerSvc := inmocks.NewMockContainerService(t)
682682 secretSvc := inmocks.NewMockSecretService(t)
683683
684- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
684+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
685685
686686 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
687687
@@ -725,7 +725,7 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
725725 secretSvc := inmocks.NewMockSecretService(t)
726726 registrySvc := inmocks.NewMockRegistryService(t)
727727
728- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
728+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
729729
730730 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
731731 registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
@@ -748,7 +748,7 @@ func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
748748 secretSvc := inmocks.NewMockSecretService(t)
749749 registrySvc := inmocks.NewMockRegistryService(t)
750750
751- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
751+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
752752
753753 tests := []struct {
754754 name string
@@ -802,7 +802,7 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
802802 containerSvc := inmocks.NewMockContainerService(t)
803803 secretSvc := inmocks.NewMockSecretService(t)
804804
805- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
805+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
806806
807807 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
808808 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -819,7 +819,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
819819 containerSvc := inmocks.NewMockContainerService(t)
820820 secretSvc := inmocks.NewMockSecretService(t)
821821
822- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
822+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
823823
824824 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
825825 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -836,7 +836,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
836836 containerSvc := inmocks.NewMockContainerService(t)
837837 secretSvc := inmocks.NewMockSecretService(t)
838838
839- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
839+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
840840
841841 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
842842 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -853,7 +853,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
853853 containerSvc := inmocks.NewMockContainerService(t)
854854 secretSvc := inmocks.NewMockSecretService(t)
855855
856- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
856+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
857857
858858 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
859859 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -870,7 +870,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
870870 containerSvc := inmocks.NewMockContainerService(t)
871871 secretSvc := inmocks.NewMockSecretService(t)
872872
873- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
873+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
874874
875875 tests := []struct {
876876 method string
@@ -903,7 +903,7 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
903903 containerSvc := inmocks.NewMockContainerService(t)
904904 secretSvc := inmocks.NewMockSecretService(t)
905905
906- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
906+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
907907
908908 tests := []struct {
909909 name string
@@ -933,7 +933,7 @@ func TestHandler_Restart_ContainerNotFound(t *testing.T) {
933933 containerSvc := inmocks.NewMockContainerService(t)
934934 secretSvc := inmocks.NewMockSecretService(t)
935935
936- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
936+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
937937
938938 containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
939939
@@ -952,7 +952,7 @@ func TestHandler_Restart_GenericError(t *testing.T) {
952952 containerSvc := inmocks.NewMockContainerService(t)
953953 secretSvc := inmocks.NewMockSecretService(t)
954954
955- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
955+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
956956
957957 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
958958
@@ -974,7 +974,7 @@ func TestHandler_Restart_Success(t *testing.T) {
974974 containerSvc := inmocks.NewMockContainerService(t)
975975 secretSvc := inmocks.NewMockSecretService(t)
976976
977- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
977+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
978978
979979 tests := []struct {
980980 name string
@@ -1021,7 +1021,7 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) {
10211021 containerSvc := inmocks.NewMockContainerService(t)
10221022 secretSvc := inmocks.NewMockSecretService(t)
10231023
1024- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1024+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
10251025
10261026 tests := []struct {
10271027 name string
@@ -1052,7 +1052,7 @@ func TestHandler_Tags_Success(t *testing.T) {
10521052 secretSvc := inmocks.NewMockSecretService(t)
10531053 registrySvc := inmocks.NewMockRegistryService(t)
10541054
1055- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1055+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
10561056
10571057 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
10581058
@@ -1079,7 +1079,7 @@ func TestHandler_Tags_Error(t *testing.T) {
10791079 secretSvc := inmocks.NewMockSecretService(t)
10801080 registrySvc := inmocks.NewMockRegistryService(t)
10811081
1082- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())
1082+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
10831083
10841084 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
10851085
@@ -1099,7 +1099,7 @@ func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
10991099 containerSvc := inmocks.NewMockContainerService(t)
11001100 secretSvc := inmocks.NewMockSecretService(t)
11011101
1102- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1102+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
11031103
11041104 body := `{"REDIS_URL": "redis://localhost:6379"}`
11051105 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
@@ -1117,7 +1117,7 @@ func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
11171117 containerSvc := inmocks.NewMockContainerService(t)
11181118 secretSvc := inmocks.NewMockSecretService(t)
11191119
1120- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1120+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
11211121
11221122 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
11231123 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -1134,7 +1134,7 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
11341134 containerSvc := inmocks.NewMockContainerService(t)
11351135 secretSvc := inmocks.NewMockSecretService(t)
11361136
1137- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1137+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
11381138
11391139 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
11401140 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -1152,7 +1152,7 @@ func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
11521152 containerSvc := inmocks.NewMockContainerService(t)
11531153 secretSvc := inmocks.NewMockSecretService(t)
11541154
1155- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1155+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
11561156
11571157 body := `{not valid json`
11581158 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
@@ -1171,7 +1171,7 @@ func TestHandler_NotFound(t *testing.T) {
11711171 containerSvc := inmocks.NewMockContainerService(t)
11721172 secretSvc := inmocks.NewMockSecretService(t)
11731173
1174- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())
1174+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
11751175
11761176 req := httptest.NewRequest("GET", "/admin/unknown", nil)
11771177 req = req.WithContext(ctxWithScopes("admin:*:*"))
@@ -1181,3 +1181,88 @@ func TestHandler_NotFound(t *testing.T) {
11811181
11821182 assert.Equal(t, http.StatusNotFound, rec.Code)
11831183 }
1184+
1185+func TestHandler_BackupsStatus(t *testing.T) {
1186+ configSvc := inmocks.NewMockConfigService(t)
1187+ authSvc := inmocks.NewMockAuthService(t)
1188+ containerSvc := inmocks.NewMockContainerService(t)
1189+ secretSvc := inmocks.NewMockSecretService(t)
1190+ backupSvc := inmocks.NewMockBackupService(t)
1191+
1192+ backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1193+
1194+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1195+
1196+ req := httptest.NewRequest("GET", "/admin/backups/status", nil)
1197+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1198+ rec := httptest.NewRecorder()
1199+
1200+ handler.ServeHTTP(rec, req)
1201+
1202+ assert.Equal(t, http.StatusOK, rec.Code)
1203+ assert.Contains(t, rec.Body.String(), "app.example.com")
1204+}
1205+
1206+func TestHandler_BackupsListDomain(t *testing.T) {
1207+ configSvc := inmocks.NewMockConfigService(t)
1208+ authSvc := inmocks.NewMockAuthService(t)
1209+ containerSvc := inmocks.NewMockContainerService(t)
1210+ secretSvc := inmocks.NewMockSecretService(t)
1211+ backupSvc := inmocks.NewMockBackupService(t)
1212+
1213+ backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1214+
1215+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1216+
1217+ req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil)
1218+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1219+ rec := httptest.NewRecorder()
1220+
1221+ handler.ServeHTTP(rec, req)
1222+
1223+ assert.Equal(t, http.StatusOK, rec.Code)
1224+ assert.Contains(t, rec.Body.String(), "app.example.com")
1225+}
1226+
1227+func TestHandler_BackupsRunDomain(t *testing.T) {
1228+ configSvc := inmocks.NewMockConfigService(t)
1229+ authSvc := inmocks.NewMockAuthService(t)
1230+ containerSvc := inmocks.NewMockContainerService(t)
1231+ secretSvc := inmocks.NewMockSecretService(t)
1232+ backupSvc := inmocks.NewMockBackupService(t)
1233+
1234+ backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1235+
1236+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1237+
1238+ body := bytes.NewBufferString(`{"db":"postgres"}`)
1239+ req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
1240+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1241+ rec := httptest.NewRecorder()
1242+
1243+ handler.ServeHTTP(rec, req)
1244+
1245+ assert.Equal(t, http.StatusOK, rec.Code)
1246+ assert.Contains(t, rec.Body.String(), "completed")
1247+}
1248+
1249+func TestHandler_BackupsDetectDomain(t *testing.T) {
1250+ configSvc := inmocks.NewMockConfigService(t)
1251+ authSvc := inmocks.NewMockAuthService(t)
1252+ containerSvc := inmocks.NewMockContainerService(t)
1253+ secretSvc := inmocks.NewMockSecretService(t)
1254+ backupSvc := inmocks.NewMockBackupService(t)
1255+
1256+ backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)
1257+
1258+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1259+
1260+ req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)
1261+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1262+ rec := httptest.NewRecorder()
1263+
1264+ handler.ServeHTTP(rec, req)
1265+
1266+ assert.Equal(t, http.StatusOK, rec.Code)
1267+ assert.Contains(t, rec.Body.String(), "postgres")
1268+}
@@ -34,7 +34,7 @@ func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) {
34 containerSvc := inmocks.NewMockContainerService(t)34 containerSvc := inmocks.NewMockContainerService(t)
35 secretSvc := inmocks.NewMockSecretService(t)35 secretSvc := inmocks.NewMockSecretService(t)
36 36
37- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())37+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
38 38
39 tests := []struct {39 tests := []struct {
40 name string40 name string
@@ -97,7 +97,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) {
97 secretSvc := inmocks.NewMockSecretService(t)97 secretSvc := inmocks.NewMockSecretService(t)
98 registrySvc := inmocks.NewMockRegistryService(t)98 registrySvc := inmocks.NewMockRegistryService(t)
99 99
100- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())100+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
101 101
102 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`102 routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}`
103 103
@@ -158,7 +158,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) {
158 secretSvc := inmocks.NewMockSecretService(t)158 secretSvc := inmocks.NewMockSecretService(t)
159 registrySvc := inmocks.NewMockRegistryService(t)159 registrySvc := inmocks.NewMockRegistryService(t)
160 160
161- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())161+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
162 162
163 routeJSON := `{"image": "myapp:v2"}`163 routeJSON := `{"image": "myapp:v2"}`
164 164
@@ -203,7 +203,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) {
203 containerSvc := inmocks.NewMockContainerService(t)203 containerSvc := inmocks.NewMockContainerService(t)
204 secretSvc := inmocks.NewMockSecretService(t)204 secretSvc := inmocks.NewMockSecretService(t)
205 205
206- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())206+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
207 207
208 tests := []struct {208 tests := []struct {
209 name string209 name string
@@ -247,7 +247,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) {
247 containerSvc := inmocks.NewMockContainerService(t)247 containerSvc := inmocks.NewMockContainerService(t)
248 secretSvc := inmocks.NewMockSecretService(t)248 secretSvc := inmocks.NewMockSecretService(t)
249 249
250- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())250+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
251 251
252 tests := []struct {252 tests := []struct {
253 name string253 name string
@@ -304,7 +304,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) {
304 containerSvc := inmocks.NewMockContainerService(t)304 containerSvc := inmocks.NewMockContainerService(t)
305 secretSvc := inmocks.NewMockSecretService(t)305 secretSvc := inmocks.NewMockSecretService(t)
306 306
307- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())307+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
308 308
309 secretsJSON := `{"API_KEY": "secret123"}`309 secretsJSON := `{"API_KEY": "secret123"}`
310 310
@@ -353,7 +353,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) {
353 containerSvc := inmocks.NewMockContainerService(t)353 containerSvc := inmocks.NewMockContainerService(t)
354 secretSvc := inmocks.NewMockSecretService(t)354 secretSvc := inmocks.NewMockSecretService(t)
355 355
356- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())356+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
357 357
358 tests := []struct {358 tests := []struct {
359 name string359 name string
@@ -397,7 +397,7 @@ func TestHandler_AttachmentSecretsPost(t *testing.T) {
397 containerSvc := inmocks.NewMockContainerService(t)397 containerSvc := inmocks.NewMockContainerService(t)
398 secretSvc := inmocks.NewMockSecretService(t)398 secretSvc := inmocks.NewMockSecretService(t)
399 399
400- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())400+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
401 401
402 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)402 secretSvc.EXPECT().SetAttachment(mock.Anything, "app.example.com", "redis", mock.AnythingOfType("map[string]string")).Return(nil)
403 403
@@ -423,7 +423,7 @@ func TestHandler_AttachmentSecretsDelete(t *testing.T) {
423 containerSvc := inmocks.NewMockContainerService(t)423 containerSvc := inmocks.NewMockContainerService(t)
424 secretSvc := inmocks.NewMockSecretService(t)424 secretSvc := inmocks.NewMockSecretService(t)
425 425
426- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())426+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
427 427
428 secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)428 secretSvc.EXPECT().DeleteAttachment(mock.Anything, "app.example.com", "redis", "REDIS_URL").Return(nil)
429 429
@@ -450,7 +450,7 @@ func TestHandler_Status_RequiresReadScope(t *testing.T) {
450 containerSvc := inmocks.NewMockContainerService(t)450 containerSvc := inmocks.NewMockContainerService(t)
451 secretSvc := inmocks.NewMockSecretService(t)451 secretSvc := inmocks.NewMockSecretService(t)
452 452
453- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())453+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
454 454
455 tests := []struct {455 tests := []struct {
456 name string456 name string
@@ -504,7 +504,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) {
504 containerSvc := inmocks.NewMockContainerService(t)504 containerSvc := inmocks.NewMockContainerService(t)
505 secretSvc := inmocks.NewMockSecretService(t)505 secretSvc := inmocks.NewMockSecretService(t)
506 506
507- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())507+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
508 508
509 tests := []struct {509 tests := []struct {
510 name string510 name string
@@ -561,7 +561,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) {
561 containerSvc := inmocks.NewMockContainerService(t)561 containerSvc := inmocks.NewMockContainerService(t)
562 secretSvc := inmocks.NewMockSecretService(t)562 secretSvc := inmocks.NewMockSecretService(t)
563 563
564- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())564+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
565 565
566 tests := []struct {566 tests := []struct {
567 name string567 name string
@@ -615,7 +615,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) {
615 containerSvc := inmocks.NewMockContainerService(t)615 containerSvc := inmocks.NewMockContainerService(t)
616 secretSvc := inmocks.NewMockSecretService(t)616 secretSvc := inmocks.NewMockSecretService(t)
617 617
618- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())618+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
619 619
620 expectedRoutes := []domain.Route{620 expectedRoutes := []domain.Route{
621 {Domain: "app1.example.com", Image: "app1:latest"},621 {Domain: "app1.example.com", Image: "app1:latest"},
@@ -650,7 +650,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) {
650 containerSvc := inmocks.NewMockContainerService(t)650 containerSvc := inmocks.NewMockContainerService(t)
651 secretSvc := inmocks.NewMockSecretService(t)651 secretSvc := inmocks.NewMockSecretService(t)
652 652
653- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())653+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
654 654
655 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{655 configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{
656 Domain: "app.example.com", Image: "app:latest",656 Domain: "app.example.com", Image: "app:latest",
@@ -681,7 +681,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) {
681 containerSvc := inmocks.NewMockContainerService(t)681 containerSvc := inmocks.NewMockContainerService(t)
682 secretSvc := inmocks.NewMockSecretService(t)682 secretSvc := inmocks.NewMockSecretService(t)
683 683
684- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())684+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
685 685
686 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)686 configSvc.EXPECT().GetRoute(mock.Anything, "nonexistent.example.com").Return(nil, domain.ErrRouteNotFound)
687 687
@@ -725,7 +725,7 @@ func TestHandler_RoutesPost_MissingFields(t *testing.T) {
725 secretSvc := inmocks.NewMockSecretService(t)725 secretSvc := inmocks.NewMockSecretService(t)
726 registrySvc := inmocks.NewMockRegistryService(t)726 registrySvc := inmocks.NewMockRegistryService(t)
727 727
728- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())728+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
729 729
730 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)730 configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr)
731 registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()731 registrySvc.EXPECT().GetManifest(mock.Anything, mock.Anything, mock.Anything).Return(nil, nil).Maybe()
@@ -748,7 +748,7 @@ func TestHandler_RoutesPost_ImageNotFound(t *testing.T) {
748 secretSvc := inmocks.NewMockSecretService(t)748 secretSvc := inmocks.NewMockSecretService(t)
749 registrySvc := inmocks.NewMockRegistryService(t)749 registrySvc := inmocks.NewMockRegistryService(t)
750 750
751- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())751+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
752 752
753 tests := []struct {753 tests := []struct {
754 name string754 name string
@@ -802,7 +802,7 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) {
802 containerSvc := inmocks.NewMockContainerService(t)802 containerSvc := inmocks.NewMockContainerService(t)
803 secretSvc := inmocks.NewMockSecretService(t)803 secretSvc := inmocks.NewMockSecretService(t)
804 804
805- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())805+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
806 806
807 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))807 req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`))
808 req = req.WithContext(ctxWithScopes("admin:routes:write"))808 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -819,7 +819,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) {
819 containerSvc := inmocks.NewMockContainerService(t)819 containerSvc := inmocks.NewMockContainerService(t)
820 secretSvc := inmocks.NewMockSecretService(t)820 secretSvc := inmocks.NewMockSecretService(t)
821 821
822- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())822+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
823 823
824 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)824 req := httptest.NewRequest("DELETE", "/admin/routes/", nil)
825 req = req.WithContext(ctxWithScopes("admin:routes:write"))825 req = req.WithContext(ctxWithScopes("admin:routes:write"))
@@ -836,7 +836,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) {
836 containerSvc := inmocks.NewMockContainerService(t)836 containerSvc := inmocks.NewMockContainerService(t)
837 secretSvc := inmocks.NewMockSecretService(t)837 secretSvc := inmocks.NewMockSecretService(t)
838 838
839- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())839+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
840 840
841 req := httptest.NewRequest("GET", "/admin/secrets/", nil)841 req := httptest.NewRequest("GET", "/admin/secrets/", nil)
842 req = req.WithContext(ctxWithScopes("admin:secrets:read"))842 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -853,7 +853,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) {
853 containerSvc := inmocks.NewMockContainerService(t)853 containerSvc := inmocks.NewMockContainerService(t)
854 secretSvc := inmocks.NewMockSecretService(t)854 secretSvc := inmocks.NewMockSecretService(t)
855 855
856- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())856+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
857 857
858 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)858 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil)
859 req = req.WithContext(ctxWithScopes("admin:secrets:write"))859 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -870,7 +870,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) {
870 containerSvc := inmocks.NewMockContainerService(t)870 containerSvc := inmocks.NewMockContainerService(t)
871 secretSvc := inmocks.NewMockSecretService(t)871 secretSvc := inmocks.NewMockSecretService(t)
872 872
873- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())873+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
874 874
875 tests := []struct {875 tests := []struct {
876 method string876 method string
@@ -903,7 +903,7 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) {
903 containerSvc := inmocks.NewMockContainerService(t)903 containerSvc := inmocks.NewMockContainerService(t)
904 secretSvc := inmocks.NewMockSecretService(t)904 secretSvc := inmocks.NewMockSecretService(t)
905 905
906- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())906+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
907 907
908 tests := []struct {908 tests := []struct {
909 name string909 name string
@@ -933,7 +933,7 @@ func TestHandler_Restart_ContainerNotFound(t *testing.T) {
933 containerSvc := inmocks.NewMockContainerService(t)933 containerSvc := inmocks.NewMockContainerService(t)
934 secretSvc := inmocks.NewMockSecretService(t)934 secretSvc := inmocks.NewMockSecretService(t)
935 935
936- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())936+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
937 937
938 containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)938 containerSvc.EXPECT().Restart(mock.Anything, "nonexistent.example.com", false).Return(domain.ErrContainerNotFound)
939 939
@@ -952,7 +952,7 @@ func TestHandler_Restart_GenericError(t *testing.T) {
952 containerSvc := inmocks.NewMockContainerService(t)952 containerSvc := inmocks.NewMockContainerService(t)
953 secretSvc := inmocks.NewMockSecretService(t)953 secretSvc := inmocks.NewMockSecretService(t)
954 954
955- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())955+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
956 956
957 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))957 containerSvc.EXPECT().Restart(mock.Anything, "test.example.com", false).Return(fmt.Errorf("docker daemon error: secret details"))
958 958
@@ -974,7 +974,7 @@ func TestHandler_Restart_Success(t *testing.T) {
974 containerSvc := inmocks.NewMockContainerService(t)974 containerSvc := inmocks.NewMockContainerService(t)
975 secretSvc := inmocks.NewMockSecretService(t)975 secretSvc := inmocks.NewMockSecretService(t)
976 976
977- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())977+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
978 978
979 tests := []struct {979 tests := []struct {
980 name string980 name string
@@ -1021,7 +1021,7 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) {
1021 containerSvc := inmocks.NewMockContainerService(t)1021 containerSvc := inmocks.NewMockContainerService(t)
1022 secretSvc := inmocks.NewMockSecretService(t)1022 secretSvc := inmocks.NewMockSecretService(t)
1023 1023
1024- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())1024+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1025 1025
1026 tests := []struct {1026 tests := []struct {
1027 name string1027 name string
@@ -1052,7 +1052,7 @@ func TestHandler_Tags_Success(t *testing.T) {
1052 secretSvc := inmocks.NewMockSecretService(t)1052 secretSvc := inmocks.NewMockSecretService(t)
1053 registrySvc := inmocks.NewMockRegistryService(t)1053 registrySvc := inmocks.NewMockRegistryService(t)
1054 1054
1055- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())1055+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
1056 1056
1057 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)1057 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return([]string{"latest", "v1.0"}, nil)
1058 1058
@@ -1079,7 +1079,7 @@ func TestHandler_Tags_Error(t *testing.T) {
1079 secretSvc := inmocks.NewMockSecretService(t)1079 secretSvc := inmocks.NewMockSecretService(t)
1080 registrySvc := inmocks.NewMockRegistryService(t)1080 registrySvc := inmocks.NewMockRegistryService(t)
1081 1081
1082- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger())1082+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, registrySvc, nil, testLogger(), nil)
1083 1083
1084 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))1084 registrySvc.EXPECT().ListTags(mock.Anything, "myapp").Return(nil, fmt.Errorf("registry error"))
1085 1085
@@ -1099,7 +1099,7 @@ func TestHandler_AttachmentSecretsPost_RequiresWriteScope(t *testing.T) {
1099 containerSvc := inmocks.NewMockContainerService(t)1099 containerSvc := inmocks.NewMockContainerService(t)
1100 secretSvc := inmocks.NewMockSecretService(t)1100 secretSvc := inmocks.NewMockSecretService(t)
1101 1101
1102- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())1102+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1103 1103
1104 body := `{"REDIS_URL": "redis://localhost:6379"}`1104 body := `{"REDIS_URL": "redis://localhost:6379"}`
1105 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))1105 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
@@ -1117,7 +1117,7 @@ func TestHandler_AttachmentSecretsDelete_RequiresWriteScope(t *testing.T) {
1117 containerSvc := inmocks.NewMockContainerService(t)1117 containerSvc := inmocks.NewMockContainerService(t)
1118 secretSvc := inmocks.NewMockSecretService(t)1118 secretSvc := inmocks.NewMockSecretService(t)
1119 1119
1120- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())1120+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1121 1121
1122 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)1122 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil)
1123 req = req.WithContext(ctxWithScopes("admin:secrets:read"))1123 req = req.WithContext(ctxWithScopes("admin:secrets:read"))
@@ -1134,7 +1134,7 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) {
1134 containerSvc := inmocks.NewMockContainerService(t)1134 containerSvc := inmocks.NewMockContainerService(t)
1135 secretSvc := inmocks.NewMockSecretService(t)1135 secretSvc := inmocks.NewMockSecretService(t)
1136 1136
1137- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())1137+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1138 1138
1139 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)1139 req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil)
1140 req = req.WithContext(ctxWithScopes("admin:secrets:write"))1140 req = req.WithContext(ctxWithScopes("admin:secrets:write"))
@@ -1152,7 +1152,7 @@ func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) {
1152 containerSvc := inmocks.NewMockContainerService(t)1152 containerSvc := inmocks.NewMockContainerService(t)
1153 secretSvc := inmocks.NewMockSecretService(t)1153 secretSvc := inmocks.NewMockSecretService(t)
1154 1154
1155- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())1155+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1156 1156
1157 body := `{not valid json`1157 body := `{not valid json`
1158 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))1158 req := httptest.NewRequest("POST", "/admin/secrets/app.example.com/attachments/redis", bytes.NewBufferString(body))
@@ -1171,7 +1171,7 @@ func TestHandler_NotFound(t *testing.T) {
1171 containerSvc := inmocks.NewMockContainerService(t)1171 containerSvc := inmocks.NewMockContainerService(t)
1172 secretSvc := inmocks.NewMockSecretService(t)1172 secretSvc := inmocks.NewMockSecretService(t)
1173 1173
1174- handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger())1174+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1175 1175
1176 req := httptest.NewRequest("GET", "/admin/unknown", nil)1176 req := httptest.NewRequest("GET", "/admin/unknown", nil)
1177 req = req.WithContext(ctxWithScopes("admin:*:*"))1177 req = req.WithContext(ctxWithScopes("admin:*:*"))
@@ -1181,3 +1181,88 @@ func TestHandler_NotFound(t *testing.T) {
1181 1181
1182 assert.Equal(t, http.StatusNotFound, rec.Code)1182 assert.Equal(t, http.StatusNotFound, rec.Code)
1183 }1183 }
1184+
1185+func TestHandler_BackupsStatus(t *testing.T) {
1186+ configSvc := inmocks.NewMockConfigService(t)
1187+ authSvc := inmocks.NewMockAuthService(t)
1188+ containerSvc := inmocks.NewMockContainerService(t)
1189+ secretSvc := inmocks.NewMockSecretService(t)
1190+ backupSvc := inmocks.NewMockBackupService(t)
1191+
1192+ backupSvc.EXPECT().Status(mock.Anything).Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1193+
1194+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1195+
1196+ req := httptest.NewRequest("GET", "/admin/backups/status", nil)
1197+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1198+ rec := httptest.NewRecorder()
1199+
1200+ handler.ServeHTTP(rec, req)
1201+
1202+ assert.Equal(t, http.StatusOK, rec.Code)
1203+ assert.Contains(t, rec.Body.String(), "app.example.com")
1204+}
1205+
1206+func TestHandler_BackupsListDomain(t *testing.T) {
1207+ configSvc := inmocks.NewMockConfigService(t)
1208+ authSvc := inmocks.NewMockAuthService(t)
1209+ containerSvc := inmocks.NewMockContainerService(t)
1210+ secretSvc := inmocks.NewMockSecretService(t)
1211+ backupSvc := inmocks.NewMockBackupService(t)
1212+
1213+ backupSvc.EXPECT().ListBackups(mock.Anything, "app.example.com").Return([]domain.BackupJob{{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1214+
1215+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1216+
1217+ req := httptest.NewRequest("GET", "/admin/backups/app.example.com", nil)
1218+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1219+ rec := httptest.NewRecorder()
1220+
1221+ handler.ServeHTTP(rec, req)
1222+
1223+ assert.Equal(t, http.StatusOK, rec.Code)
1224+ assert.Contains(t, rec.Body.String(), "app.example.com")
1225+}
1226+
1227+func TestHandler_BackupsRunDomain(t *testing.T) {
1228+ configSvc := inmocks.NewMockConfigService(t)
1229+ authSvc := inmocks.NewMockAuthService(t)
1230+ containerSvc := inmocks.NewMockContainerService(t)
1231+ secretSvc := inmocks.NewMockSecretService(t)
1232+ backupSvc := inmocks.NewMockBackupService(t)
1233+
1234+ backupSvc.EXPECT().RunBackup(mock.Anything, "app.example.com", "postgres").Return(&domain.BackupResult{Job: domain.BackupJob{Domain: "app.example.com", DBName: "postgres", Status: domain.BackupStatusCompleted}}, nil)
1235+
1236+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1237+
1238+ body := bytes.NewBufferString(`{"db":"postgres"}`)
1239+ req := httptest.NewRequest("POST", "/admin/backups/app.example.com", body)
1240+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1241+ rec := httptest.NewRecorder()
1242+
1243+ handler.ServeHTTP(rec, req)
1244+
1245+ assert.Equal(t, http.StatusOK, rec.Code)
1246+ assert.Contains(t, rec.Body.String(), "completed")
1247+}
1248+
1249+func TestHandler_BackupsDetectDomain(t *testing.T) {
1250+ configSvc := inmocks.NewMockConfigService(t)
1251+ authSvc := inmocks.NewMockAuthService(t)
1252+ containerSvc := inmocks.NewMockContainerService(t)
1253+ secretSvc := inmocks.NewMockSecretService(t)
1254+ backupSvc := inmocks.NewMockBackupService(t)
1255+
1256+ backupSvc.EXPECT().DetectDatabases(mock.Anything, "app.example.com").Return([]domain.DBInfo{{Type: domain.DBTypePostgreSQL, Name: "postgres", Port: 5432}}, nil)
1257+
1258+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), backupSvc)
1259+
1260+ req := httptest.NewRequest("GET", "/admin/backups/app.example.com/detect", nil)
1261+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1262+ rec := httptest.NewRecorder()
1263+
1264+ handler.ServeHTTP(rec, req)
1265+
1266+ assert.Equal(t, http.StatusOK, rec.Code)
1267+ assert.Contains(t, rec.Body.String(), "postgres")
1268+}
M internal/adapters/out/docker/runtime.go +40 -17
@@ -13,6 +13,7 @@ import (
1313 "sort"
1414 "strconv"
1515 "strings"
16+ "time"
1617
1718 "github.com/bnema/zerowrap"
1819 cerrdefs "github.com/containerd/errdefs"
@@ -1134,11 +1135,21 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
11341135 return nil, log.WrapErr(err, "failed to create exec")
11351136 }
11361137
1137- attachResp, err := r.client.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})
1138+ attachCtx := ctx
1139+ var cancel context.CancelFunc
1140+ if _, hasDeadline := ctx.Deadline(); !hasDeadline {
1141+ attachCtx, cancel = context.WithTimeout(ctx, 5*time.Minute)
1142+ defer cancel()
1143+ }
1144+
1145+ attachResp, err := r.client.ContainerExecAttach(attachCtx, execResp.ID, container.ExecAttachOptions{})
11381146 if err != nil {
11391147 return nil, log.WrapErr(err, "failed to attach to exec")
11401148 }
11411149 defer attachResp.Close()
1150+ if deadline, ok := attachCtx.Deadline(); ok && attachResp.Conn != nil {
1151+ _ = attachResp.Conn.SetReadDeadline(deadline)
1152+ }
11421153
11431154 stdout, stderr, err := parseExecOutput(attachResp.Reader)
11441155 if err != nil {
@@ -1168,9 +1179,8 @@ func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
11681179 return stdout.Bytes(), stderr.Bytes(), nil
11691180 }
11701181
1171-// CopyFromContainer copies a file from a container to the host.
1172-// Returns the file contents as a byte slice.
1173-func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) {
1182+// CopyFromContainer copies a file from a container and returns a content stream.
1183+func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
11741184 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
11751185 zerowrap.FieldLayer: "adapter",
11761186 zerowrap.FieldAdapter: "docker",
@@ -1180,24 +1190,23 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
11801190 })
11811191 log := zerowrap.FromCtx(ctx)
11821192
1183- reader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
1193+ tarReader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
11841194 if err != nil {
11851195 return nil, log.WrapErr(err, "failed to copy from container")
11861196 }
1187- defer reader.Close()
11881197
1189- // The response is a tar archive - we need to extract the file
1190- data, err := extractFileFromTar(reader, srcPath)
1198+ // The response is a tar archive - we need to extract the requested file stream.
1199+ reader, err := extractFileFromTar(tarReader, srcPath)
11911200 if err != nil {
1201+ _ = tarReader.Close()
11921202 return nil, log.WrapErr(err, "failed to extract file from tar")
11931203 }
11941204
1195- log.Debug().Int("size", len(data)).Msg("file copied from container")
1196- return data, nil
1205+ return reader, nil
11971206 }
11981207
11991208 // extractFileFromTar extracts a single file from a tar archive.
1200-func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {
1209+func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
12011210 tr := tar.NewReader(reader)
12021211
12031212 // The path in the tar may be relative or absolute
@@ -1217,15 +1226,23 @@ func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {
12171226 if header.Typeflag == tar.TypeReg {
12181227 headerName := filepath.Base(header.Name)
12191228 if headerName == targetName {
1220- data, err := io.ReadAll(tr)
1221- if err != nil {
1222- return nil, err
1223- }
1224- return data, nil
1229+ size := header.Size
1230+ pr, pw := io.Pipe()
1231+ go func() {
1232+ defer reader.Close()
1233+ _, copyErr := io.CopyN(pw, tr, size)
1234+ if copyErr != nil {
1235+ _ = pw.CloseWithError(copyErr)
1236+ return
1237+ }
1238+ _ = pw.Close()
1239+ }()
1240+ return pr, nil
12251241 }
12261242 }
12271243 }
12281244
1245+ _ = reader.Close()
12291246 return nil, fmt.Errorf("file not found in container: %s", targetPath)
12301247 }
12311248
@@ -1265,10 +1282,16 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile
12651282 }()
12661283
12671284 // Copy the env file from the container
1268- data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
1285+ reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
12691286 if err != nil {
12701287 return nil, err
12711288 }
1289+ defer reader.Close()
1290+
1291+ data, err := io.ReadAll(reader)
1292+ if err != nil {
1293+ return nil, log.WrapErr(err, "failed to read extracted env file")
1294+ }
12721295
12731296 log.Info().Int("size", len(data)).Msg("env file extracted from image")
12741297 return data, nil
@@ -13,6 +13,7 @@ import (
13 "sort"13 "sort"
14 "strconv"14 "strconv"
15 "strings"15 "strings"
16+ "time"
16 17
17 "github.com/bnema/zerowrap"18 "github.com/bnema/zerowrap"
18 cerrdefs "github.com/containerd/errdefs"19 cerrdefs "github.com/containerd/errdefs"
@@ -1134,11 +1135,21 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [
1134 return nil, log.WrapErr(err, "failed to create exec")1135 return nil, log.WrapErr(err, "failed to create exec")
1135 }1136 }
1136 1137
1137- attachResp, err := r.client.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{})1138+ attachCtx := ctx
1139+ var cancel context.CancelFunc
1140+ if _, hasDeadline := ctx.Deadline(); !hasDeadline {
1141+ attachCtx, cancel = context.WithTimeout(ctx, 5*time.Minute)
1142+ defer cancel()
1143+ }
1144+
1145+ attachResp, err := r.client.ContainerExecAttach(attachCtx, execResp.ID, container.ExecAttachOptions{})
1138 if err != nil {1146 if err != nil {
1139 return nil, log.WrapErr(err, "failed to attach to exec")1147 return nil, log.WrapErr(err, "failed to attach to exec")
1140 }1148 }
1141 defer attachResp.Close()1149 defer attachResp.Close()
1150+ if deadline, ok := attachCtx.Deadline(); ok && attachResp.Conn != nil {
1151+ _ = attachResp.Conn.SetReadDeadline(deadline)
1152+ }
1142 1153
1143 stdout, stderr, err := parseExecOutput(attachResp.Reader)1154 stdout, stderr, err := parseExecOutput(attachResp.Reader)
1144 if err != nil {1155 if err != nil {
@@ -1168,9 +1179,8 @@ func parseExecOutput(reader io.Reader) ([]byte, []byte, error) {
1168 return stdout.Bytes(), stderr.Bytes(), nil1179 return stdout.Bytes(), stderr.Bytes(), nil
1169 }1180 }
1170 1181
1171-// CopyFromContainer copies a file from a container to the host.1182+// CopyFromContainer copies a file from a container and returns a content stream.
1172-// Returns the file contents as a byte slice.1183+func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error) {
1173-func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error) {
1174 ctx = zerowrap.CtxWithFields(ctx, map[string]any{1184 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1175 zerowrap.FieldLayer: "adapter",1185 zerowrap.FieldLayer: "adapter",
1176 zerowrap.FieldAdapter: "docker",1186 zerowrap.FieldAdapter: "docker",
@@ -1180,24 +1190,23 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st
1180 })1190 })
1181 log := zerowrap.FromCtx(ctx)1191 log := zerowrap.FromCtx(ctx)
1182 1192
1183- reader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)1193+ tarReader, _, err := r.client.CopyFromContainer(ctx, containerID, srcPath)
1184 if err != nil {1194 if err != nil {
1185 return nil, log.WrapErr(err, "failed to copy from container")1195 return nil, log.WrapErr(err, "failed to copy from container")
1186 }1196 }
1187- defer reader.Close()
1188 1197
1189- // The response is a tar archive - we need to extract the file1198+ // The response is a tar archive - we need to extract the requested file stream.
1190- data, err := extractFileFromTar(reader, srcPath)1199+ reader, err := extractFileFromTar(tarReader, srcPath)
1191 if err != nil {1200 if err != nil {
1201+ _ = tarReader.Close()
1192 return nil, log.WrapErr(err, "failed to extract file from tar")1202 return nil, log.WrapErr(err, "failed to extract file from tar")
1193 }1203 }
1194 1204
1195- log.Debug().Int("size", len(data)).Msg("file copied from container")1205+ return reader, nil
1196- return data, nil
1197 }1206 }
1198 1207
1199 // extractFileFromTar extracts a single file from a tar archive.1208 // extractFileFromTar extracts a single file from a tar archive.
1200-func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {1209+func extractFileFromTar(reader io.ReadCloser, targetPath string) (io.ReadCloser, error) {
1201 tr := tar.NewReader(reader)1210 tr := tar.NewReader(reader)
1202 1211
1203 // The path in the tar may be relative or absolute1212 // The path in the tar may be relative or absolute
@@ -1217,15 +1226,23 @@ func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) {
1217 if header.Typeflag == tar.TypeReg {1226 if header.Typeflag == tar.TypeReg {
1218 headerName := filepath.Base(header.Name)1227 headerName := filepath.Base(header.Name)
1219 if headerName == targetName {1228 if headerName == targetName {
1220- data, err := io.ReadAll(tr)1229+ size := header.Size
1221- if err != nil {1230+ pr, pw := io.Pipe()
1222- return nil, err1231+ go func() {
1223- }1232+ defer reader.Close()
1224- return data, nil1233+ _, copyErr := io.CopyN(pw, tr, size)
1234+ if copyErr != nil {
1235+ _ = pw.CloseWithError(copyErr)
1236+ return
1237+ }
1238+ _ = pw.Close()
1239+ }()
1240+ return pr, nil
1225 }1241 }
1226 }1242 }
1227 }1243 }
1228 1244
1245+ _ = reader.Close()
1229 return nil, fmt.Errorf("file not found in container: %s", targetPath)1246 return nil, fmt.Errorf("file not found in container: %s", targetPath)
1230 }1247 }
1231 1248
@@ -1265,10 +1282,16 @@ func (r *Runtime) ExtractEnvFileFromImage(ctx context.Context, imageRef, envFile
1265 }()1282 }()
1266 1283
1267 // Copy the env file from the container1284 // Copy the env file from the container
1268- data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)1285+ reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath)
1269 if err != nil {1286 if err != nil {
1270 return nil, err1287 return nil, err
1271 }1288 }
1289+ defer reader.Close()
1290+
1291+ data, err := io.ReadAll(reader)
1292+ if err != nil {
1293+ return nil, log.WrapErr(err, "failed to read extracted env file")
1294+ }
1272 1295
1273 log.Info().Int("size", len(data)).Msg("env file extracted from image")1296 log.Info().Int("size", len(data)).Msg("env file extracted from image")
1274 return data, nil1297 return data, nil
M internal/adapters/out/docker/runtime_exec_test.go +15 -4
@@ -23,10 +23,21 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {
2323 func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) {
2424 r := &Runtime{}
2525
26- result, err := r.ExecInContainer(context.Background(), "abc123", nil)
27-
28- require.Error(t, err)
29- assert.Nil(t, result)
26+ tests := []struct {
27+ name string
28+ cmd []string
29+ }{
30+ {name: "nil", cmd: nil},
31+ {name: "empty slice", cmd: []string{}},
32+ }
33+
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ result, err := r.ExecInContainer(context.Background(), "abc123", tt.cmd)
37+ require.Error(t, err)
38+ assert.Nil(t, result)
39+ })
40+ }
3041 }
3142
3243 func frameDockerStream(streamID byte, payload []byte) []byte {
@@ -23,10 +23,21 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) {
23 func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) {23 func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) {
24 r := &Runtime{}24 r := &Runtime{}
25 25
26- result, err := r.ExecInContainer(context.Background(), "abc123", nil)26+ tests := []struct {
27-27+ name string
28- require.Error(t, err)28+ cmd []string
29- assert.Nil(t, result)29+ }{
30+ {name: "nil", cmd: nil},
31+ {name: "empty slice", cmd: []string{}},
32+ }
33+
34+ for _, tt := range tests {
35+ t.Run(tt.name, func(t *testing.T) {
36+ result, err := r.ExecInContainer(context.Background(), "abc123", tt.cmd)
37+ require.Error(t, err)
38+ assert.Nil(t, result)
39+ })
40+ }
30 }41 }
31 42
32 func frameDockerStream(streamID byte, payload []byte) []byte {43 func frameDockerStream(streamID byte, payload []byte) []byte {
M internal/adapters/out/filesystem/backup_storage.go +60 -13
@@ -2,12 +2,14 @@ package filesystem
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "io"
78 "os"
89 "path/filepath"
910 "sort"
1011 "strings"
12+ "syscall"
1113 "time"
1214
1315 "github.com/bnema/zerowrap"
@@ -15,10 +17,7 @@ import (
1517 "github.com/bnema/gordon/internal/domain"
1618 )
1719
18-const (
19- backupDirName = "backups"
20- backupTimestampLayout = "20060102T150405Z"
21-)
20+const backupTimestampLayout = "20060102T150405Z"
2221
2322 // BackupStorage implements backup artifact persistence on local filesystem.
2423 type BackupStorage struct {
@@ -28,8 +27,7 @@ type BackupStorage struct {
2827
2928 // NewBackupStorage creates a new filesystem backup storage.
3029 func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) {
31- backupRoot := filepath.Join(rootDir, backupDirName)
32- if err := os.MkdirAll(backupRoot, 0750); err != nil {
30+ if err := os.MkdirAll(rootDir, 0750); err != nil {
3331 return nil, fmt.Errorf("failed to create backup directory: %w", err)
3432 }
3533
@@ -45,7 +43,7 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche
4543 schedulePart = "manual"
4644 }
4745
48- backupDir := filepath.Join(s.rootDir, backupDirName, domainPart, dbPart, schedulePart)
46+ backupDir := filepath.Join(s.rootDir, domainPart, dbPart, schedulePart)
4947 if err := os.MkdirAll(backupDir, 0750); err != nil {
5048 return "", fmt.Errorf("failed to create backup path: %w", err)
5149 }
@@ -54,7 +52,7 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche
5452 finalPath := filepath.Join(backupDir, fileName)
5553 tmpPath := finalPath + ".tmp"
5654
57- f, err := os.Create(tmpPath)
55+ f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
5856 if err != nil {
5957 return "", fmt.Errorf("failed to create temp backup file: %w", err)
6058 }
@@ -80,7 +78,7 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche
8078
8179 // Get retrieves a backup file by path.
8280 func (s *BackupStorage) Get(_ context.Context, path string) (io.ReadCloser, error) {
83- if !pathWithinRoot(filepath.Join(s.rootDir, backupDirName), path) {
81+ if !pathWithinRoot(s.rootDir, path) {
8482 return nil, fmt.Errorf("backup path escapes storage root")
8583 }
8684
@@ -93,7 +91,7 @@ func (s *BackupStorage) Get(_ context.Context, path string) (io.ReadCloser, erro
9391
9492 // List returns backups for a domain, optionally filtered by schedule.
9593 func (s *BackupStorage) List(_ context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) {
96- domainRoot := filepath.Join(s.rootDir, backupDirName, sanitizeBackupPathComponent(domainName))
94+ domainRoot := filepath.Join(s.rootDir, sanitizeBackupPathComponent(domainName))
9795 if _, err := os.Stat(domainRoot); err != nil {
9896 if os.IsNotExist(err) {
9997 return []domain.BackupJob{}, nil
@@ -163,7 +161,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom
163161
164162 // Delete removes a backup file.
165163 func (s *BackupStorage) Delete(_ context.Context, path string) error {
166- if !pathWithinRoot(filepath.Join(s.rootDir, backupDirName), path) {
164+ if !pathWithinRoot(s.rootDir, path) {
167165 return fmt.Errorf("backup path escapes storage root")
168166 }
169167 return os.Remove(path)
@@ -200,6 +198,9 @@ func (s *BackupStorage) ApplyRetention(ctx context.Context, domainName string, p
200198 }
201199 return deleted, err
202200 }
201+ if err := s.removeEmptyBackupDirs(group[idx].FilePath, domainName); err != nil {
202+ return deleted, err
203+ }
203204 deleted++
204205 }
205206 }
@@ -207,6 +208,36 @@ func (s *BackupStorage) ApplyRetention(ctx context.Context, domainName string, p
207208 return deleted, nil
208209 }
209210
211+func (s *BackupStorage) removeEmptyBackupDirs(filePath, domainName string) error {
212+ backupRoot := s.rootDir
213+ domainRoot := filepath.Join(backupRoot, sanitizeBackupPathComponent(domainName))
214+
215+ dir := filepath.Dir(filePath)
216+ for {
217+ if !pathWithinRoot(backupRoot, dir) {
218+ return nil
219+ }
220+ if dir == domainRoot || dir == backupRoot {
221+ break
222+ }
223+
224+ err := os.Remove(dir)
225+ if err == nil {
226+ dir = filepath.Dir(dir)
227+ continue
228+ }
229+ if os.IsNotExist(err) {
230+ return nil
231+ }
232+ if errors.Is(err, syscall.ENOTEMPTY) || errors.Is(err, syscall.EEXIST) {
233+ return nil
234+ }
235+ return err
236+ }
237+
238+ return nil
239+}
240+
210241 func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSchedule) int {
211242 switch schedule {
212243 case domain.ScheduleHourly:
@@ -227,13 +258,29 @@ func sanitizeBackupPathComponent(input string) string {
227258 if clean == "" {
228259 return "unknown"
229260 }
261+ if strings.Trim(clean, ".") == "" {
262+ return "unknown"
263+ }
264+ clean = strings.Trim(clean, ".")
265+ if clean == "" {
266+ return "unknown"
267+ }
230268 replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
231269 return replacer.Replace(clean)
232270 }
233271
234272 func pathWithinRoot(root, path string) bool {
235- rootClean := filepath.Clean(root)
236- pathClean := filepath.Clean(path)
273+ rootAbs, err := filepath.Abs(root)
274+ if err != nil {
275+ return false
276+ }
277+ pathAbs, err := filepath.Abs(path)
278+ if err != nil {
279+ return false
280+ }
281+
282+ rootClean := filepath.Clean(rootAbs)
283+ pathClean := filepath.Clean(pathAbs)
237284 rel, err := filepath.Rel(rootClean, pathClean)
238285 if err != nil {
239286 return false
@@ -2,12 +2,14 @@ package filesystem
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "io"7 "io"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
9 "sort"10 "sort"
10 "strings"11 "strings"
12+ "syscall"
11 "time"13 "time"
12 14
13 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
@@ -15,10 +17,7 @@ import (
15 "github.com/bnema/gordon/internal/domain"17 "github.com/bnema/gordon/internal/domain"
16 )18 )
17 19
18-const (20+const backupTimestampLayout = "20060102T150405Z"
19- backupDirName = "backups"
20- backupTimestampLayout = "20060102T150405Z"
21-)
22 21
23 // BackupStorage implements backup artifact persistence on local filesystem.22 // BackupStorage implements backup artifact persistence on local filesystem.
24 type BackupStorage struct {23 type BackupStorage struct {
@@ -28,8 +27,7 @@ type BackupStorage struct {
28 27
29 // NewBackupStorage creates a new filesystem backup storage.28 // NewBackupStorage creates a new filesystem backup storage.
30 func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) {29 func NewBackupStorage(rootDir string, log zerowrap.Logger) (*BackupStorage, error) {
31- backupRoot := filepath.Join(rootDir, backupDirName)30+ if err := os.MkdirAll(rootDir, 0750); err != nil {
32- if err := os.MkdirAll(backupRoot, 0750); err != nil {
33 return nil, fmt.Errorf("failed to create backup directory: %w", err)31 return nil, fmt.Errorf("failed to create backup directory: %w", err)
34 }32 }
35 33
@@ -45,7 +43,7 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche
45 schedulePart = "manual"43 schedulePart = "manual"
46 }44 }
47 45
48- backupDir := filepath.Join(s.rootDir, backupDirName, domainPart, dbPart, schedulePart)46+ backupDir := filepath.Join(s.rootDir, domainPart, dbPart, schedulePart)
49 if err := os.MkdirAll(backupDir, 0750); err != nil {47 if err := os.MkdirAll(backupDir, 0750); err != nil {
50 return "", fmt.Errorf("failed to create backup path: %w", err)48 return "", fmt.Errorf("failed to create backup path: %w", err)
51 }49 }
@@ -54,7 +52,7 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche
54 finalPath := filepath.Join(backupDir, fileName)52 finalPath := filepath.Join(backupDir, fileName)
55 tmpPath := finalPath + ".tmp"53 tmpPath := finalPath + ".tmp"
56 54
57- f, err := os.Create(tmpPath)55+ f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
58 if err != nil {56 if err != nil {
59 return "", fmt.Errorf("failed to create temp backup file: %w", err)57 return "", fmt.Errorf("failed to create temp backup file: %w", err)
60 }58 }
@@ -80,7 +78,7 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche
80 78
81 // Get retrieves a backup file by path.79 // Get retrieves a backup file by path.
82 func (s *BackupStorage) Get(_ context.Context, path string) (io.ReadCloser, error) {80 func (s *BackupStorage) Get(_ context.Context, path string) (io.ReadCloser, error) {
83- if !pathWithinRoot(filepath.Join(s.rootDir, backupDirName), path) {81+ if !pathWithinRoot(s.rootDir, path) {
84 return nil, fmt.Errorf("backup path escapes storage root")82 return nil, fmt.Errorf("backup path escapes storage root")
85 }83 }
86 84
@@ -93,7 +91,7 @@ func (s *BackupStorage) Get(_ context.Context, path string) (io.ReadCloser, erro
93 91
94 // List returns backups for a domain, optionally filtered by schedule.92 // List returns backups for a domain, optionally filtered by schedule.
95 func (s *BackupStorage) List(_ context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) {93 func (s *BackupStorage) List(_ context.Context, domainName string, schedule *domain.BackupSchedule) ([]domain.BackupJob, error) {
96- domainRoot := filepath.Join(s.rootDir, backupDirName, sanitizeBackupPathComponent(domainName))94+ domainRoot := filepath.Join(s.rootDir, sanitizeBackupPathComponent(domainName))
97 if _, err := os.Stat(domainRoot); err != nil {95 if _, err := os.Stat(domainRoot); err != nil {
98 if os.IsNotExist(err) {96 if os.IsNotExist(err) {
99 return []domain.BackupJob{}, nil97 return []domain.BackupJob{}, nil
@@ -163,7 +161,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom
163 161
164 // Delete removes a backup file.162 // Delete removes a backup file.
165 func (s *BackupStorage) Delete(_ context.Context, path string) error {163 func (s *BackupStorage) Delete(_ context.Context, path string) error {
166- if !pathWithinRoot(filepath.Join(s.rootDir, backupDirName), path) {164+ if !pathWithinRoot(s.rootDir, path) {
167 return fmt.Errorf("backup path escapes storage root")165 return fmt.Errorf("backup path escapes storage root")
168 }166 }
169 return os.Remove(path)167 return os.Remove(path)
@@ -200,6 +198,9 @@ func (s *BackupStorage) ApplyRetention(ctx context.Context, domainName string, p
200 }198 }
201 return deleted, err199 return deleted, err
202 }200 }
201+ if err := s.removeEmptyBackupDirs(group[idx].FilePath, domainName); err != nil {
202+ return deleted, err
203+ }
203 deleted++204 deleted++
204 }205 }
205 }206 }
@@ -207,6 +208,36 @@ func (s *BackupStorage) ApplyRetention(ctx context.Context, domainName string, p
207 return deleted, nil208 return deleted, nil
208 }209 }
209 210
211+func (s *BackupStorage) removeEmptyBackupDirs(filePath, domainName string) error {
212+ backupRoot := s.rootDir
213+ domainRoot := filepath.Join(backupRoot, sanitizeBackupPathComponent(domainName))
214+
215+ dir := filepath.Dir(filePath)
216+ for {
217+ if !pathWithinRoot(backupRoot, dir) {
218+ return nil
219+ }
220+ if dir == domainRoot || dir == backupRoot {
221+ break
222+ }
223+
224+ err := os.Remove(dir)
225+ if err == nil {
226+ dir = filepath.Dir(dir)
227+ continue
228+ }
229+ if os.IsNotExist(err) {
230+ return nil
231+ }
232+ if errors.Is(err, syscall.ENOTEMPTY) || errors.Is(err, syscall.EEXIST) {
233+ return nil
234+ }
235+ return err
236+ }
237+
238+ return nil
239+}
240+
210 func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSchedule) int {241 func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSchedule) int {
211 switch schedule {242 switch schedule {
212 case domain.ScheduleHourly:243 case domain.ScheduleHourly:
@@ -227,13 +258,29 @@ func sanitizeBackupPathComponent(input string) string {
227 if clean == "" {258 if clean == "" {
228 return "unknown"259 return "unknown"
229 }260 }
261+ if strings.Trim(clean, ".") == "" {
262+ return "unknown"
263+ }
264+ clean = strings.Trim(clean, ".")
265+ if clean == "" {
266+ return "unknown"
267+ }
230 replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")268 replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_")
231 return replacer.Replace(clean)269 return replacer.Replace(clean)
232 }270 }
233 271
234 func pathWithinRoot(root, path string) bool {272 func pathWithinRoot(root, path string) bool {
235- rootClean := filepath.Clean(root)273+ rootAbs, err := filepath.Abs(root)
236- pathClean := filepath.Clean(path)274+ if err != nil {
275+ return false
276+ }
277+ pathAbs, err := filepath.Abs(path)
278+ if err != nil {
279+ return false
280+ }
281+
282+ rootClean := filepath.Clean(rootAbs)
283+ pathClean := filepath.Clean(pathAbs)
237 rel, err := filepath.Rel(rootClean, pathClean)284 rel, err := filepath.Rel(rootClean, pathClean)
238 if err != nil {285 if err != nil {
239 return false286 return false
M internal/adapters/out/filesystem/backup_storage_test.go +11 -2
@@ -3,6 +3,7 @@ package filesystem
33 import (
44 "bytes"
55 "context"
6+ "io"
67 "testing"
78 "time"
89
@@ -24,8 +25,7 @@ func TestBackupStorage_StoreAndGet(t *testing.T) {
2425 require.NoError(t, err)
2526 defer rc.Close()
2627
27- data := make([]byte, len(payload))
28- _, err = rc.Read(data)
28+ data, err := io.ReadAll(rc)
2929 require.NoError(t, err)
3030 assert.Equal(t, payload, data)
3131 }
@@ -84,3 +84,12 @@ func TestBackupStorage_ApplyRetention(t *testing.T) {
8484 require.NoError(t, err)
8585 assert.Len(t, jobs, 2)
8686 }
87+
88+func TestBackupStorage_StoreSanitizesDotOnlyPathComponents(t *testing.T) {
89+ storage, err := NewBackupStorage(t.TempDir(), testLogger())
90+ require.NoError(t, err)
91+
92+ path, err := storage.Store(context.Background(), "..", "...", domain.ScheduleDaily, time.Now().UTC(), bytes.NewReader([]byte("data")))
93+ require.NoError(t, err)
94+ assert.NotContains(t, path, "..")
95+}
@@ -3,6 +3,7 @@ package filesystem
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "io"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -24,8 +25,7 @@ func TestBackupStorage_StoreAndGet(t *testing.T) {
24 require.NoError(t, err)25 require.NoError(t, err)
25 defer rc.Close()26 defer rc.Close()
26 27
27- data := make([]byte, len(payload))28+ data, err := io.ReadAll(rc)
28- _, err = rc.Read(data)
29 require.NoError(t, err)29 require.NoError(t, err)
30 assert.Equal(t, payload, data)30 assert.Equal(t, payload, data)
31 }31 }
@@ -84,3 +84,12 @@ func TestBackupStorage_ApplyRetention(t *testing.T) {
84 require.NoError(t, err)84 require.NoError(t, err)
85 assert.Len(t, jobs, 2)85 assert.Len(t, jobs, 2)
86 }86 }
87+
88+func TestBackupStorage_StoreSanitizesDotOnlyPathComponents(t *testing.T) {
89+ storage, err := NewBackupStorage(t.TempDir(), testLogger())
90+ require.NoError(t, err)
91+
92+ path, err := storage.Store(context.Background(), "..", "...", domain.ScheduleDaily, time.Now().UTC(), bytes.NewReader([]byte("data")))
93+ require.NoError(t, err)
94+ assert.NotContains(t, path, "..")
95+}
M internal/app/run.go +6 -0
@@ -1825,6 +1825,12 @@ func loadConfig(v *viper.Viper, configPath string) error {
18251825 v.SetDefault("volumes.prefix", "gordon")
18261826 v.SetDefault("volumes.preserve", true)
18271827 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1828+ v.SetDefault("backups.enabled", false)
1829+ v.SetDefault("backups.storage_dir", "")
1830+ v.SetDefault("backups.retention.hourly", 0)
1831+ v.SetDefault("backups.retention.daily", 0)
1832+ v.SetDefault("backups.retention.weekly", 0)
1833+ v.SetDefault("backups.retention.monthly", 0)
18281834 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
18291835 v.SetDefault("server.registry_allowed_ips", []string{})
18301836 v.SetDefault("deploy.readiness_delay", "5s")
@@ -1825,6 +1825,12 @@ func loadConfig(v *viper.Viper, configPath string) error {
1825 v.SetDefault("volumes.prefix", "gordon")1825 v.SetDefault("volumes.prefix", "gordon")
1826 v.SetDefault("volumes.preserve", true)1826 v.SetDefault("volumes.preserve", true)
1827 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)1827 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1828+ v.SetDefault("backups.enabled", false)
1829+ v.SetDefault("backups.storage_dir", "")
1830+ v.SetDefault("backups.retention.hourly", 0)
1831+ v.SetDefault("backups.retention.daily", 0)
1832+ v.SetDefault("backups.retention.weekly", 0)
1833+ v.SetDefault("backups.retention.monthly", 0)
1828 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit1834 v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit
1829 v.SetDefault("server.registry_allowed_ips", []string{})1835 v.SetDefault("server.registry_allowed_ips", []string{})
1830 v.SetDefault("deploy.readiness_delay", "5s")1836 v.SetDefault("deploy.readiness_delay", "5s")
M internal/boundaries/out/mocks/mock_container_runtime.go +8 -8
@@ -104,23 +104,23 @@ func (_c *MockContainerRuntime_ConnectContainerToNetwork_Call) RunAndReturn(run
104104 }
105105
106106 // CopyFromContainer provides a mock function for the type MockContainerRuntime
107-func (_mock *MockContainerRuntime) CopyFromContainer(ctx context.Context, containerID string, srcPath string) ([]byte, error) {
107+func (_mock *MockContainerRuntime) CopyFromContainer(ctx context.Context, containerID string, srcPath string) (io.ReadCloser, error) {
108108 ret := _mock.Called(ctx, containerID, srcPath)
109109
110110 if len(ret) == 0 {
111111 panic("no return value specified for CopyFromContainer")
112112 }
113113
114- var r0 []byte
114+ var r0 io.ReadCloser
115115 var r1 error
116- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]byte, error)); ok {
116+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (io.ReadCloser, error)); ok {
117117 return returnFunc(ctx, containerID, srcPath)
118118 }
119- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []byte); ok {
119+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) io.ReadCloser); ok {
120120 r0 = returnFunc(ctx, containerID, srcPath)
121121 } else {
122122 if ret.Get(0) != nil {
123- r0 = ret.Get(0).([]byte)
123+ r0 = ret.Get(0).(io.ReadCloser)
124124 }
125125 }
126126 if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
@@ -167,12 +167,12 @@ func (_c *MockContainerRuntime_CopyFromContainer_Call) Run(run func(ctx context.
167167 return _c
168168 }
169169
170-func (_c *MockContainerRuntime_CopyFromContainer_Call) Return(bytes []byte, err error) *MockContainerRuntime_CopyFromContainer_Call {
171- _c.Call.Return(bytes, err)
170+func (_c *MockContainerRuntime_CopyFromContainer_Call) Return(readCloser io.ReadCloser, err error) *MockContainerRuntime_CopyFromContainer_Call {
171+ _c.Call.Return(readCloser, err)
172172 return _c
173173 }
174174
175-func (_c *MockContainerRuntime_CopyFromContainer_Call) RunAndReturn(run func(ctx context.Context, containerID string, srcPath string) ([]byte, error)) *MockContainerRuntime_CopyFromContainer_Call {
175+func (_c *MockContainerRuntime_CopyFromContainer_Call) RunAndReturn(run func(ctx context.Context, containerID string, srcPath string) (io.ReadCloser, error)) *MockContainerRuntime_CopyFromContainer_Call {
176176 _c.Call.Return(run)
177177 return _c
178178 }
@@ -104,23 +104,23 @@ func (_c *MockContainerRuntime_ConnectContainerToNetwork_Call) RunAndReturn(run
104 }104 }
105 105
106 // CopyFromContainer provides a mock function for the type MockContainerRuntime106 // CopyFromContainer provides a mock function for the type MockContainerRuntime
107-func (_mock *MockContainerRuntime) CopyFromContainer(ctx context.Context, containerID string, srcPath string) ([]byte, error) {107+func (_mock *MockContainerRuntime) CopyFromContainer(ctx context.Context, containerID string, srcPath string) (io.ReadCloser, error) {
108 ret := _mock.Called(ctx, containerID, srcPath)108 ret := _mock.Called(ctx, containerID, srcPath)
109 109
110 if len(ret) == 0 {110 if len(ret) == 0 {
111 panic("no return value specified for CopyFromContainer")111 panic("no return value specified for CopyFromContainer")
112 }112 }
113 113
114- var r0 []byte114+ var r0 io.ReadCloser
115 var r1 error115 var r1 error
116- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) ([]byte, error)); ok {116+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (io.ReadCloser, error)); ok {
117 return returnFunc(ctx, containerID, srcPath)117 return returnFunc(ctx, containerID, srcPath)
118 }118 }
119- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) []byte); ok {119+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) io.ReadCloser); ok {
120 r0 = returnFunc(ctx, containerID, srcPath)120 r0 = returnFunc(ctx, containerID, srcPath)
121 } else {121 } else {
122 if ret.Get(0) != nil {122 if ret.Get(0) != nil {
123- r0 = ret.Get(0).([]byte)123+ r0 = ret.Get(0).(io.ReadCloser)
124 }124 }
125 }125 }
126 if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {126 if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
@@ -167,12 +167,12 @@ func (_c *MockContainerRuntime_CopyFromContainer_Call) Run(run func(ctx context.
167 return _c167 return _c
168 }168 }
169 169
170-func (_c *MockContainerRuntime_CopyFromContainer_Call) Return(bytes []byte, err error) *MockContainerRuntime_CopyFromContainer_Call {170+func (_c *MockContainerRuntime_CopyFromContainer_Call) Return(readCloser io.ReadCloser, err error) *MockContainerRuntime_CopyFromContainer_Call {
171- _c.Call.Return(bytes, err)171+ _c.Call.Return(readCloser, err)
172 return _c172 return _c
173 }173 }
174 174
175-func (_c *MockContainerRuntime_CopyFromContainer_Call) RunAndReturn(run func(ctx context.Context, containerID string, srcPath string) ([]byte, error)) *MockContainerRuntime_CopyFromContainer_Call {175+func (_c *MockContainerRuntime_CopyFromContainer_Call) RunAndReturn(run func(ctx context.Context, containerID string, srcPath string) (io.ReadCloser, error)) *MockContainerRuntime_CopyFromContainer_Call {
176 _c.Call.Return(run)176 _c.Call.Return(run)
177 return _c177 return _c
178 }178 }
M internal/boundaries/out/runtime.go +1 -1
@@ -62,7 +62,7 @@ type ContainerRuntime interface {
6262
6363 // In-container operations
6464 ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)
65- CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error)
65+ CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)
6666
6767 // Network management
6868 CreateNetwork(ctx context.Context, name string, options map[string]string) error
@@ -62,7 +62,7 @@ type ContainerRuntime interface {
62 62
63 // In-container operations63 // In-container operations
64 ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)64 ExecInContainer(ctx context.Context, containerID string, cmd []string) (*ExecResult, error)
65- CopyFromContainer(ctx context.Context, containerID, srcPath string) ([]byte, error)65+ CopyFromContainer(ctx context.Context, containerID, srcPath string) (io.ReadCloser, error)
66 66
67 // Network management67 // Network management
68 CreateNetwork(ctx context.Context, name string, options map[string]string) error68 CreateNetwork(ctx context.Context, name string, options map[string]string) error
M internal/domain/backup.go +14 -0
@@ -47,9 +47,23 @@ type DBInfo struct {
4747 Port int
4848 ContainerID string
4949 ImageName string
50+ // Credentials contains sensitive values (passwords/tokens).
51+ // Never log or expose this map in API responses.
5052 Credentials map[string]string
5153 }
5254
55+// ClearCredentials clears sensitive credential values from DBInfo.
56+func (d *DBInfo) ClearCredentials() {
57+ if d == nil || d.Credentials == nil {
58+ return
59+ }
60+ for k := range d.Credentials {
61+ d.Credentials[k] = ""
62+ delete(d.Credentials, k)
63+ }
64+ d.Credentials = nil
65+}
66+
5367 // BackupJob represents a scheduled or manual backup operation.
5468 type BackupJob struct {
5569 ID string
@@ -47,9 +47,23 @@ type DBInfo struct {
47 Port int47 Port int
48 ContainerID string48 ContainerID string
49 ImageName string49 ImageName string
50+ // Credentials contains sensitive values (passwords/tokens).
51+ // Never log or expose this map in API responses.
50 Credentials map[string]string52 Credentials map[string]string
51 }53 }
52 54
55+// ClearCredentials clears sensitive credential values from DBInfo.
56+func (d *DBInfo) ClearCredentials() {
57+ if d == nil || d.Credentials == nil {
58+ return
59+ }
60+ for k := range d.Credentials {
61+ d.Credentials[k] = ""
62+ delete(d.Credentials, k)
63+ }
64+ d.Credentials = nil
65+}
66+
53 // BackupJob represents a scheduled or manual backup operation.67 // BackupJob represents a scheduled or manual backup operation.
54 type BackupJob struct {68 type BackupJob struct {
55 ID string69 ID string
M internal/domain/container.go +1 -0
@@ -28,6 +28,7 @@ type Attachment struct {
2828 ContainerID string
2929 Status string
3030 Network string
31+ Ports []int
3132 }
3233
3334 // RouteInfo combines route configuration with runtime state.
@@ -28,6 +28,7 @@ type Attachment struct {
28 ContainerID string28 ContainerID string
29 Status string29 Status string
30 Network string30 Network string
31+ Ports []int
31 }32 }
32 33
33 // RouteInfo combines route configuration with runtime state.34 // RouteInfo combines route configuration with runtime state.
M internal/domain/cron.go +4 -0
@@ -3,6 +3,10 @@ package domain
33 import "time"
44
55 // CronSchedule represents a recurring schedule.
6+//
7+// Preset schedules currently use fixed UTC times by design (MVP scope).
8+// Timezone-aware scheduling is deferred until a timezone field is added.
9+// When timezone support lands, host system timezone should be the default.
610 type CronSchedule struct {
711 Preset BackupSchedule
812 }
@@ -3,6 +3,10 @@ package domain
3 import "time"3 import "time"
4 4
5 // CronSchedule represents a recurring schedule.5 // CronSchedule represents a recurring schedule.
6+//
7+// Preset schedules currently use fixed UTC times by design (MVP scope).
8+// Timezone-aware scheduling is deferred until a timezone field is added.
9+// When timezone support lands, host system timezone should be the default.
6 type CronSchedule struct {10 type CronSchedule struct {
7 Preset BackupSchedule11 Preset BackupSchedule
8 }12 }
M internal/usecase/backup/detector.go +28 -1
@@ -13,12 +13,17 @@ func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domai
1313
1414 switch {
1515 case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"):
16+ port := 5432
17+ if len(a.Ports) > 0 && a.Ports[0] > 0 {
18+ port = a.Ports[0]
19+ }
1620 return domain.DBInfo{
1721 Type: domain.DBTypePostgreSQL,
22+ Version: postgresVersionFromImage(a.Image),
1823 Domain: domainName,
1924 Name: a.Name,
2025 Host: a.Name,
21- Port: 5432,
26+ Port: port,
2227 ContainerID: a.ContainerID,
2328 ImageName: a.Image,
2429 }, true
@@ -26,3 +31,25 @@ func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domai
2631 return domain.DBInfo{}, false
2732 }
2833 }
34+
35+func postgresVersionFromImage(image string) string {
36+ lastColon := strings.LastIndex(image, ":")
37+ if lastColon == -1 {
38+ return ""
39+ }
40+ tag := strings.TrimSpace(image[lastColon+1:])
41+ if tag == "" {
42+ return ""
43+ }
44+
45+ for i := 0; i < len(tag); i++ {
46+ if tag[i] < '0' || tag[i] > '9' {
47+ if i == 0 {
48+ return ""
49+ }
50+ return tag[:i]
51+ }
52+ }
53+
54+ return tag
55+}
@@ -13,12 +13,17 @@ func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domai
13 13
14 switch {14 switch {
15 case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"):15 case strings.Contains(image, "postgres"), strings.Contains(name, "postgres"):
16+ port := 5432
17+ if len(a.Ports) > 0 && a.Ports[0] > 0 {
18+ port = a.Ports[0]
19+ }
16 return domain.DBInfo{20 return domain.DBInfo{
17 Type: domain.DBTypePostgreSQL,21 Type: domain.DBTypePostgreSQL,
22+ Version: postgresVersionFromImage(a.Image),
18 Domain: domainName,23 Domain: domainName,
19 Name: a.Name,24 Name: a.Name,
20 Host: a.Name,25 Host: a.Name,
21- Port: 5432,26+ Port: port,
22 ContainerID: a.ContainerID,27 ContainerID: a.ContainerID,
23 ImageName: a.Image,28 ImageName: a.Image,
24 }, true29 }, true
@@ -26,3 +31,25 @@ func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domai
26 return domain.DBInfo{}, false31 return domain.DBInfo{}, false
27 }32 }
28 }33 }
34+
35+func postgresVersionFromImage(image string) string {
36+ lastColon := strings.LastIndex(image, ":")
37+ if lastColon == -1 {
38+ return ""
39+ }
40+ tag := strings.TrimSpace(image[lastColon+1:])
41+ if tag == "" {
42+ return ""
43+ }
44+
45+ for i := 0; i < len(tag); i++ {
46+ if tag[i] < '0' || tag[i] > '9' {
47+ if i == 0 {
48+ return ""
49+ }
50+ return tag[:i]
51+ }
52+ }
53+
54+ return tag
55+}
A internal/usecase/backup/detector_test.go +54 -0
@@ -0,0 +1,54 @@
1+package backup
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestDetectDatabaseFromAttachment_ExtractsPostgresVersion(t *testing.T) {
12+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
13+ Name: "postgres",
14+ Image: "postgres:17.4-alpine",
15+ ContainerID: "abc123",
16+ Ports: []int{55432},
17+ })
18+
19+ require.True(t, ok)
20+ assert.Equal(t, domain.DBTypePostgreSQL, db.Type)
21+ assert.Equal(t, "17", db.Version)
22+ assert.Equal(t, 55432, db.Port)
23+}
24+
25+func TestDetectDatabaseFromAttachment_DefaultPort(t *testing.T) {
26+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
27+ Name: "postgres",
28+ Image: "postgres:16",
29+ ContainerID: "xyz789",
30+ Ports: []int{},
31+ })
32+
33+ require.True(t, ok)
34+ assert.Equal(t, 5432, db.Port)
35+ assert.Equal(t, "16", db.Version)
36+}
37+
38+func TestPostgresVersionFromImage(t *testing.T) {
39+ assert.Equal(t, "18", postgresVersionFromImage("postgres:18"))
40+ assert.Equal(t, "17", postgresVersionFromImage("postgres:17.4-alpine"))
41+ assert.Equal(t, "", postgresVersionFromImage("postgres:latest"))
42+ assert.Equal(t, "", postgresVersionFromImage("postgres:alpine"))
43+ assert.Equal(t, "", postgresVersionFromImage("postgres"))
44+}
45+
46+func TestDetectDatabaseFromAttachment_NonPostgres(t *testing.T) {
47+ _, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
48+ Name: "mysql",
49+ Image: "mysql:8.0",
50+ ContainerID: "def456",
51+ Ports: []int{3306},
52+ })
53+ assert.False(t, ok)
54+}
@@ -0,0 +1,54 @@
1+package backup
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/gordon/internal/domain"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestDetectDatabaseFromAttachment_ExtractsPostgresVersion(t *testing.T) {
12+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
13+ Name: "postgres",
14+ Image: "postgres:17.4-alpine",
15+ ContainerID: "abc123",
16+ Ports: []int{55432},
17+ })
18+
19+ require.True(t, ok)
20+ assert.Equal(t, domain.DBTypePostgreSQL, db.Type)
21+ assert.Equal(t, "17", db.Version)
22+ assert.Equal(t, 55432, db.Port)
23+}
24+
25+func TestDetectDatabaseFromAttachment_DefaultPort(t *testing.T) {
26+ db, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
27+ Name: "postgres",
28+ Image: "postgres:16",
29+ ContainerID: "xyz789",
30+ Ports: []int{},
31+ })
32+
33+ require.True(t, ok)
34+ assert.Equal(t, 5432, db.Port)
35+ assert.Equal(t, "16", db.Version)
36+}
37+
38+func TestPostgresVersionFromImage(t *testing.T) {
39+ assert.Equal(t, "18", postgresVersionFromImage("postgres:18"))
40+ assert.Equal(t, "17", postgresVersionFromImage("postgres:17.4-alpine"))
41+ assert.Equal(t, "", postgresVersionFromImage("postgres:latest"))
42+ assert.Equal(t, "", postgresVersionFromImage("postgres:alpine"))
43+ assert.Equal(t, "", postgresVersionFromImage("postgres"))
44+}
45+
46+func TestDetectDatabaseFromAttachment_NonPostgres(t *testing.T) {
47+ _, ok := detectDatabaseFromAttachment("app.example.com", domain.Attachment{
48+ Name: "mysql",
49+ Image: "mysql:8.0",
50+ ContainerID: "def456",
51+ Ports: []int{3306},
52+ })
53+ assert.False(t, ok)
54+}
M internal/usecase/backup/integration_test.go +11 -2
@@ -42,7 +42,9 @@ func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Ru
4242 networkName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano())
4343 containerName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano())
4444
45- require.NoError(t, runtime.PullImage(ctx, image))
45+ pullCtx, cancelPull := context.WithTimeout(ctx, 5*time.Minute)
46+ defer cancelPull()
47+ require.NoError(t, runtime.PullImage(pullCtx, image))
4648 require.NoError(t, runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}))
4749 t.Cleanup(func() { _ = runtime.RemoveNetwork(context.Background(), networkName) })
4850
@@ -90,6 +92,7 @@ func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Ru
9092 ContainerID: container.ID,
9193 Status: container.Status,
9294 Network: networkName,
95+ Ports: []int{5432},
9396 },
9497 },
9598 },
@@ -132,6 +135,12 @@ func requireDockerRuntime(t *testing.T) *docker.Runtime {
132135 func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containerID string, timeout time.Duration) error {
133136 deadline := time.Now().Add(timeout)
134137 for time.Now().Before(deadline) {
138+ select {
139+ case <-ctx.Done():
140+ return ctx.Err()
141+ default:
142+ }
143+
135144 res, err := runtime.ExecInContainer(ctx, containerID, []string{"pg_isready", "-U", "postgres", "-d", "appdb"})
136145 if err == nil && res.ExitCode == 0 {
137146 return nil
@@ -142,7 +151,7 @@ func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containe
142151 }
143152
144153 func seedPostgresData(ctx context.Context, runtime *docker.Runtime, containerID string) error {
145- cmd := `psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS backup_items (id serial primary key, name text); INSERT INTO backup_items(name) VALUES ('one'), ('two');"`
154+ cmd := `psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres -c "SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'" | grep -q 1 || createdb -U "$POSTGRES_USER" "$POSTGRES_DB"; psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS backup_items (id serial primary key, name text); INSERT INTO backup_items(name) VALUES ('one'), ('two');"`
146155 res, err := runtime.ExecInContainer(ctx, containerID, []string{"sh", "-c", cmd})
147156 if err != nil {
148157 return err
@@ -42,7 +42,9 @@ func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Ru
42 networkName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano())42 networkName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano())
43 containerName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano())43 containerName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano())
44 44
45- require.NoError(t, runtime.PullImage(ctx, image))45+ pullCtx, cancelPull := context.WithTimeout(ctx, 5*time.Minute)
46+ defer cancelPull()
47+ require.NoError(t, runtime.PullImage(pullCtx, image))
46 require.NoError(t, runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}))48 require.NoError(t, runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}))
47 t.Cleanup(func() { _ = runtime.RemoveNetwork(context.Background(), networkName) })49 t.Cleanup(func() { _ = runtime.RemoveNetwork(context.Background(), networkName) })
48 50
@@ -90,6 +92,7 @@ func runPostgresBackupFlow(t *testing.T, ctx context.Context, runtime *docker.Ru
90 ContainerID: container.ID,92 ContainerID: container.ID,
91 Status: container.Status,93 Status: container.Status,
92 Network: networkName,94 Network: networkName,
95+ Ports: []int{5432},
93 },96 },
94 },97 },
95 },98 },
@@ -132,6 +135,12 @@ func requireDockerRuntime(t *testing.T) *docker.Runtime {
132 func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containerID string, timeout time.Duration) error {135 func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containerID string, timeout time.Duration) error {
133 deadline := time.Now().Add(timeout)136 deadline := time.Now().Add(timeout)
134 for time.Now().Before(deadline) {137 for time.Now().Before(deadline) {
138+ select {
139+ case <-ctx.Done():
140+ return ctx.Err()
141+ default:
142+ }
143+
135 res, err := runtime.ExecInContainer(ctx, containerID, []string{"pg_isready", "-U", "postgres", "-d", "appdb"})144 res, err := runtime.ExecInContainer(ctx, containerID, []string{"pg_isready", "-U", "postgres", "-d", "appdb"})
136 if err == nil && res.ExitCode == 0 {145 if err == nil && res.ExitCode == 0 {
137 return nil146 return nil
@@ -142,7 +151,7 @@ func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containe
142 }151 }
143 152
144 func seedPostgresData(ctx context.Context, runtime *docker.Runtime, containerID string) error {153 func seedPostgresData(ctx context.Context, runtime *docker.Runtime, containerID string) error {
145- cmd := `psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS backup_items (id serial primary key, name text); INSERT INTO backup_items(name) VALUES ('one'), ('two');"`154+ cmd := `psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d postgres -c "SELECT 1 FROM pg_database WHERE datname='${POSTGRES_DB}'" | grep -q 1 || createdb -U "$POSTGRES_USER" "$POSTGRES_DB"; psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "CREATE TABLE IF NOT EXISTS backup_items (id serial primary key, name text); INSERT INTO backup_items(name) VALUES ('one'), ('two');"`
146 res, err := runtime.ExecInContainer(ctx, containerID, []string{"sh", "-c", cmd})155 res, err := runtime.ExecInContainer(ctx, containerID, []string{"sh", "-c", cmd})
147 if err != nil {156 if err != nil {
148 return err157 return err
M internal/usecase/backup/service.go +68 -6
@@ -3,8 +3,13 @@ package backup
33 import (
44 "bytes"
55 "context"
6+ "crypto/rand"
7+ "encoding/hex"
8+ "errors"
69 "fmt"
10+ "sort"
711 "strings"
12+ "sync"
813 "time"
914
1015 "github.com/bnema/zerowrap"
@@ -14,6 +19,8 @@ import (
1419 "github.com/bnema/gordon/internal/domain"
1520 )
1621
22+const backupExecTimeout = 30 * time.Minute
23+
1724 // Service orchestrates backup operations.
1825 type Service struct {
1926 runtime out.ContainerRuntime
@@ -75,8 +82,14 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
7582 return nil, fmt.Errorf("unsupported database type: %s", db.Type)
7683 }
7784
78- execResult, err := s.runtime.ExecInContainer(ctx, db.ContainerID, []string{"sh", "-c", postgresDumpCommand()})
85+ execCtx, cancelExec := context.WithTimeout(ctx, backupExecTimeout)
86+ defer cancelExec()
87+
88+ execResult, err := s.runtime.ExecInContainer(execCtx, db.ContainerID, []string{"sh", "-c", postgresDumpCommand()})
7989 if err != nil {
90+ if errors.Is(execCtx.Err(), context.DeadlineExceeded) {
91+ return nil, fmt.Errorf("pg_dump timed out after %s", backupExecTimeout)
92+ }
8093 return nil, err
8194 }
8295 if execResult.ExitCode != 0 {
@@ -89,7 +102,7 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
89102 }
90103
91104 job := domain.BackupJob{
92- ID: started.Format(time.RFC3339Nano),
105+ ID: newBackupJobID(started),
93106 Domain: domainName,
94107 DBName: db.Name,
95108 Type: domain.BackupTypeLogical,
@@ -119,17 +132,66 @@ func (s *Service) RestorePITR(context.Context, string, time.Time) error {
119132 // Status returns aggregate backup status for all managed domains.
120133 func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) {
121134 routes := s.containerSvc.List(ctx)
122- jobs := make([]domain.BackupJob, 0)
135+ domainNames := make([]string, 0, len(routes))
123136 for domainName := range routes {
124- domainJobs, err := s.ListBackups(ctx, domainName)
125- if err != nil {
126- return nil, err
137+ domainNames = append(domainNames, domainName)
138+ }
139+ sort.Strings(domainNames)
140+
141+ const maxWorkers = 4
142+ sem := make(chan struct{}, maxWorkers)
143+ results := make([][]domain.BackupJob, len(domainNames))
144+ errCh := make(chan error, 1)
145+ var wg sync.WaitGroup
146+
147+ for i, domainName := range domainNames {
148+ select {
149+ case <-ctx.Done():
150+ return nil, ctx.Err()
151+ default:
127152 }
153+
154+ wg.Add(1)
155+ sem <- struct{}{}
156+ go func(idx int, name string) {
157+ defer wg.Done()
158+ defer func() { <-sem }()
159+
160+ domainJobs, err := s.ListBackups(ctx, name)
161+ if err != nil {
162+ select {
163+ case errCh <- err:
164+ default:
165+ }
166+ return
167+ }
168+ results[idx] = domainJobs
169+ }(i, domainName)
170+ }
171+
172+ wg.Wait()
173+ select {
174+ case err := <-errCh:
175+ return nil, err
176+ default:
177+ }
178+
179+ jobs := make([]domain.BackupJob, 0)
180+ for _, domainJobs := range results {
128181 jobs = append(jobs, domainJobs...)
129182 }
183+
130184 return jobs, nil
131185 }
132186
187+func newBackupJobID(started time.Time) string {
188+ random := make([]byte, 4)
189+ if _, err := rand.Read(random); err != nil {
190+ return fmt.Sprintf("%s-%d", started.Format(time.RFC3339Nano), time.Now().UnixNano())
191+ }
192+ return fmt.Sprintf("%s-%s", started.Format(time.RFC3339Nano), hex.EncodeToString(random))
193+}
194+
133195 func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error) {
134196 if len(dbs) == 0 {
135197 return domain.DBInfo{}, fmt.Errorf("no supported database attachments detected")
@@ -3,8 +3,13 @@ package backup
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "crypto/rand"
7+ "encoding/hex"
8+ "errors"
6 "fmt"9 "fmt"
10+ "sort"
7 "strings"11 "strings"
12+ "sync"
8 "time"13 "time"
9 14
10 "github.com/bnema/zerowrap"15 "github.com/bnema/zerowrap"
@@ -14,6 +19,8 @@ import (
14 "github.com/bnema/gordon/internal/domain"19 "github.com/bnema/gordon/internal/domain"
15 )20 )
16 21
22+const backupExecTimeout = 30 * time.Minute
23+
17 // Service orchestrates backup operations.24 // Service orchestrates backup operations.
18 type Service struct {25 type Service struct {
19 runtime out.ContainerRuntime26 runtime out.ContainerRuntime
@@ -75,8 +82,14 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
75 return nil, fmt.Errorf("unsupported database type: %s", db.Type)82 return nil, fmt.Errorf("unsupported database type: %s", db.Type)
76 }83 }
77 84
78- execResult, err := s.runtime.ExecInContainer(ctx, db.ContainerID, []string{"sh", "-c", postgresDumpCommand()})85+ execCtx, cancelExec := context.WithTimeout(ctx, backupExecTimeout)
86+ defer cancelExec()
87+
88+ execResult, err := s.runtime.ExecInContainer(execCtx, db.ContainerID, []string{"sh", "-c", postgresDumpCommand()})
79 if err != nil {89 if err != nil {
90+ if errors.Is(execCtx.Err(), context.DeadlineExceeded) {
91+ return nil, fmt.Errorf("pg_dump timed out after %s", backupExecTimeout)
92+ }
80 return nil, err93 return nil, err
81 }94 }
82 if execResult.ExitCode != 0 {95 if execResult.ExitCode != 0 {
@@ -89,7 +102,7 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do
89 }102 }
90 103
91 job := domain.BackupJob{104 job := domain.BackupJob{
92- ID: started.Format(time.RFC3339Nano),105+ ID: newBackupJobID(started),
93 Domain: domainName,106 Domain: domainName,
94 DBName: db.Name,107 DBName: db.Name,
95 Type: domain.BackupTypeLogical,108 Type: domain.BackupTypeLogical,
@@ -119,17 +132,66 @@ func (s *Service) RestorePITR(context.Context, string, time.Time) error {
119 // Status returns aggregate backup status for all managed domains.132 // Status returns aggregate backup status for all managed domains.
120 func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) {133 func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) {
121 routes := s.containerSvc.List(ctx)134 routes := s.containerSvc.List(ctx)
122- jobs := make([]domain.BackupJob, 0)135+ domainNames := make([]string, 0, len(routes))
123 for domainName := range routes {136 for domainName := range routes {
124- domainJobs, err := s.ListBackups(ctx, domainName)137+ domainNames = append(domainNames, domainName)
125- if err != nil {138+ }
126- return nil, err139+ sort.Strings(domainNames)
140+
141+ const maxWorkers = 4
142+ sem := make(chan struct{}, maxWorkers)
143+ results := make([][]domain.BackupJob, len(domainNames))
144+ errCh := make(chan error, 1)
145+ var wg sync.WaitGroup
146+
147+ for i, domainName := range domainNames {
148+ select {
149+ case <-ctx.Done():
150+ return nil, ctx.Err()
151+ default:
127 }152 }
153+
154+ wg.Add(1)
155+ sem <- struct{}{}
156+ go func(idx int, name string) {
157+ defer wg.Done()
158+ defer func() { <-sem }()
159+
160+ domainJobs, err := s.ListBackups(ctx, name)
161+ if err != nil {
162+ select {
163+ case errCh <- err:
164+ default:
165+ }
166+ return
167+ }
168+ results[idx] = domainJobs
169+ }(i, domainName)
170+ }
171+
172+ wg.Wait()
173+ select {
174+ case err := <-errCh:
175+ return nil, err
176+ default:
177+ }
178+
179+ jobs := make([]domain.BackupJob, 0)
180+ for _, domainJobs := range results {
128 jobs = append(jobs, domainJobs...)181 jobs = append(jobs, domainJobs...)
129 }182 }
183+
130 return jobs, nil184 return jobs, nil
131 }185 }
132 186
187+func newBackupJobID(started time.Time) string {
188+ random := make([]byte, 4)
189+ if _, err := rand.Read(random); err != nil {
190+ return fmt.Sprintf("%s-%d", started.Format(time.RFC3339Nano), time.Now().UnixNano())
191+ }
192+ return fmt.Sprintf("%s-%s", started.Format(time.RFC3339Nano), hex.EncodeToString(random))
193+}
194+
133 func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error) {195 func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error) {
134 if len(dbs) == 0 {196 if len(dbs) == 0 {
135 return domain.DBInfo{}, fmt.Errorf("no supported database attachments detected")197 return domain.DBInfo{}, fmt.Errorf("no supported database attachments detected")
M internal/usecase/container/service.go +2 -0
@@ -1430,6 +1430,7 @@ func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Att
14301430 ContainerID: container.ID,
14311431 Status: container.Status,
14321432 Network: network,
1433+ Ports: append([]int(nil), container.Ports...),
14331434 }
14341435 result[ownerDomain] = append(result[ownerDomain], attachment)
14351436 }
@@ -1467,6 +1468,7 @@ func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Co
14671468 ContainerID: container.ID,
14681469 Status: container.Status,
14691470 Network: network,
1471+ Ports: append([]int(nil), container.Ports...),
14701472 }
14711473 attachments = append(attachments, attachment)
14721474 }
@@ -1430,6 +1430,7 @@ func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Att
1430 ContainerID: container.ID,1430 ContainerID: container.ID,
1431 Status: container.Status,1431 Status: container.Status,
1432 Network: network,1432 Network: network,
1433+ Ports: append([]int(nil), container.Ports...),
1433 }1434 }
1434 result[ownerDomain] = append(result[ownerDomain], attachment)1435 result[ownerDomain] = append(result[ownerDomain], attachment)
1435 }1436 }
@@ -1467,6 +1468,7 @@ func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Co
1467 ContainerID: container.ID,1468 ContainerID: container.ID,
1468 Status: container.Status,1469 Status: container.Status,
1469 Network: network,1470 Network: network,
1471+ Ports: append([]int(nil), container.Ports...),
1470 }1472 }
1471 attachments = append(attachments, attachment)1473 attachments = append(attachments, attachment)
1472 }1474 }
M internal/usecase/cron/scheduler.go +37 -6
@@ -18,6 +18,8 @@ type Scheduler struct {
1818 stopCh chan struct{}
1919 log zerowrap.Logger
2020 nowFn func() time.Time
21+ maxJobs int
22+ started atomic.Bool
2123 }
2224
2325 type entry struct {
@@ -36,6 +38,7 @@ func NewScheduler(log zerowrap.Logger) *Scheduler {
3638 entries: make(map[string]*entry),
3739 stopCh: make(chan struct{}),
3840 log: log,
41+ maxJobs: 4,
3942 nowFn: func() time.Time {
4043 return time.Now().UTC()
4144 },
@@ -83,9 +86,14 @@ func (s *Scheduler) Remove(id string) {
8386
8487 // Start begins the scheduler loop.
8588 func (s *Scheduler) Start(ctx context.Context) {
89+ if !s.started.CompareAndSwap(false, true) {
90+ return
91+ }
92+
8693 ticker := time.NewTicker(time.Minute)
8794 go func() {
8895 defer ticker.Stop()
96+ s.runDue(ctx)
8997 for {
9098 select {
9199 case <-ctx.Done():
@@ -99,7 +107,11 @@ func (s *Scheduler) Start(ctx context.Context) {
99107 }()
100108 }
101109
102-// Stop stops the scheduler loop.
110+// Stop stops Scheduler processing.
111+//
112+// Stop is idempotent and safe to call multiple times. Because Stop closes
113+// Scheduler.stopCh, the scheduler instance is not restartable after Stop;
114+// create a new Scheduler to start processing again.
103115 func (s *Scheduler) Stop() {
104116 select {
105117 case <-s.stopCh:
@@ -129,26 +141,29 @@ func (s *Scheduler) List() []domain.CronEntry {
129141 return entries
130142 }
131143
132-// RunNow triggers a registered job immediately.
133-func (s *Scheduler) RunNow(id string) error {
144+// RunNow triggers a registered job immediately with the provided context.
145+func (s *Scheduler) RunNow(ctx context.Context, id string) error {
134146 e := s.getEntry(id)
135147 if e == nil {
136148 return fmt.Errorf("schedule %q not found", id)
137149 }
138150
139- return s.executeEntry(context.Background(), e)
151+ return s.executeEntry(ctx, e)
140152 }
141153
142154 func (s *Scheduler) runDue(ctx context.Context) {
143155 now := s.nowFn()
144156 entries := s.snapshotEntries()
157+ sem := make(chan struct{}, s.maxJobs)
145158 for _, e := range entries {
146159 if now.Before(e.nextRun) {
147160 continue
148161 }
162+ sem <- struct{}{}
149163
150164 e := e
151165 go func() {
166+ defer func() { <-sem }()
152167 if err := s.executeEntry(ctx, e); err != nil {
153168 s.log.Warn().Err(err).Str("schedule_id", e.id).Msg("scheduled job failed")
154169 }
@@ -163,10 +178,13 @@ func (s *Scheduler) executeEntry(ctx context.Context, e *entry) error {
163178 defer e.running.Store(false)
164179
165180 now := s.nowFn()
166- err := e.job(ctx)
181+ jobErr := e.job(ctx)
167182
168183 nextRun, nextErr := calculateNextRun(now, e.schedule)
169184 if nextErr != nil {
185+ if jobErr != nil {
186+ return fmt.Errorf("job failed: %w; also failed to calculate next run: %v", jobErr, nextErr)
187+ }
170188 return nextErr
171189 }
172190
@@ -175,7 +193,7 @@ func (s *Scheduler) executeEntry(ctx context.Context, e *entry) error {
175193 e.nextRun = nextRun
176194 s.mu.Unlock()
177195
178- return err
196+ return jobErr
179197 }
180198
181199 func (s *Scheduler) getEntry(id string) *entry {
@@ -195,6 +213,19 @@ func (s *Scheduler) snapshotEntries() []*entry {
195213 return entries
196214 }
197215
216+// calculateNextRun computes the next execution time using UTC-only preset rules.
217+//
218+// Current MVP behavior is intentionally fixed to UTC because domain.CronSchedule
219+// has no timezone field yet:
220+// - daily: 02:00 UTC
221+// - weekly: Sunday 03:00 UTC
222+// - monthly: first day of month 04:00 UTC
223+//
224+// This means users in non-UTC timezones should account for UTC offsets.
225+//
226+// TODO: add timezone support on domain.CronSchedule once timezone-aware
227+// scheduling is introduced. Default timezone should be the host system timezone
228+// when no explicit timezone is configured.
198229 func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {
199230 now = now.UTC()
200231
@@ -18,6 +18,8 @@ type Scheduler struct {
18 stopCh chan struct{}18 stopCh chan struct{}
19 log zerowrap.Logger19 log zerowrap.Logger
20 nowFn func() time.Time20 nowFn func() time.Time
21+ maxJobs int
22+ started atomic.Bool
21 }23 }
22 24
23 type entry struct {25 type entry struct {
@@ -36,6 +38,7 @@ func NewScheduler(log zerowrap.Logger) *Scheduler {
36 entries: make(map[string]*entry),38 entries: make(map[string]*entry),
37 stopCh: make(chan struct{}),39 stopCh: make(chan struct{}),
38 log: log,40 log: log,
41+ maxJobs: 4,
39 nowFn: func() time.Time {42 nowFn: func() time.Time {
40 return time.Now().UTC()43 return time.Now().UTC()
41 },44 },
@@ -83,9 +86,14 @@ func (s *Scheduler) Remove(id string) {
83 86
84 // Start begins the scheduler loop.87 // Start begins the scheduler loop.
85 func (s *Scheduler) Start(ctx context.Context) {88 func (s *Scheduler) Start(ctx context.Context) {
89+ if !s.started.CompareAndSwap(false, true) {
90+ return
91+ }
92+
86 ticker := time.NewTicker(time.Minute)93 ticker := time.NewTicker(time.Minute)
87 go func() {94 go func() {
88 defer ticker.Stop()95 defer ticker.Stop()
96+ s.runDue(ctx)
89 for {97 for {
90 select {98 select {
91 case <-ctx.Done():99 case <-ctx.Done():
@@ -99,7 +107,11 @@ func (s *Scheduler) Start(ctx context.Context) {
99 }()107 }()
100 }108 }
101 109
102-// Stop stops the scheduler loop.110+// Stop stops Scheduler processing.
111+//
112+// Stop is idempotent and safe to call multiple times. Because Stop closes
113+// Scheduler.stopCh, the scheduler instance is not restartable after Stop;
114+// create a new Scheduler to start processing again.
103 func (s *Scheduler) Stop() {115 func (s *Scheduler) Stop() {
104 select {116 select {
105 case <-s.stopCh:117 case <-s.stopCh:
@@ -129,26 +141,29 @@ func (s *Scheduler) List() []domain.CronEntry {
129 return entries141 return entries
130 }142 }
131 143
132-// RunNow triggers a registered job immediately.144+// RunNow triggers a registered job immediately with the provided context.
133-func (s *Scheduler) RunNow(id string) error {145+func (s *Scheduler) RunNow(ctx context.Context, id string) error {
134 e := s.getEntry(id)146 e := s.getEntry(id)
135 if e == nil {147 if e == nil {
136 return fmt.Errorf("schedule %q not found", id)148 return fmt.Errorf("schedule %q not found", id)
137 }149 }
138 150
139- return s.executeEntry(context.Background(), e)151+ return s.executeEntry(ctx, e)
140 }152 }
141 153
142 func (s *Scheduler) runDue(ctx context.Context) {154 func (s *Scheduler) runDue(ctx context.Context) {
143 now := s.nowFn()155 now := s.nowFn()
144 entries := s.snapshotEntries()156 entries := s.snapshotEntries()
157+ sem := make(chan struct{}, s.maxJobs)
145 for _, e := range entries {158 for _, e := range entries {
146 if now.Before(e.nextRun) {159 if now.Before(e.nextRun) {
147 continue160 continue
148 }161 }
162+ sem <- struct{}{}
149 163
150 e := e164 e := e
151 go func() {165 go func() {
166+ defer func() { <-sem }()
152 if err := s.executeEntry(ctx, e); err != nil {167 if err := s.executeEntry(ctx, e); err != nil {
153 s.log.Warn().Err(err).Str("schedule_id", e.id).Msg("scheduled job failed")168 s.log.Warn().Err(err).Str("schedule_id", e.id).Msg("scheduled job failed")
154 }169 }
@@ -163,10 +178,13 @@ func (s *Scheduler) executeEntry(ctx context.Context, e *entry) error {
163 defer e.running.Store(false)178 defer e.running.Store(false)
164 179
165 now := s.nowFn()180 now := s.nowFn()
166- err := e.job(ctx)181+ jobErr := e.job(ctx)
167 182
168 nextRun, nextErr := calculateNextRun(now, e.schedule)183 nextRun, nextErr := calculateNextRun(now, e.schedule)
169 if nextErr != nil {184 if nextErr != nil {
185+ if jobErr != nil {
186+ return fmt.Errorf("job failed: %w; also failed to calculate next run: %v", jobErr, nextErr)
187+ }
170 return nextErr188 return nextErr
171 }189 }
172 190
@@ -175,7 +193,7 @@ func (s *Scheduler) executeEntry(ctx context.Context, e *entry) error {
175 e.nextRun = nextRun193 e.nextRun = nextRun
176 s.mu.Unlock()194 s.mu.Unlock()
177 195
178- return err196+ return jobErr
179 }197 }
180 198
181 func (s *Scheduler) getEntry(id string) *entry {199 func (s *Scheduler) getEntry(id string) *entry {
@@ -195,6 +213,19 @@ func (s *Scheduler) snapshotEntries() []*entry {
195 return entries213 return entries
196 }214 }
197 215
216+// calculateNextRun computes the next execution time using UTC-only preset rules.
217+//
218+// Current MVP behavior is intentionally fixed to UTC because domain.CronSchedule
219+// has no timezone field yet:
220+// - daily: 02:00 UTC
221+// - weekly: Sunday 03:00 UTC
222+// - monthly: first day of month 04:00 UTC
223+//
224+// This means users in non-UTC timezones should account for UTC offsets.
225+//
226+// TODO: add timezone support on domain.CronSchedule once timezone-aware
227+// scheduling is introduced. Default timezone should be the host system timezone
228+// when no explicit timezone is configured.
198 func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {229 func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) {
199 now = now.UTC()230 now = now.UTC()
200 231
M internal/usecase/cron/scheduler_test.go +41 -8
@@ -12,13 +12,43 @@ import (
1212 "github.com/stretchr/testify/require"
1313 )
1414
15-func TestCalculateNextRunHourly(t *testing.T) {
15+func TestCalculateNextRunPresets(t *testing.T) {
1616 now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC)
1717
18- next, err := calculateNextRun(now, domain.CronSchedule{Preset: domain.ScheduleHourly})
18+ tests := []struct {
19+ name string
20+ schedule domain.BackupSchedule
21+ expected time.Time
22+ }{
23+ {
24+ name: "hourly",
25+ schedule: domain.ScheduleHourly,
26+ expected: time.Date(2026, 2, 7, 13, 0, 0, 0, time.UTC),
27+ },
28+ {
29+ name: "daily",
30+ schedule: domain.ScheduleDaily,
31+ expected: time.Date(2026, 2, 8, 2, 0, 0, 0, time.UTC),
32+ },
33+ {
34+ name: "weekly",
35+ schedule: domain.ScheduleWeekly,
36+ expected: time.Date(2026, 2, 8, 3, 0, 0, 0, time.UTC),
37+ },
38+ {
39+ name: "monthly",
40+ schedule: domain.ScheduleMonthly,
41+ expected: time.Date(2026, 3, 1, 4, 0, 0, 0, time.UTC),
42+ },
43+ }
1944
20- require.NoError(t, err)
21- assert.Equal(t, time.Date(2026, 2, 7, 13, 0, 0, 0, time.UTC), next)
45+ for _, tt := range tests {
46+ t.Run(tt.name, func(t *testing.T) {
47+ next, err := calculateNextRun(now, domain.CronSchedule{Preset: tt.schedule})
48+ require.NoError(t, err)
49+ assert.Equal(t, tt.expected, next)
50+ })
51+ }
2252 }
2353
2454 func TestSchedulerAddListAndRunNow(t *testing.T) {
@@ -38,7 +68,7 @@ func TestSchedulerAddListAndRunNow(t *testing.T) {
3868 assert.Equal(t, "backup-daily", entries[0].ID)
3969 assert.Equal(t, "daily backup", entries[0].Name)
4070
41- err = s.RunNow("backup-daily")
71+ err = s.RunNow(context.Background(), "backup-daily")
4272 require.NoError(t, err)
4373 assert.Equal(t, 1, runs)
4474
@@ -60,16 +90,19 @@ func TestSchedulerRunNowRejectsConcurrentRun(t *testing.T) {
6090 require.NoError(t, err)
6191
6292 var wg sync.WaitGroup
93+ firstErrCh := make(chan error, 1)
6394 wg.Add(1)
6495 go func() {
6596 defer wg.Done()
66- _ = s.RunNow("backup-hourly")
97+ firstErrCh <- s.RunNow(context.Background(), "backup-hourly")
6798 }()
6899
69100 <-started
70- err = s.RunNow("backup-hourly")
71- assert.Error(t, err)
101+ err2 := s.RunNow(context.Background(), "backup-hourly")
102+ assert.Error(t, err2)
72103
73104 close(release)
74105 wg.Wait()
106+ err1 := <-firstErrCh
107+ require.NoError(t, err1)
75108 }
@@ -12,13 +12,43 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
15-func TestCalculateNextRunHourly(t *testing.T) {15+func TestCalculateNextRunPresets(t *testing.T) {
16 now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC)16 now := time.Date(2026, 2, 7, 12, 34, 20, 0, time.UTC)
17 17
18- next, err := calculateNextRun(now, domain.CronSchedule{Preset: domain.ScheduleHourly})18+ tests := []struct {
19+ name string
20+ schedule domain.BackupSchedule
21+ expected time.Time
22+ }{
23+ {
24+ name: "hourly",
25+ schedule: domain.ScheduleHourly,
26+ expected: time.Date(2026, 2, 7, 13, 0, 0, 0, time.UTC),
27+ },
28+ {
29+ name: "daily",
30+ schedule: domain.ScheduleDaily,
31+ expected: time.Date(2026, 2, 8, 2, 0, 0, 0, time.UTC),
32+ },
33+ {
34+ name: "weekly",
35+ schedule: domain.ScheduleWeekly,
36+ expected: time.Date(2026, 2, 8, 3, 0, 0, 0, time.UTC),
37+ },
38+ {
39+ name: "monthly",
40+ schedule: domain.ScheduleMonthly,
41+ expected: time.Date(2026, 3, 1, 4, 0, 0, 0, time.UTC),
42+ },
43+ }
19 44
20- require.NoError(t, err)45+ for _, tt := range tests {
21- assert.Equal(t, time.Date(2026, 2, 7, 13, 0, 0, 0, time.UTC), next)46+ t.Run(tt.name, func(t *testing.T) {
47+ next, err := calculateNextRun(now, domain.CronSchedule{Preset: tt.schedule})
48+ require.NoError(t, err)
49+ assert.Equal(t, tt.expected, next)
50+ })
51+ }
22 }52 }
23 53
24 func TestSchedulerAddListAndRunNow(t *testing.T) {54 func TestSchedulerAddListAndRunNow(t *testing.T) {
@@ -38,7 +68,7 @@ func TestSchedulerAddListAndRunNow(t *testing.T) {
38 assert.Equal(t, "backup-daily", entries[0].ID)68 assert.Equal(t, "backup-daily", entries[0].ID)
39 assert.Equal(t, "daily backup", entries[0].Name)69 assert.Equal(t, "daily backup", entries[0].Name)
40 70
41- err = s.RunNow("backup-daily")71+ err = s.RunNow(context.Background(), "backup-daily")
42 require.NoError(t, err)72 require.NoError(t, err)
43 assert.Equal(t, 1, runs)73 assert.Equal(t, 1, runs)
44 74
@@ -60,16 +90,19 @@ func TestSchedulerRunNowRejectsConcurrentRun(t *testing.T) {
60 require.NoError(t, err)90 require.NoError(t, err)
61 91
62 var wg sync.WaitGroup92 var wg sync.WaitGroup
93+ firstErrCh := make(chan error, 1)
63 wg.Add(1)94 wg.Add(1)
64 go func() {95 go func() {
65 defer wg.Done()96 defer wg.Done()
66- _ = s.RunNow("backup-hourly")97+ firstErrCh <- s.RunNow(context.Background(), "backup-hourly")
67 }()98 }()
68 99
69 <-started100 <-started
70- err = s.RunNow("backup-hourly")101+ err2 := s.RunNow(context.Background(), "backup-hourly")
71- assert.Error(t, err)102+ assert.Error(t, err2)
72 103
73 close(release)104 close(release)
74 wg.Wait()105 wg.Wait()
106+ err1 := <-firstErrCh
107+ require.NoError(t, err1)
75 }108 }

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