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
- 28497b70d70337734a57fd086b74696af5f0d1db
- author
- brice · · 2026-02-07 13:00 UTC
- committer
- brice · · 2026-02-07 14:40 UTC
- parent
- b2d1306
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
23 files · +584 -138
M docs/config/backups.md +3 -2
| @@ -46,13 +46,13 @@ monthly = 12 | |||
| 46 | 46 | Backups are stored under: | |
| 47 | 47 | ||
| 48 | 48 | ```text | |
| 49 | - | <storage_dir>/backups/<domain>/<db>/<schedule>/<timestamp>.bak | |
| 49 | + | <storage_dir>/<domain>/<db>/<schedule>/<timestamp>.bak | |
| 50 | 50 | ``` | |
| 51 | 51 | ||
| 52 | 52 | Example: | |
| 53 | 53 | ||
| 54 | 54 | ```text | |
| 55 | - | ~/.gordon/backups/backups/app.example.com/postgres/daily/20260207T110000Z.bak | |
| 55 | + | ~/.gordon/backups/app.example.com/postgres/daily/20260207T110000Z.bak | |
| 56 | 56 | ``` | |
| 57 | 57 | ||
| 58 | 58 | ## Notes | |
| @@ -60,6 +60,7 @@ Example: | |||
| 60 | 60 | - Backups require PostgreSQL attachment containers to be running. | |
| 61 | 61 | - The backup command executes inside the database container using Gordon runtime APIs. | |
| 62 | 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 | 65 | ## Related | |
| 65 | 66 | ||
| @@ -46,13 +46,13 @@ monthly = 12 | |||||
| 46 | Backups are stored under: | 46 | Backups are stored under: | ||
| 47 | 47 | ||||
| 48 | ```text | 48 | ```text | ||
| 49 | - | <storage_dir>/backups/<domain>/<db>/<schedule>/<timestamp>.bak | 49 | + | <storage_dir>/<domain>/<db>/<schedule>/<timestamp>.bak |
| 50 | ``` | 50 | ``` | ||
| 51 | 51 | ||||
| 52 | Example: | 52 | Example: | ||
| 53 | 53 | ||||
| 54 | ```text | 54 | ```text | ||
| 55 | - | ~/.gordon/backups/backups/app.example.com/postgres/daily/20260207T110000Z.bak | 55 | + | ~/.gordon/backups/app.example.com/postgres/daily/20260207T110000Z.bak |
| 56 | ``` | 56 | ``` | ||
| 57 | 57 | ||||
| 58 | ## Notes | 58 | ## 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 | ## Related | 65 | ## Related | ||
| 65 | 66 | ||||
M docs/config/reference.md +9 -4
| @@ -134,6 +134,9 @@ hourly = 0 # Keep N hourly backups per DB | |||
| 134 | 134 | daily = 0 # Keep N daily backups per DB | |
| 135 | 135 | weekly = 0 # Keep N weekly backups per DB | |
| 136 | 136 | 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 | 142 | ## Default Values Summary | |
| @@ -179,10 +182,12 @@ monthly = 0 # Keep N monthly backups per DB | |||
| 179 | 182 | | `volumes.preserve` | `true` | Keep volumes | | |
| 180 | 183 | | `backups.enabled` | `false` | Backup service disabled | | |
| 181 | 184 | | `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. | |
| 186 | 191 | ||
| 187 | 192 | ## Environment Variables | |
| 188 | 193 | ||
| @@ -134,6 +134,9 @@ hourly = 0 # Keep N hourly backups per DB | |||||
| 134 | daily = 0 # Keep N daily backups per DB | 134 | daily = 0 # Keep N daily backups per DB | ||
| 135 | weekly = 0 # Keep N weekly backups per DB | 135 | weekly = 0 # Keep N weekly backups per DB | ||
| 136 | monthly = 0 # Keep N monthly backups per DB | 136 | 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 Summary | 142 | ## 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 Variables | 192 | ## Environment Variables | ||
| 188 | 193 | ||||
M internal/adapters/dto/admin_backups.go +11 -11
| @@ -4,17 +4,17 @@ import "time" | |||
| 4 | 4 | ||
| 5 | 5 | // BackupJob represents backup metadata in admin API responses. | |
| 6 | 6 | 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"` | |
| 18 | 18 | } | |
| 19 | 19 | ||
| 20 | 20 | // 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/spf13/cobra" | |
| 8 | 9 | ) | |
| @@ -14,8 +15,7 @@ func newBackupCmd() *cobra.Command { | |||
| 14 | 15 | Short: "Manage database backups", | |
| 15 | 16 | Long: `Manage database backups through Gordon's admin API. | |
| 16 | 17 | ||
| 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.`, | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 21 | 21 | cmd.AddCommand(newBackupListCmd()) | |
| @@ -34,7 +34,7 @@ func newBackupListCmd() *cobra.Command { | |||
| 34 | 34 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 35 | 35 | client, isRemote := GetRemoteClient() | |
| 36 | 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 | 40 | domainName := "" | |
| @@ -51,9 +51,10 @@ func newBackupListCmd() *cobra.Command { | |||
| 51 | 51 | fmt.Println("No backups found") | |
| 52 | 52 | return nil | |
| 53 | 53 | } | |
| 54 | + | fmt.Println("DOMAIN\tDB\tSTATUS\tSTARTED_AT\tFILE_PATH") | |
| 54 | 55 | ||
| 55 | 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 | 60 | return nil | |
| @@ -71,7 +72,7 @@ func newBackupRunCmd() *cobra.Command { | |||
| 71 | 72 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 72 | 73 | client, isRemote := GetRemoteClient() | |
| 73 | 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 | 78 | result, err := client.RunBackup(context.Background(), args[0], dbName) | |
| @@ -79,7 +80,8 @@ func newBackupRunCmd() *cobra.Command { | |||
| 79 | 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 | 85 | return nil | |
| 84 | 86 | }, | |
| 85 | 87 | } | |
| @@ -96,7 +98,7 @@ func newBackupDetectCmd() *cobra.Command { | |||
| 96 | 98 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 97 | 99 | client, isRemote := GetRemoteClient() | |
| 98 | 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 | 104 | dbs, err := client.DetectDatabases(context.Background(), args[0]) | |
| @@ -108,6 +110,7 @@ func newBackupDetectCmd() *cobra.Command { | |||
| 108 | 110 | fmt.Println("No supported databases detected") | |
| 109 | 111 | return nil | |
| 110 | 112 | } | |
| 113 | + | fmt.Println("NAME\tTYPE\tHOST\tPORT\tIMAGE") | |
| 111 | 114 | ||
| 112 | 115 | for _, db := range dbs { | |
| 113 | 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 | 128 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 126 | 129 | client, isRemote := GetRemoteClient() | |
| 127 | 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 | 134 | jobs, err := client.BackupStatus(context.Background()) | |
| @@ -137,12 +140,20 @@ func newBackupStatusCmd() *cobra.Command { | |||
| 137 | 140 | fmt.Println("No backup status available") | |
| 138 | 141 | return nil | |
| 139 | 142 | } | |
| 143 | + | fmt.Println("DOMAIN\tDB\tSTATUS\tSTARTED_AT") | |
| 140 | 144 | ||
| 141 | 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 | 149 | 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 | + | } | |
| @@ -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/--token | 18 | + | 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 nil | 52 | 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 nil | 60 | 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 nil | 85 | 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 nil | 111 | 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 nil | 141 | 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 nil | 149 | 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 ( | |||
| 9 | 9 | "net/http" | |
| 10 | 10 | "strconv" | |
| 11 | 11 | "strings" | |
| 12 | + | "time" | |
| 12 | 13 | ||
| 13 | 14 | "github.com/bnema/zerowrap" | |
| 14 | 15 | ||
| @@ -82,6 +83,17 @@ func toRouteResponse(r domain.Route) dto.Route { | |||
| 82 | 83 | } | |
| 83 | 84 | ||
| 84 | 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 | 97 | return dto.BackupJob{ | |
| 86 | 98 | ID: job.ID, | |
| 87 | 99 | Domain: job.Domain, | |
| @@ -89,8 +101,8 @@ func toBackupJobResponse(job domain.BackupJob) dto.BackupJob { | |||
| 89 | 101 | Schedule: string(job.Schedule), | |
| 90 | 102 | Type: string(job.Type), | |
| 91 | 103 | Status: string(job.Status), | |
| 92 | - | StartedAt: job.StartedAt, | |
| 93 | - | CompletedAt: job.CompletedAt, | |
| 104 | + | StartedAt: startedAt, | |
| 105 | + | CompletedAt: completedAt, | |
| 94 | 106 | SizeBytes: job.SizeBytes, | |
| 95 | 107 | FilePath: job.FilePath, | |
| 96 | 108 | Error: job.Error, | |
| @@ -120,18 +132,13 @@ func NewHandler( | |||
| 120 | 132 | registrySvc in.RegistryService, | |
| 121 | 133 | eventBus out.EventPublisher, | |
| 122 | 134 | log zerowrap.Logger, | |
| 123 | - | backupSvc ...in.BackupService, | |
| 135 | + | backupSvc in.BackupService, | |
| 124 | 136 | ) *Handler { | |
| 125 | - | var backup in.BackupService | |
| 126 | - | if len(backupSvc) > 0 { | |
| 127 | - | backup = backupSvc[0] | |
| 128 | - | } | |
| 129 | - | ||
| 130 | 137 | return &Handler{ | |
| 131 | 138 | configSvc: configSvc, | |
| 132 | 139 | authSvc: authSvc, | |
| 133 | 140 | containerSvc: containerSvc, | |
| 134 | - | backupSvc: backup, | |
| 141 | + | backupSvc: backupSvc, | |
| 135 | 142 | healthSvc: healthSvc, | |
| 136 | 143 | secretSvc: secretSvc, | |
| 137 | 144 | logSvc: logSvc, | |
| @@ -853,9 +860,13 @@ func (h *Handler) handleBackupsDomainRun(w http.ResponseWriter, r *http.Request, | |||
| 853 | 860 | ||
| 854 | 861 | result, err := h.backupSvc.RunBackup(ctx, backupDomain, req.DB) | |
| 855 | 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 | 866 | 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 | 871 | h.sendJSON(w, http.StatusOK, dto.BackupRunResponse{ | |
| 861 | 872 | 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 | return | 866 | 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) { | |||
| 34 | 34 | containerSvc := inmocks.NewMockContainerService(t) | |
| 35 | 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 | 39 | tests := []struct { | |
| 40 | 40 | name string | |
| @@ -97,7 +97,7 @@ func TestHandler_RoutesPost_RequiresWriteScope(t *testing.T) { | |||
| 97 | 97 | secretSvc := inmocks.NewMockSecretService(t) | |
| 98 | 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 | 102 | routeJSON := `{"domain": "app.example.com", "image": "myapp:latest"}` | |
| 103 | 103 | ||
| @@ -158,7 +158,7 @@ func TestHandler_RoutesPut_RequiresWriteScope(t *testing.T) { | |||
| 158 | 158 | secretSvc := inmocks.NewMockSecretService(t) | |
| 159 | 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 | 163 | routeJSON := `{"image": "myapp:v2"}` | |
| 164 | 164 | ||
| @@ -203,7 +203,7 @@ func TestHandler_RoutesDelete_RequiresWriteScope(t *testing.T) { | |||
| 203 | 203 | containerSvc := inmocks.NewMockContainerService(t) | |
| 204 | 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 | 208 | tests := []struct { | |
| 209 | 209 | name string | |
| @@ -247,7 +247,7 @@ func TestHandler_SecretsGet_RequiresReadScope(t *testing.T) { | |||
| 247 | 247 | containerSvc := inmocks.NewMockContainerService(t) | |
| 248 | 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 | 252 | tests := []struct { | |
| 253 | 253 | name string | |
| @@ -304,7 +304,7 @@ func TestHandler_SecretsPost_RequiresWriteScope(t *testing.T) { | |||
| 304 | 304 | containerSvc := inmocks.NewMockContainerService(t) | |
| 305 | 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 | 309 | secretsJSON := `{"API_KEY": "secret123"}` | |
| 310 | 310 | ||
| @@ -353,7 +353,7 @@ func TestHandler_SecretsDelete_RequiresWriteScope(t *testing.T) { | |||
| 353 | 353 | containerSvc := inmocks.NewMockContainerService(t) | |
| 354 | 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 | 358 | tests := []struct { | |
| 359 | 359 | name string | |
| @@ -397,7 +397,7 @@ func TestHandler_AttachmentSecretsPost(t *testing.T) { | |||
| 397 | 397 | containerSvc := inmocks.NewMockContainerService(t) | |
| 398 | 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 | 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 | 423 | containerSvc := inmocks.NewMockContainerService(t) | |
| 424 | 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 | 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 | 450 | containerSvc := inmocks.NewMockContainerService(t) | |
| 451 | 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 | 455 | tests := []struct { | |
| 456 | 456 | name string | |
| @@ -504,7 +504,7 @@ func TestHandler_Config_RequiresReadScope(t *testing.T) { | |||
| 504 | 504 | containerSvc := inmocks.NewMockContainerService(t) | |
| 505 | 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 | 509 | tests := []struct { | |
| 510 | 510 | name string | |
| @@ -561,7 +561,7 @@ func TestHandler_Reload_RequiresWriteScope(t *testing.T) { | |||
| 561 | 561 | containerSvc := inmocks.NewMockContainerService(t) | |
| 562 | 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 | 566 | tests := []struct { | |
| 567 | 567 | name string | |
| @@ -615,7 +615,7 @@ func TestHandler_RoutesGet_ReturnsRoutes(t *testing.T) { | |||
| 615 | 615 | containerSvc := inmocks.NewMockContainerService(t) | |
| 616 | 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 | 620 | expectedRoutes := []domain.Route{ | |
| 621 | 621 | {Domain: "app1.example.com", Image: "app1:latest"}, | |
| @@ -650,7 +650,7 @@ func TestHandler_RoutesGet_SingleRoute(t *testing.T) { | |||
| 650 | 650 | containerSvc := inmocks.NewMockContainerService(t) | |
| 651 | 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 | 655 | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(&domain.Route{ | |
| 656 | 656 | Domain: "app.example.com", Image: "app:latest", | |
| @@ -681,7 +681,7 @@ func TestHandler_RoutesGet_NotFound(t *testing.T) { | |||
| 681 | 681 | containerSvc := inmocks.NewMockContainerService(t) | |
| 682 | 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 | 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 | 725 | secretSvc := inmocks.NewMockSecretService(t) | |
| 726 | 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 | 730 | configSvc.EXPECT().AddRoute(mock.Anything, mock.Anything).Return(tt.mockErr) | |
| 731 | 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 | 748 | secretSvc := inmocks.NewMockSecretService(t) | |
| 749 | 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 | 753 | tests := []struct { | |
| 754 | 754 | name string | |
| @@ -802,7 +802,7 @@ func TestHandler_RoutesPut_MissingDomain(t *testing.T) { | |||
| 802 | 802 | containerSvc := inmocks.NewMockContainerService(t) | |
| 803 | 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 | 807 | req := httptest.NewRequest("PUT", "/admin/routes/", bytes.NewBufferString(`{"image": "app:latest"}`)) | |
| 808 | 808 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -819,7 +819,7 @@ func TestHandler_RoutesDelete_MissingDomain(t *testing.T) { | |||
| 819 | 819 | containerSvc := inmocks.NewMockContainerService(t) | |
| 820 | 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 | 824 | req := httptest.NewRequest("DELETE", "/admin/routes/", nil) | |
| 825 | 825 | req = req.WithContext(ctxWithScopes("admin:routes:write")) | |
| @@ -836,7 +836,7 @@ func TestHandler_Secrets_MissingDomain(t *testing.T) { | |||
| 836 | 836 | containerSvc := inmocks.NewMockContainerService(t) | |
| 837 | 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 | 841 | req := httptest.NewRequest("GET", "/admin/secrets/", nil) | |
| 842 | 842 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| @@ -853,7 +853,7 @@ func TestHandler_SecretsDelete_MissingKey(t *testing.T) { | |||
| 853 | 853 | containerSvc := inmocks.NewMockContainerService(t) | |
| 854 | 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 | 858 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com", nil) | |
| 859 | 859 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| @@ -870,7 +870,7 @@ func TestHandler_MethodNotAllowed(t *testing.T) { | |||
| 870 | 870 | containerSvc := inmocks.NewMockContainerService(t) | |
| 871 | 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 | 875 | tests := []struct { | |
| 876 | 876 | method string | |
| @@ -903,7 +903,7 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) { | |||
| 903 | 903 | containerSvc := inmocks.NewMockContainerService(t) | |
| 904 | 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 | 908 | tests := []struct { | |
| 909 | 909 | name string | |
| @@ -933,7 +933,7 @@ func TestHandler_Restart_ContainerNotFound(t *testing.T) { | |||
| 933 | 933 | containerSvc := inmocks.NewMockContainerService(t) | |
| 934 | 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 | 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 | 952 | containerSvc := inmocks.NewMockContainerService(t) | |
| 953 | 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 | 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 | 974 | containerSvc := inmocks.NewMockContainerService(t) | |
| 975 | 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 | 979 | tests := []struct { | |
| 980 | 980 | name string | |
| @@ -1021,7 +1021,7 @@ func TestHandler_Tags_InvalidRepository(t *testing.T) { | |||
| 1021 | 1021 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1022 | 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 | 1026 | tests := []struct { | |
| 1027 | 1027 | name string | |
| @@ -1052,7 +1052,7 @@ func TestHandler_Tags_Success(t *testing.T) { | |||
| 1052 | 1052 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1053 | 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 | 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 | 1079 | secretSvc := inmocks.NewMockSecretService(t) | |
| 1080 | 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 | 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 | 1099 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1100 | 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 | 1104 | body := `{"REDIS_URL": "redis://localhost:6379"}` | |
| 1105 | 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 | 1117 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1118 | 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 | 1122 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis/REDIS_URL", nil) | |
| 1123 | 1123 | req = req.WithContext(ctxWithScopes("admin:secrets:read")) | |
| @@ -1134,7 +1134,7 @@ func TestHandler_AttachmentSecretsDelete_RequiresKey(t *testing.T) { | |||
| 1134 | 1134 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1135 | 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 | 1139 | req := httptest.NewRequest("DELETE", "/admin/secrets/app.example.com/attachments/redis", nil) | |
| 1140 | 1140 | req = req.WithContext(ctxWithScopes("admin:secrets:write")) | |
| @@ -1152,7 +1152,7 @@ func TestHandler_AttachmentSecretsPost_InvalidJSON(t *testing.T) { | |||
| 1152 | 1152 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1153 | 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 | 1157 | body := `{not valid json` | |
| 1158 | 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 | 1171 | containerSvc := inmocks.NewMockContainerService(t) | |
| 1172 | 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 | 1176 | req := httptest.NewRequest("GET", "/admin/unknown", nil) | |
| 1177 | 1177 | req = req.WithContext(ctxWithScopes("admin:*:*")) | |
| @@ -1181,3 +1181,88 @@ func TestHandler_NotFound(t *testing.T) { | |||
| 1181 | 1181 | ||
| 1182 | 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 | + | } | |
| @@ -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 string | 40 | 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 string | 209 | 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 string | 253 | 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 string | 359 | 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 string | 456 | 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 string | 510 | 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 string | 567 | 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 string | 754 | 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 string | 876 | 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 string | 909 | 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 string | 980 | 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 string | 1027 | 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 ( | |||
| 13 | 13 | "sort" | |
| 14 | 14 | "strconv" | |
| 15 | 15 | "strings" | |
| 16 | + | "time" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/bnema/zerowrap" | |
| 18 | 19 | cerrdefs "github.com/containerd/errdefs" | |
| @@ -1134,11 +1135,21 @@ func (r *Runtime) ExecInContainer(ctx context.Context, containerID string, cmd [ | |||
| 1134 | 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 | 1146 | if err != nil { | |
| 1139 | 1147 | return nil, log.WrapErr(err, "failed to attach to exec") | |
| 1140 | 1148 | } | |
| 1141 | 1149 | defer attachResp.Close() | |
| 1150 | + | if deadline, ok := attachCtx.Deadline(); ok && attachResp.Conn != nil { | |
| 1151 | + | _ = attachResp.Conn.SetReadDeadline(deadline) | |
| 1152 | + | } | |
| 1142 | 1153 | ||
| 1143 | 1154 | stdout, stderr, err := parseExecOutput(attachResp.Reader) | |
| 1144 | 1155 | if err != nil { | |
| @@ -1168,9 +1179,8 @@ func parseExecOutput(reader io.Reader) ([]byte, []byte, error) { | |||
| 1168 | 1179 | return stdout.Bytes(), stderr.Bytes(), nil | |
| 1169 | 1180 | } | |
| 1170 | 1181 | ||
| 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) { | |
| 1174 | 1184 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| 1175 | 1185 | zerowrap.FieldLayer: "adapter", | |
| 1176 | 1186 | zerowrap.FieldAdapter: "docker", | |
| @@ -1180,24 +1190,23 @@ func (r *Runtime) CopyFromContainer(ctx context.Context, containerID, srcPath st | |||
| 1180 | 1190 | }) | |
| 1181 | 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 | 1194 | if err != nil { | |
| 1185 | 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 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) | |
| 1191 | 1200 | if err != nil { | |
| 1201 | + | _ = tarReader.Close() | |
| 1192 | 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") | |
| 1196 | - | return data, nil | |
| 1205 | + | return reader, nil | |
| 1197 | 1206 | } | |
| 1198 | 1207 | ||
| 1199 | 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 | 1210 | tr := tar.NewReader(reader) | |
| 1202 | 1211 | ||
| 1203 | 1212 | // The path in the tar may be relative or absolute | |
| @@ -1217,15 +1226,23 @@ func extractFileFromTar(reader io.Reader, targetPath string) ([]byte, error) { | |||
| 1217 | 1226 | if header.Typeflag == tar.TypeReg { | |
| 1218 | 1227 | headerName := filepath.Base(header.Name) | |
| 1219 | 1228 | 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 | |
| 1225 | 1241 | } | |
| 1226 | 1242 | } | |
| 1227 | 1243 | } | |
| 1228 | 1244 | ||
| 1245 | + | _ = reader.Close() | |
| 1229 | 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 | 1284 | // Copy the env file from the container | |
| 1268 | - | data, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath) | |
| 1285 | + | reader, err := r.CopyFromContainer(ctx, tempContainerID, envFilePath) | |
| 1269 | 1286 | if err != nil { | |
| 1270 | 1287 | 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 | 1296 | log.Info().Int("size", len(data)).Msg("env file extracted from image") | |
| 1274 | 1297 | 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(), nil | 1179 | 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 file | 1198 | + | // 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 absolute | 1212 | // 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, err | 1231 | + | go func() { |
| 1223 | - | } | 1232 | + | defer reader.Close() |
| 1224 | - | return data, nil | 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 | |||
| 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 container | 1284 | // 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, err | 1287 | 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, nil | 1297 | return data, nil | ||
M internal/adapters/out/docker/runtime_exec_test.go +15 -4
| @@ -23,10 +23,21 @@ func TestParseExecOutput_SplitsStdoutAndStderr(t *testing.T) { | |||
| 23 | 23 | func TestRuntime_ExecInContainer_RejectsEmptyCommand(t *testing.T) { | |
| 24 | 24 | r := &Runtime{} | |
| 25 | 25 | ||
| 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 | + | } | |
| 30 | 41 | } | |
| 31 | 42 | ||
| 32 | 43 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "io" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 9 | 10 | "sort" | |
| 10 | 11 | "strings" | |
| 12 | + | "syscall" | |
| 11 | 13 | "time" | |
| 12 | 14 | ||
| 13 | 15 | "github.com/bnema/zerowrap" | |
| @@ -15,10 +17,7 @@ import ( | |||
| 15 | 17 | "github.com/bnema/gordon/internal/domain" | |
| 16 | 18 | ) | |
| 17 | 19 | ||
| 18 | - | const ( | |
| 19 | - | backupDirName = "backups" | |
| 20 | - | backupTimestampLayout = "20060102T150405Z" | |
| 21 | - | ) | |
| 20 | + | const backupTimestampLayout = "20060102T150405Z" | |
| 22 | 21 | ||
| 23 | 22 | // BackupStorage implements backup artifact persistence on local filesystem. | |
| 24 | 23 | type BackupStorage struct { | |
| @@ -28,8 +27,7 @@ type BackupStorage struct { | |||
| 28 | 27 | ||
| 29 | 28 | // NewBackupStorage creates a new filesystem backup storage. | |
| 30 | 29 | 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 { | |
| 33 | 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 | 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 | 47 | if err := os.MkdirAll(backupDir, 0750); err != nil { | |
| 50 | 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 | 52 | finalPath := filepath.Join(backupDir, fileName) | |
| 55 | 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 | 56 | if err != nil { | |
| 59 | 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 | 79 | // Get retrieves a backup file by path. | |
| 82 | 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 | 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 | 92 | // List returns backups for a domain, optionally filtered by schedule. | |
| 95 | 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 | 95 | if _, err := os.Stat(domainRoot); err != nil { | |
| 98 | 96 | if os.IsNotExist(err) { | |
| 99 | 97 | return []domain.BackupJob{}, nil | |
| @@ -163,7 +161,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom | |||
| 163 | 161 | ||
| 164 | 162 | // Delete removes a backup file. | |
| 165 | 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 | 165 | return fmt.Errorf("backup path escapes storage root") | |
| 168 | 166 | } | |
| 169 | 167 | return os.Remove(path) | |
| @@ -200,6 +198,9 @@ func (s *BackupStorage) ApplyRetention(ctx context.Context, domainName string, p | |||
| 200 | 198 | } | |
| 201 | 199 | return deleted, err | |
| 202 | 200 | } | |
| 201 | + | if err := s.removeEmptyBackupDirs(group[idx].FilePath, domainName); err != nil { | |
| 202 | + | return deleted, err | |
| 203 | + | } | |
| 203 | 204 | deleted++ | |
| 204 | 205 | } | |
| 205 | 206 | } | |
| @@ -207,6 +208,36 @@ func (s *BackupStorage) ApplyRetention(ctx context.Context, domainName string, p | |||
| 207 | 208 | 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 | 241 | func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSchedule) int { | |
| 211 | 242 | switch schedule { | |
| 212 | 243 | case domain.ScheduleHourly: | |
| @@ -227,13 +258,29 @@ func sanitizeBackupPathComponent(input string) string { | |||
| 227 | 258 | if clean == "" { | |
| 228 | 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 | 268 | replacer := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_") | |
| 231 | 269 | return replacer.Replace(clean) | |
| 232 | 270 | } | |
| 233 | 271 | ||
| 234 | 272 | 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) | |
| 237 | 284 | rel, err := filepath.Rel(rootClean, pathClean) | |
| 238 | 285 | if err != nil { | |
| 239 | 286 | 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{}, nil | 97 | 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, err | 199 | 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, nil | 208 | 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 false | 286 | return false | ||
M internal/adapters/out/filesystem/backup_storage_test.go +11 -2
| @@ -3,6 +3,7 @@ package filesystem | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "io" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -24,8 +25,7 @@ func TestBackupStorage_StoreAndGet(t *testing.T) { | |||
| 24 | 25 | require.NoError(t, err) | |
| 25 | 26 | defer rc.Close() | |
| 26 | 27 | ||
| 27 | - | data := make([]byte, len(payload)) | |
| 28 | - | _, err = rc.Read(data) | |
| 28 | + | data, err := io.ReadAll(rc) | |
| 29 | 29 | require.NoError(t, err) | |
| 30 | 30 | assert.Equal(t, payload, data) | |
| 31 | 31 | } | |
| @@ -84,3 +84,12 @@ func TestBackupStorage_ApplyRetention(t *testing.T) { | |||
| 84 | 84 | require.NoError(t, err) | |
| 85 | 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 | + | } | |
| @@ -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 { | |||
| 1825 | 1825 | v.SetDefault("volumes.prefix", "gordon") | |
| 1826 | 1826 | v.SetDefault("volumes.preserve", true) | |
| 1827 | 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 | 1834 | v.SetDefault("server.max_concurrent_connections", -1) // -1 = use default (10000), 0 = no limit | |
| 1829 | 1835 | v.SetDefault("server.registry_allowed_ips", []string{}) | |
| 1830 | 1836 | 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 limit | 1834 | 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 | |||
| 104 | 104 | } | |
| 105 | 105 | ||
| 106 | 106 | // 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 | 108 | ret := _mock.Called(ctx, containerID, srcPath) | |
| 109 | 109 | ||
| 110 | 110 | if len(ret) == 0 { | |
| 111 | 111 | panic("no return value specified for CopyFromContainer") | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 114 | - | var r0 []byte | |
| 114 | + | var r0 io.ReadCloser | |
| 115 | 115 | 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 | 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 | 120 | r0 = returnFunc(ctx, containerID, srcPath) | |
| 121 | 121 | } else { | |
| 122 | 122 | if ret.Get(0) != nil { | |
| 123 | - | r0 = ret.Get(0).([]byte) | |
| 123 | + | r0 = ret.Get(0).(io.ReadCloser) | |
| 124 | 124 | } | |
| 125 | 125 | } | |
| 126 | 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 | 167 | return _c | |
| 168 | 168 | } | |
| 169 | 169 | ||
| 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) | |
| 172 | 172 | 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 | 176 | _c.Call.Return(run) | |
| 177 | 177 | return _c | |
| 178 | 178 | } | |
| @@ -104,23 +104,23 @@ func (_c *MockContainerRuntime_ConnectContainerToNetwork_Call) RunAndReturn(run | |||||
| 104 | } | 104 | } | ||
| 105 | 105 | ||||
| 106 | // CopyFromContainer provides a mock function for the type MockContainerRuntime | 106 | // 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 []byte | 114 | + | var r0 io.ReadCloser |
| 115 | var r1 error | 115 | 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 _c | 167 | 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 _c | 172 | 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 _c | 177 | return _c | ||
| 178 | } | 178 | } | ||
M internal/boundaries/out/runtime.go +1 -1
| @@ -62,7 +62,7 @@ type ContainerRuntime interface { | |||
| 62 | 62 | ||
| 63 | 63 | // In-container operations | |
| 64 | 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 | 67 | // Network management | |
| 68 | 68 | CreateNetwork(ctx context.Context, name string, options map[string]string) error | |
| @@ -62,7 +62,7 @@ type ContainerRuntime interface { | |||||
| 62 | 62 | ||||
| 63 | // In-container operations | 63 | // 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 management | 67 | // Network management | ||
| 68 | CreateNetwork(ctx context.Context, name string, options map[string]string) error | 68 | 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 { | |||
| 47 | 47 | Port int | |
| 48 | 48 | ContainerID string | |
| 49 | 49 | ImageName string | |
| 50 | + | // Credentials contains sensitive values (passwords/tokens). | |
| 51 | + | // Never log or expose this map in API responses. | |
| 50 | 52 | 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 | 67 | // BackupJob represents a scheduled or manual backup operation. | |
| 54 | 68 | type BackupJob struct { | |
| 55 | 69 | ID string | |
| @@ -47,9 +47,23 @@ type DBInfo struct { | |||||
| 47 | Port int | 47 | Port int | ||
| 48 | ContainerID string | 48 | ContainerID string | ||
| 49 | ImageName string | 49 | ImageName string | ||
| 50 | + | // Credentials contains sensitive values (passwords/tokens). | |||
| 51 | + | // Never log or expose this map in API responses. | |||
| 50 | Credentials map[string]string | 52 | 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 string | 69 | ID string | ||
M internal/domain/container.go +1 -0
| @@ -28,6 +28,7 @@ type Attachment struct { | |||
| 28 | 28 | ContainerID string | |
| 29 | 29 | Status string | |
| 30 | 30 | Network string | |
| 31 | + | Ports []int | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 34 | // RouteInfo combines route configuration with runtime state. | |
| @@ -28,6 +28,7 @@ type Attachment struct { | |||||
| 28 | ContainerID string | 28 | ContainerID string | ||
| 29 | Status string | 29 | Status string | ||
| 30 | Network string | 30 | 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 | |||
| 3 | 3 | import "time" | |
| 4 | 4 | ||
| 5 | 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 | 10 | type CronSchedule struct { | |
| 7 | 11 | Preset BackupSchedule | |
| 8 | 12 | } | |
| @@ -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 BackupSchedule | 11 | Preset BackupSchedule | ||
| 8 | } | 12 | } | ||
M internal/usecase/backup/detector.go +28 -1
| @@ -13,12 +13,17 @@ func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domai | |||
| 13 | 13 | ||
| 14 | 14 | switch { | |
| 15 | 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 | 20 | return domain.DBInfo{ | |
| 17 | 21 | Type: domain.DBTypePostgreSQL, | |
| 22 | + | Version: postgresVersionFromImage(a.Image), | |
| 18 | 23 | Domain: domainName, | |
| 19 | 24 | Name: a.Name, | |
| 20 | 25 | Host: a.Name, | |
| 21 | - | Port: 5432, | |
| 26 | + | Port: port, | |
| 22 | 27 | ContainerID: a.ContainerID, | |
| 23 | 28 | ImageName: a.Image, | |
| 24 | 29 | }, true | |
| @@ -26,3 +31,25 @@ func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domai | |||
| 26 | 31 | 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 | + | } | |
| @@ -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 | }, true | 29 | }, true | ||
| @@ -26,3 +31,25 @@ func detectDatabaseFromAttachment(domainName string, a domain.Attachment) (domai | |||||
| 26 | return domain.DBInfo{}, false | 31 | 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 | |||
| 42 | 42 | networkName := fmt.Sprintf("gordon-backup-it-%s-%d", version, time.Now().UnixNano()) | |
| 43 | 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 | 48 | require.NoError(t, runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"})) | |
| 47 | 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 | 92 | ContainerID: container.ID, | |
| 91 | 93 | Status: container.Status, | |
| 92 | 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 | 135 | func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containerID string, timeout time.Duration) error { | |
| 133 | 136 | deadline := time.Now().Add(timeout) | |
| 134 | 137 | for time.Now().Before(deadline) { | |
| 138 | + | select { | |
| 139 | + | case <-ctx.Done(): | |
| 140 | + | return ctx.Err() | |
| 141 | + | default: | |
| 142 | + | } | |
| 143 | + | ||
| 135 | 144 | res, err := runtime.ExecInContainer(ctx, containerID, []string{"pg_isready", "-U", "postgres", "-d", "appdb"}) | |
| 136 | 145 | if err == nil && res.ExitCode == 0 { | |
| 137 | 146 | return nil | |
| @@ -142,7 +151,7 @@ func waitForPostgresReady(ctx context.Context, runtime *docker.Runtime, containe | |||
| 142 | 151 | } | |
| 143 | 152 | ||
| 144 | 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 | 155 | res, err := runtime.ExecInContainer(ctx, containerID, []string{"sh", "-c", cmd}) | |
| 147 | 156 | if err != nil { | |
| 148 | 157 | 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 nil | 146 | 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 err | 157 | return err | ||
M internal/usecase/backup/service.go +68 -6
| @@ -3,8 +3,13 @@ package backup | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "crypto/rand" | |
| 7 | + | "encoding/hex" | |
| 8 | + | "errors" | |
| 6 | 9 | "fmt" | |
| 10 | + | "sort" | |
| 7 | 11 | "strings" | |
| 12 | + | "sync" | |
| 8 | 13 | "time" | |
| 9 | 14 | ||
| 10 | 15 | "github.com/bnema/zerowrap" | |
| @@ -14,6 +19,8 @@ import ( | |||
| 14 | 19 | "github.com/bnema/gordon/internal/domain" | |
| 15 | 20 | ) | |
| 16 | 21 | ||
| 22 | + | const backupExecTimeout = 30 * time.Minute | |
| 23 | + | ||
| 17 | 24 | // Service orchestrates backup operations. | |
| 18 | 25 | type Service struct { | |
| 19 | 26 | runtime out.ContainerRuntime | |
| @@ -75,8 +82,14 @@ func (s *Service) RunBackup(ctx context.Context, domainName, dbName string) (*do | |||
| 75 | 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 | 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 | 93 | return nil, err | |
| 81 | 94 | } | |
| 82 | 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 | 104 | job := domain.BackupJob{ | |
| 92 | - | ID: started.Format(time.RFC3339Nano), | |
| 105 | + | ID: newBackupJobID(started), | |
| 93 | 106 | Domain: domainName, | |
| 94 | 107 | DBName: db.Name, | |
| 95 | 108 | Type: domain.BackupTypeLogical, | |
| @@ -119,17 +132,66 @@ func (s *Service) RestorePITR(context.Context, string, time.Time) error { | |||
| 119 | 132 | // Status returns aggregate backup status for all managed domains. | |
| 120 | 133 | func (s *Service) Status(ctx context.Context) ([]domain.BackupJob, error) { | |
| 121 | 134 | routes := s.containerSvc.List(ctx) | |
| 122 | - | jobs := make([]domain.BackupJob, 0) | |
| 135 | + | domainNames := make([]string, 0, len(routes)) | |
| 123 | 136 | 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: | |
| 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 | 181 | jobs = append(jobs, domainJobs...) | |
| 129 | 182 | } | |
| 183 | + | ||
| 130 | 184 | 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 | 195 | func selectDatabase(dbs []domain.DBInfo, requested string) (domain.DBInfo, error) { | |
| 134 | 196 | if len(dbs) == 0 { | |
| 135 | 197 | 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.ContainerRuntime | 26 | 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, err | 93 | 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, err | 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: | |||
| 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, nil | 184 | 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 | |||
| 1430 | 1430 | ContainerID: container.ID, | |
| 1431 | 1431 | Status: container.Status, | |
| 1432 | 1432 | Network: network, | |
| 1433 | + | Ports: append([]int(nil), container.Ports...), | |
| 1433 | 1434 | } | |
| 1434 | 1435 | result[ownerDomain] = append(result[ownerDomain], attachment) | |
| 1435 | 1436 | } | |
| @@ -1467,6 +1468,7 @@ func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Co | |||
| 1467 | 1468 | ContainerID: container.ID, | |
| 1468 | 1469 | Status: container.Status, | |
| 1469 | 1470 | Network: network, | |
| 1471 | + | Ports: append([]int(nil), container.Ports...), | |
| 1470 | 1472 | } | |
| 1471 | 1473 | attachments = append(attachments, attachment) | |
| 1472 | 1474 | } | |
| @@ -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 { | |||
| 18 | 18 | stopCh chan struct{} | |
| 19 | 19 | log zerowrap.Logger | |
| 20 | 20 | nowFn func() time.Time | |
| 21 | + | maxJobs int | |
| 22 | + | started atomic.Bool | |
| 21 | 23 | } | |
| 22 | 24 | ||
| 23 | 25 | type entry struct { | |
| @@ -36,6 +38,7 @@ func NewScheduler(log zerowrap.Logger) *Scheduler { | |||
| 36 | 38 | entries: make(map[string]*entry), | |
| 37 | 39 | stopCh: make(chan struct{}), | |
| 38 | 40 | log: log, | |
| 41 | + | maxJobs: 4, | |
| 39 | 42 | nowFn: func() time.Time { | |
| 40 | 43 | return time.Now().UTC() | |
| 41 | 44 | }, | |
| @@ -83,9 +86,14 @@ func (s *Scheduler) Remove(id string) { | |||
| 83 | 86 | ||
| 84 | 87 | // Start begins the scheduler loop. | |
| 85 | 88 | func (s *Scheduler) Start(ctx context.Context) { | |
| 89 | + | if !s.started.CompareAndSwap(false, true) { | |
| 90 | + | return | |
| 91 | + | } | |
| 92 | + | ||
| 86 | 93 | ticker := time.NewTicker(time.Minute) | |
| 87 | 94 | go func() { | |
| 88 | 95 | defer ticker.Stop() | |
| 96 | + | s.runDue(ctx) | |
| 89 | 97 | for { | |
| 90 | 98 | select { | |
| 91 | 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 | 115 | func (s *Scheduler) Stop() { | |
| 104 | 116 | select { | |
| 105 | 117 | case <-s.stopCh: | |
| @@ -129,26 +141,29 @@ func (s *Scheduler) List() []domain.CronEntry { | |||
| 129 | 141 | return entries | |
| 130 | 142 | } | |
| 131 | 143 | ||
| 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 { | |
| 134 | 146 | e := s.getEntry(id) | |
| 135 | 147 | if e == nil { | |
| 136 | 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 | 154 | func (s *Scheduler) runDue(ctx context.Context) { | |
| 143 | 155 | now := s.nowFn() | |
| 144 | 156 | entries := s.snapshotEntries() | |
| 157 | + | sem := make(chan struct{}, s.maxJobs) | |
| 145 | 158 | for _, e := range entries { | |
| 146 | 159 | if now.Before(e.nextRun) { | |
| 147 | 160 | continue | |
| 148 | 161 | } | |
| 162 | + | sem <- struct{}{} | |
| 149 | 163 | ||
| 150 | 164 | e := e | |
| 151 | 165 | go func() { | |
| 166 | + | defer func() { <-sem }() | |
| 152 | 167 | if err := s.executeEntry(ctx, e); err != nil { | |
| 153 | 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 | 178 | defer e.running.Store(false) | |
| 164 | 179 | ||
| 165 | 180 | now := s.nowFn() | |
| 166 | - | err := e.job(ctx) | |
| 181 | + | jobErr := e.job(ctx) | |
| 167 | 182 | ||
| 168 | 183 | nextRun, nextErr := calculateNextRun(now, e.schedule) | |
| 169 | 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 | 188 | return nextErr | |
| 171 | 189 | } | |
| 172 | 190 | ||
| @@ -175,7 +193,7 @@ func (s *Scheduler) executeEntry(ctx context.Context, e *entry) error { | |||
| 175 | 193 | e.nextRun = nextRun | |
| 176 | 194 | s.mu.Unlock() | |
| 177 | 195 | ||
| 178 | - | return err | |
| 196 | + | return jobErr | |
| 179 | 197 | } | |
| 180 | 198 | ||
| 181 | 199 | func (s *Scheduler) getEntry(id string) *entry { | |
| @@ -195,6 +213,19 @@ func (s *Scheduler) snapshotEntries() []*entry { | |||
| 195 | 213 | 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 | 229 | func calculateNextRun(now time.Time, schedule domain.CronSchedule) (time.Time, error) { | |
| 199 | 230 | now = now.UTC() | |
| 200 | 231 | ||
| @@ -18,6 +18,8 @@ type Scheduler struct { | |||||
| 18 | stopCh chan struct{} | 18 | stopCh chan struct{} | ||
| 19 | log zerowrap.Logger | 19 | log zerowrap.Logger | ||
| 20 | nowFn func() time.Time | 20 | 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 entries | 141 | 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 | continue | 160 | continue | ||
| 148 | } | 161 | } | ||
| 162 | + | sem <- struct{}{} | |||
| 149 | 163 | ||||
| 150 | e := e | 164 | 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 nextErr | 188 | return nextErr | ||
| 171 | } | 189 | } | ||
| 172 | 190 | ||||
| @@ -175,7 +193,7 @@ func (s *Scheduler) executeEntry(ctx context.Context, e *entry) error { | |||||
| 175 | e.nextRun = nextRun | 193 | e.nextRun = nextRun | ||
| 176 | s.mu.Unlock() | 194 | s.mu.Unlock() | ||
| 177 | 195 | ||||
| 178 | - | return err | 196 | + | 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 entries | 213 | 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 ( | |||
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | func TestCalculateNextRunHourly(t *testing.T) { | |
| 15 | + | func TestCalculateNextRunPresets(t *testing.T) { | |
| 16 | 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) | |
| 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 | + | } | |
| 22 | 52 | } | |
| 23 | 53 | ||
| 24 | 54 | func TestSchedulerAddListAndRunNow(t *testing.T) { | |
| @@ -38,7 +68,7 @@ func TestSchedulerAddListAndRunNow(t *testing.T) { | |||
| 38 | 68 | assert.Equal(t, "backup-daily", entries[0].ID) | |
| 39 | 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 | 72 | require.NoError(t, err) | |
| 43 | 73 | assert.Equal(t, 1, runs) | |
| 44 | 74 | ||
| @@ -60,16 +90,19 @@ func TestSchedulerRunNowRejectsConcurrentRun(t *testing.T) { | |||
| 60 | 90 | require.NoError(t, err) | |
| 61 | 91 | ||
| 62 | 92 | var wg sync.WaitGroup | |
| 93 | + | firstErrCh := make(chan error, 1) | |
| 63 | 94 | wg.Add(1) | |
| 64 | 95 | go func() { | |
| 65 | 96 | defer wg.Done() | |
| 66 | - | _ = s.RunNow("backup-hourly") | |
| 97 | + | firstErrCh <- s.RunNow(context.Background(), "backup-hourly") | |
| 67 | 98 | }() | |
| 68 | 99 | ||
| 69 | 100 | <-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) | |
| 72 | 103 | ||
| 73 | 104 | close(release) | |
| 74 | 105 | wg.Wait() | |
| 106 | + | err1 := <-firstErrCh | |
| 107 | + | require.NoError(t, err1) | |
| 75 | 108 | } | |
| @@ -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.WaitGroup | 92 | 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 | <-started | 100 | <-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 | } | ||