fix: scope volume pruning and unique backup files
- commit
- 3520c0f5937a7280509c8e1fe954dfad79be2504
- author
- brice · · 2026-04-25 09:39 UTC
- parent
- 9167cd9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +203 -17
M docs/cli/auth.md +1 -1
| @@ -139,7 +139,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m` | |||
| 139 | 139 | ||
| 140 | 140 | Registry scopes: `push`, `pull`, `push,pull` | |
| 141 | 141 | ||
| 142 | - | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:secrets:read`, `admin:secrets:write` | |
| 142 | + | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write` | |
| 143 | 143 | ||
| 144 | 144 | Combine scopes with commas: | |
| 145 | 145 | ||
| @@ -139,7 +139,7 @@ Standard Go durations also work: `24h`, `30m`, `1h30m` | |||||
| 139 | 139 | ||||
| 140 | Registry scopes: `push`, `pull`, `push,pull` | 140 | Registry scopes: `push`, `pull`, `push,pull` | ||
| 141 | 141 | ||||
| 142 | - | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:secrets:read`, `admin:secrets:write` | 142 | + | Admin scopes: `admin:*:*`, `admin:routes:read`, `admin:routes:write`, `admin:config:read`, `admin:config:write`, `admin:status:read`, `admin:logs:read`, `admin:volumes:read`, `admin:volumes:write`, `admin:secrets:read`, `admin:secrets:write` |
| 143 | 143 | ||||
| 144 | Combine scopes with commas: | 144 | Combine scopes with commas: | ||
| 145 | 145 | ||||
M docs/config/auth.md +2 -0
| @@ -93,6 +93,8 @@ Admin scopes (for remote CLI): | |||
| 93 | 93 | | `admin:config:write` | Config write access | | |
| 94 | 94 | | `admin:status:read` | Read-only status/health | | |
| 95 | 95 | | `admin:logs:read` | Read-only logs access | | |
| 96 | + | | `admin:volumes:read` | List Docker volumes | | |
| 97 | + | | `admin:volumes:write` | Prune eligible Gordon-managed volumes | | |
| 96 | 98 | | `admin:secrets:read` | List secret keys | | |
| 97 | 99 | | `admin:secrets:write` | Set/delete secrets | | |
| 98 | 100 | ||
| @@ -93,6 +93,8 @@ Admin scopes (for remote CLI): | |||||
| 93 | | `admin:config:write` | Config write access | | 93 | | `admin:config:write` | Config write access | | ||
| 94 | | `admin:status:read` | Read-only status/health | | 94 | | `admin:status:read` | Read-only status/health | | ||
| 95 | | `admin:logs:read` | Read-only logs access | | 95 | | `admin:logs:read` | Read-only logs access | | ||
| 96 | + | | `admin:volumes:read` | List Docker volumes | | |||
| 97 | + | | `admin:volumes:write` | Prune eligible Gordon-managed volumes | | |||
| 96 | | `admin:secrets:read` | List secret keys | | 98 | | `admin:secrets:read` | List secret keys | | ||
| 97 | | `admin:secrets:write` | Set/delete secrets | | 99 | | `admin:secrets:write` | Set/delete secrets | | ||
| 98 | 100 | ||||
M internal/adapters/in/http/admin/handler_test.go +58 -0
| @@ -98,6 +98,64 @@ func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler { | |||
| 98 | 98 | return NewHandler(deps) | |
| 99 | 99 | } | |
| 100 | 100 | ||
| 101 | + | func TestHandler_VolumesGet_RequiresVolumesReadScope(t *testing.T) { | |
| 102 | + | volumeSvc := inmocks.NewMockVolumeService(t) | |
| 103 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc }) | |
| 104 | + | ||
| 105 | + | tests := []struct { | |
| 106 | + | name string | |
| 107 | + | scopes []string | |
| 108 | + | wantStatus int | |
| 109 | + | }{ | |
| 110 | + | {name: "volumes read access granted", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusOK}, | |
| 111 | + | {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK}, | |
| 112 | + | {name: "status read denied", scopes: []string{"admin:status:read"}, wantStatus: http.StatusForbidden}, | |
| 113 | + | {name: "volumes write denied", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusForbidden}, | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | for _, tt := range tests { | |
| 117 | + | t.Run(tt.name, func(t *testing.T) { | |
| 118 | + | if tt.wantStatus == http.StatusOK { | |
| 119 | + | volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{}, nil).Once() | |
| 120 | + | } | |
| 121 | + | req := httptest.NewRequest(http.MethodGet, "/admin/volumes", nil) | |
| 122 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |
| 123 | + | rec := httptest.NewRecorder() | |
| 124 | + | handler.ServeHTTP(rec, req) | |
| 125 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 126 | + | }) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) { | |
| 131 | + | volumeSvc := inmocks.NewMockVolumeService(t) | |
| 132 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc }) | |
| 133 | + | ||
| 134 | + | tests := []struct { | |
| 135 | + | name string | |
| 136 | + | scopes []string | |
| 137 | + | wantStatus int | |
| 138 | + | }{ | |
| 139 | + | {name: "volumes write access granted", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusOK}, | |
| 140 | + | {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK}, | |
| 141 | + | {name: "config write denied", scopes: []string{"admin:config:write"}, wantStatus: http.StatusForbidden}, | |
| 142 | + | {name: "volumes read denied", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusForbidden}, | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | for _, tt := range tests { | |
| 146 | + | t.Run(tt.name, func(t *testing.T) { | |
| 147 | + | if tt.wantStatus == http.StatusOK { | |
| 148 | + | volumeSvc.EXPECT().PruneVolumes(mock.Anything, true).Return(&domain.VolumePruneReport{}, []*domain.VolumeInfo{}, nil).Once() | |
| 149 | + | } | |
| 150 | + | req := httptest.NewRequest(http.MethodPost, "/admin/volumes/prune", bytes.NewBufferString(`{"dry_run":true}`)) | |
| 151 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |
| 152 | + | rec := httptest.NewRecorder() | |
| 153 | + | handler.ServeHTTP(rec, req) | |
| 154 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |
| 155 | + | }) | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 101 | 159 | // Routes endpoint tests | |
| 102 | 160 | ||
| 103 | 161 | func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | |
| @@ -98,6 +98,64 @@ func newTestHandler(t *testing.T, opts ...func(*HandlerDeps)) *Handler { | |||||
| 98 | return NewHandler(deps) | 98 | return NewHandler(deps) | ||
| 99 | } | 99 | } | ||
| 100 | 100 | ||||
| 101 | + | func TestHandler_VolumesGet_RequiresVolumesReadScope(t *testing.T) { | |||
| 102 | + | volumeSvc := inmocks.NewMockVolumeService(t) | |||
| 103 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc }) | |||
| 104 | + | ||||
| 105 | + | tests := []struct { | |||
| 106 | + | name string | |||
| 107 | + | scopes []string | |||
| 108 | + | wantStatus int | |||
| 109 | + | }{ | |||
| 110 | + | {name: "volumes read access granted", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusOK}, | |||
| 111 | + | {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK}, | |||
| 112 | + | {name: "status read denied", scopes: []string{"admin:status:read"}, wantStatus: http.StatusForbidden}, | |||
| 113 | + | {name: "volumes write denied", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusForbidden}, | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | for _, tt := range tests { | |||
| 117 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 118 | + | if tt.wantStatus == http.StatusOK { | |||
| 119 | + | volumeSvc.EXPECT().ListVolumes(mock.Anything).Return([]*domain.VolumeInfo{}, nil).Once() | |||
| 120 | + | } | |||
| 121 | + | req := httptest.NewRequest(http.MethodGet, "/admin/volumes", nil) | |||
| 122 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 123 | + | rec := httptest.NewRecorder() | |||
| 124 | + | handler.ServeHTTP(rec, req) | |||
| 125 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 126 | + | }) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestHandler_VolumesPrune_RequiresVolumesWriteScope(t *testing.T) { | |||
| 131 | + | volumeSvc := inmocks.NewMockVolumeService(t) | |||
| 132 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.VolumeSvc = volumeSvc }) | |||
| 133 | + | ||||
| 134 | + | tests := []struct { | |||
| 135 | + | name string | |||
| 136 | + | scopes []string | |||
| 137 | + | wantStatus int | |||
| 138 | + | }{ | |||
| 139 | + | {name: "volumes write access granted", scopes: []string{"admin:volumes:write"}, wantStatus: http.StatusOK}, | |||
| 140 | + | {name: "all admin access granted", scopes: []string{"admin:*:*"}, wantStatus: http.StatusOK}, | |||
| 141 | + | {name: "config write denied", scopes: []string{"admin:config:write"}, wantStatus: http.StatusForbidden}, | |||
| 142 | + | {name: "volumes read denied", scopes: []string{"admin:volumes:read"}, wantStatus: http.StatusForbidden}, | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | for _, tt := range tests { | |||
| 146 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 147 | + | if tt.wantStatus == http.StatusOK { | |||
| 148 | + | volumeSvc.EXPECT().PruneVolumes(mock.Anything, true).Return(&domain.VolumePruneReport{}, []*domain.VolumeInfo{}, nil).Once() | |||
| 149 | + | } | |||
| 150 | + | req := httptest.NewRequest(http.MethodPost, "/admin/volumes/prune", bytes.NewBufferString(`{"dry_run":true}`)) | |||
| 151 | + | req = req.WithContext(ctxWithScopes(tt.scopes...)) | |||
| 152 | + | rec := httptest.NewRecorder() | |||
| 153 | + | handler.ServeHTTP(rec, req) | |||
| 154 | + | assert.Equal(t, tt.wantStatus, rec.Code) | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 157 | + | } | |||
| 158 | + | ||||
| 101 | // Routes endpoint tests | 159 | // Routes endpoint tests | ||
| 102 | 160 | ||||
| 103 | func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | 161 | func TestHandler_RoutesGet_RequiresReadScope(t *testing.T) { | ||
M internal/adapters/in/http/admin/handler_volumes.go +4 -4
| @@ -19,8 +19,8 @@ func (h *Handler) handleListVolumes(w http.ResponseWriter, r *http.Request) { | |||
| 19 | 19 | ||
| 20 | 20 | ctx := r.Context() | |
| 21 | 21 | ||
| 22 | - | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |
| 23 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |
| 22 | + | if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionRead) { | |
| 23 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:read") | |
| 24 | 24 | return | |
| 25 | 25 | } | |
| 26 | 26 | ||
| @@ -63,8 +63,8 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) { | |||
| 63 | 63 | ||
| 64 | 64 | ctx := r.Context() | |
| 65 | 65 | ||
| 66 | - | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | |
| 67 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | |
| 66 | + | if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionWrite) { | |
| 67 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:write") | |
| 68 | 68 | return | |
| 69 | 69 | } | |
| 70 | 70 | ||
| @@ -19,8 +19,8 @@ func (h *Handler) handleListVolumes(w http.ResponseWriter, r *http.Request) { | |||||
| 19 | 19 | ||||
| 20 | ctx := r.Context() | 20 | ctx := r.Context() | ||
| 21 | 21 | ||||
| 22 | - | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | 22 | + | if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionRead) { |
| 23 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | 23 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:read") |
| 24 | return | 24 | return | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| @@ -63,8 +63,8 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) { | |||||
| 63 | 63 | ||||
| 64 | ctx := r.Context() | 64 | ctx := r.Context() | ||
| 65 | 65 | ||||
| 66 | - | if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) { | 66 | + | if !HasAccess(ctx, domain.AdminResourceVolumes, domain.AdminActionWrite) { |
| 67 | - | h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write") | 67 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for volumes:write") |
| 68 | return | 68 | return | ||
| 69 | } | 69 | } | ||
| 70 | 70 | ||||
M internal/adapters/out/filesystem/backup_storage.go +36 -4
| @@ -2,6 +2,8 @@ package filesystem | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/rand" | |
| 6 | + | "encoding/hex" | |
| 5 | 7 | "errors" | |
| 6 | 8 | "fmt" | |
| 7 | 9 | "io" | |
| @@ -62,11 +64,12 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche | |||
| 62 | 64 | return "", fmt.Errorf("failed to create backup path: %w", err) | |
| 63 | 65 | } | |
| 64 | 66 | ||
| 65 | - | fileName := timestamp.UTC().Format(backupTimestampLayout) + ".bak" | |
| 67 | + | fileName := timestamp.UTC().Format(time.RFC3339Nano) + "-" + randomBackupSuffix() + ".bak" | |
| 68 | + | fileName = sanitizeBackupFileName(fileName) | |
| 66 | 69 | finalPath := filepath.Join(backupDir, fileName) | |
| 67 | 70 | tmpPath := finalPath + ".tmp" | |
| 68 | 71 | ||
| 69 | - | f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | |
| 72 | + | f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) | |
| 70 | 73 | if err != nil { | |
| 71 | 74 | return "", fmt.Errorf("failed to create temp backup file: %w", err) | |
| 72 | 75 | } | |
| @@ -118,7 +121,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom | |||
| 118 | 121 | if walkErr != nil { | |
| 119 | 122 | return walkErr | |
| 120 | 123 | } | |
| 121 | - | if d.IsDir() { | |
| 124 | + | if d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") { | |
| 122 | 125 | return nil | |
| 123 | 126 | } | |
| 124 | 127 | ||
| @@ -144,7 +147,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom | |||
| 144 | 147 | ||
| 145 | 148 | startedAt := info.ModTime().UTC() | |
| 146 | 149 | base := filepath.Base(path) | |
| 147 | - | if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil { | |
| 150 | + | if ts, ok := parseBackupStartedAt(base); ok { | |
| 148 | 151 | startedAt = ts.UTC() | |
| 149 | 152 | } | |
| 150 | 153 | ||
| @@ -267,6 +270,35 @@ func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSch | |||
| 267 | 270 | } | |
| 268 | 271 | } | |
| 269 | 272 | ||
| 273 | + | func parseBackupStartedAt(base string) (time.Time, bool) { | |
| 274 | + | if !strings.HasSuffix(base, ".bak") { | |
| 275 | + | return time.Time{}, false | |
| 276 | + | } | |
| 277 | + | if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil { | |
| 278 | + | return ts, true | |
| 279 | + | } | |
| 280 | + | trimmed := strings.TrimSuffix(base, ".bak") | |
| 281 | + | if idx := strings.LastIndex(trimmed, "-"); idx >= 0 { | |
| 282 | + | trimmed = trimmed[:idx] | |
| 283 | + | } | |
| 284 | + | if ts, err := time.Parse(time.RFC3339Nano, strings.ReplaceAll(trimmed, "_", ":")); err == nil { | |
| 285 | + | return ts, true | |
| 286 | + | } | |
| 287 | + | return time.Time{}, false | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | func randomBackupSuffix() string { | |
| 291 | + | var b [8]byte | |
| 292 | + | if _, err := rand.Read(b[:]); err != nil { | |
| 293 | + | return fmt.Sprintf("%d", time.Now().UTC().UnixNano()) | |
| 294 | + | } | |
| 295 | + | return hex.EncodeToString(b[:]) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func sanitizeBackupFileName(input string) string { | |
| 299 | + | return strings.NewReplacer(":", "_").Replace(input) | |
| 300 | + | } | |
| 301 | + | ||
| 270 | 302 | func sanitizeBackupPathComponent(input string) string { | |
| 271 | 303 | clean := strings.TrimSpace(input) | |
| 272 | 304 | if clean == "" { | |
| @@ -2,6 +2,8 @@ package filesystem | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "crypto/rand" | |||
| 6 | + | "encoding/hex" | |||
| 5 | "errors" | 7 | "errors" | ||
| 6 | "fmt" | 8 | "fmt" | ||
| 7 | "io" | 9 | "io" | ||
| @@ -62,11 +64,12 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche | |||||
| 62 | return "", fmt.Errorf("failed to create backup path: %w", err) | 64 | return "", fmt.Errorf("failed to create backup path: %w", err) | ||
| 63 | } | 65 | } | ||
| 64 | 66 | ||||
| 65 | - | fileName := timestamp.UTC().Format(backupTimestampLayout) + ".bak" | 67 | + | fileName := timestamp.UTC().Format(time.RFC3339Nano) + "-" + randomBackupSuffix() + ".bak" |
| 68 | + | fileName = sanitizeBackupFileName(fileName) | |||
| 66 | finalPath := filepath.Join(backupDir, fileName) | 69 | finalPath := filepath.Join(backupDir, fileName) | ||
| 67 | tmpPath := finalPath + ".tmp" | 70 | tmpPath := finalPath + ".tmp" | ||
| 68 | 71 | ||||
| 69 | - | f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) | 72 | + | f, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) |
| 70 | if err != nil { | 73 | if err != nil { | ||
| 71 | return "", fmt.Errorf("failed to create temp backup file: %w", err) | 74 | return "", fmt.Errorf("failed to create temp backup file: %w", err) | ||
| 72 | } | 75 | } | ||
| @@ -118,7 +121,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom | |||||
| 118 | if walkErr != nil { | 121 | if walkErr != nil { | ||
| 119 | return walkErr | 122 | return walkErr | ||
| 120 | } | 123 | } | ||
| 121 | - | if d.IsDir() { | 124 | + | if d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") { |
| 122 | return nil | 125 | return nil | ||
| 123 | } | 126 | } | ||
| 124 | 127 | ||||
| @@ -144,7 +147,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom | |||||
| 144 | 147 | ||||
| 145 | startedAt := info.ModTime().UTC() | 148 | startedAt := info.ModTime().UTC() | ||
| 146 | base := filepath.Base(path) | 149 | base := filepath.Base(path) | ||
| 147 | - | if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil { | 150 | + | if ts, ok := parseBackupStartedAt(base); ok { |
| 148 | startedAt = ts.UTC() | 151 | startedAt = ts.UTC() | ||
| 149 | } | 152 | } | ||
| 150 | 153 | ||||
| @@ -267,6 +270,35 @@ func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSch | |||||
| 267 | } | 270 | } | ||
| 268 | } | 271 | } | ||
| 269 | 272 | ||||
| 273 | + | func parseBackupStartedAt(base string) (time.Time, bool) { | |||
| 274 | + | if !strings.HasSuffix(base, ".bak") { | |||
| 275 | + | return time.Time{}, false | |||
| 276 | + | } | |||
| 277 | + | if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil { | |||
| 278 | + | return ts, true | |||
| 279 | + | } | |||
| 280 | + | trimmed := strings.TrimSuffix(base, ".bak") | |||
| 281 | + | if idx := strings.LastIndex(trimmed, "-"); idx >= 0 { | |||
| 282 | + | trimmed = trimmed[:idx] | |||
| 283 | + | } | |||
| 284 | + | if ts, err := time.Parse(time.RFC3339Nano, strings.ReplaceAll(trimmed, "_", ":")); err == nil { | |||
| 285 | + | return ts, true | |||
| 286 | + | } | |||
| 287 | + | return time.Time{}, false | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func randomBackupSuffix() string { | |||
| 291 | + | var b [8]byte | |||
| 292 | + | if _, err := rand.Read(b[:]); err != nil { | |||
| 293 | + | return fmt.Sprintf("%d", time.Now().UTC().UnixNano()) | |||
| 294 | + | } | |||
| 295 | + | return hex.EncodeToString(b[:]) | |||
| 296 | + | } | |||
| 297 | + | ||||
| 298 | + | func sanitizeBackupFileName(input string) string { | |||
| 299 | + | return strings.NewReplacer(":", "_").Replace(input) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 270 | func sanitizeBackupPathComponent(input string) string { | 302 | func sanitizeBackupPathComponent(input string) string { | ||
| 271 | clean := strings.TrimSpace(input) | 303 | clean := strings.TrimSpace(input) | ||
| 272 | if clean == "" { | 304 | if clean == "" { | ||
M internal/adapters/out/filesystem/backup_storage_test.go +34 -1
| @@ -31,6 +31,39 @@ func TestBackupStorage_StoreAndGet(t *testing.T) { | |||
| 31 | 31 | assert.Equal(t, payload, data) | |
| 32 | 32 | } | |
| 33 | 33 | ||
| 34 | + | func TestBackupStorage_StoreSameSecondBackupsUseDistinctFinalAndTempPaths(t *testing.T) { | |
| 35 | + | rootDir := t.TempDir() | |
| 36 | + | storage, err := NewBackupStorage(rootDir, testLogger()) | |
| 37 | + | require.NoError(t, err) | |
| 38 | + | ||
| 39 | + | now := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC) | |
| 40 | + | firstPayload := []byte("first-backup") | |
| 41 | + | secondPayload := []byte("second-backup") | |
| 42 | + | ||
| 43 | + | firstPath, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(firstPayload)) | |
| 44 | + | require.NoError(t, err) | |
| 45 | + | secondPath, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(secondPayload)) | |
| 46 | + | require.NoError(t, err) | |
| 47 | + | ||
| 48 | + | assert.NotEqual(t, firstPath, secondPath) | |
| 49 | + | assert.NoFileExists(t, firstPath+".tmp") | |
| 50 | + | assert.NoFileExists(t, secondPath+".tmp") | |
| 51 | + | ||
| 52 | + | firstRead, err := storage.Get(context.Background(), firstPath) | |
| 53 | + | require.NoError(t, err) | |
| 54 | + | defer firstRead.Close() | |
| 55 | + | firstData, err := io.ReadAll(firstRead) | |
| 56 | + | require.NoError(t, err) | |
| 57 | + | assert.Equal(t, firstPayload, firstData) | |
| 58 | + | ||
| 59 | + | secondRead, err := storage.Get(context.Background(), secondPath) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | defer secondRead.Close() | |
| 62 | + | secondData, err := io.ReadAll(secondRead) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | assert.Equal(t, secondPayload, secondData) | |
| 65 | + | } | |
| 66 | + | ||
| 34 | 67 | func TestBackupStorage_ListWithScheduleFilter(t *testing.T) { | |
| 35 | 68 | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | |
| 36 | 69 | require.NoError(t, err) | |
| @@ -71,7 +104,7 @@ func TestBackupStorage_ApplyRetention(t *testing.T) { | |||
| 71 | 104 | require.NoError(t, err) | |
| 72 | 105 | ||
| 73 | 106 | base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC) | |
| 74 | - | for i := 0; i < 4; i++ { | |
| 107 | + | for i := range 4 { | |
| 75 | 108 | _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data"))) | |
| 76 | 109 | require.NoError(t, err) | |
| 77 | 110 | } | |
| @@ -31,6 +31,39 @@ func TestBackupStorage_StoreAndGet(t *testing.T) { | |||||
| 31 | assert.Equal(t, payload, data) | 31 | assert.Equal(t, payload, data) | ||
| 32 | } | 32 | } | ||
| 33 | 33 | ||||
| 34 | + | func TestBackupStorage_StoreSameSecondBackupsUseDistinctFinalAndTempPaths(t *testing.T) { | |||
| 35 | + | rootDir := t.TempDir() | |||
| 36 | + | storage, err := NewBackupStorage(rootDir, testLogger()) | |||
| 37 | + | require.NoError(t, err) | |||
| 38 | + | ||||
| 39 | + | now := time.Date(2026, 2, 7, 11, 0, 0, 0, time.UTC) | |||
| 40 | + | firstPayload := []byte("first-backup") | |||
| 41 | + | secondPayload := []byte("second-backup") | |||
| 42 | + | ||||
| 43 | + | firstPath, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(firstPayload)) | |||
| 44 | + | require.NoError(t, err) | |||
| 45 | + | secondPath, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, now, bytes.NewReader(secondPayload)) | |||
| 46 | + | require.NoError(t, err) | |||
| 47 | + | ||||
| 48 | + | assert.NotEqual(t, firstPath, secondPath) | |||
| 49 | + | assert.NoFileExists(t, firstPath+".tmp") | |||
| 50 | + | assert.NoFileExists(t, secondPath+".tmp") | |||
| 51 | + | ||||
| 52 | + | firstRead, err := storage.Get(context.Background(), firstPath) | |||
| 53 | + | require.NoError(t, err) | |||
| 54 | + | defer firstRead.Close() | |||
| 55 | + | firstData, err := io.ReadAll(firstRead) | |||
| 56 | + | require.NoError(t, err) | |||
| 57 | + | assert.Equal(t, firstPayload, firstData) | |||
| 58 | + | ||||
| 59 | + | secondRead, err := storage.Get(context.Background(), secondPath) | |||
| 60 | + | require.NoError(t, err) | |||
| 61 | + | defer secondRead.Close() | |||
| 62 | + | secondData, err := io.ReadAll(secondRead) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | assert.Equal(t, secondPayload, secondData) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 34 | func TestBackupStorage_ListWithScheduleFilter(t *testing.T) { | 67 | func TestBackupStorage_ListWithScheduleFilter(t *testing.T) { | ||
| 35 | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | 68 | storage, err := NewBackupStorage(t.TempDir(), testLogger()) | ||
| 36 | require.NoError(t, err) | 69 | require.NoError(t, err) | ||
| @@ -71,7 +104,7 @@ func TestBackupStorage_ApplyRetention(t *testing.T) { | |||||
| 71 | require.NoError(t, err) | 104 | require.NoError(t, err) | ||
| 72 | 105 | ||||
| 73 | base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC) | 106 | base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC) | ||
| 74 | - | for i := 0; i < 4; i++ { | 107 | + | for i := range 4 { |
| 75 | _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data"))) | 108 | _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data"))) | ||
| 76 | require.NoError(t, err) | 109 | require.NoError(t, err) | ||
| 77 | } | 110 | } | ||
M internal/domain/auth.go +7 -1
| @@ -80,6 +80,7 @@ const ( | |||
| 80 | 80 | AdminResourceConfig = "config" | |
| 81 | 81 | AdminResourceStatus = "status" | |
| 82 | 82 | AdminResourceLogs = "logs" | |
| 83 | + | AdminResourceVolumes = "volumes" | |
| 83 | 84 | AdminResourceAll = "*" | |
| 84 | 85 | ) | |
| 85 | 86 | ||
| @@ -167,7 +168,7 @@ func (s *Scope) IsRepositoryScope() bool { | |||
| 167 | 168 | // AdminScope represents an admin API scope (admin:resource:actions). | |
| 168 | 169 | // Format: admin:routes:read,write or admin:*:* for full access. | |
| 169 | 170 | type AdminScope struct { | |
| 170 | - | Resource string // routes, secrets, config, status, or * | |
| 171 | + | Resource string // routes, secrets, config, status, logs, volumes, or * | |
| 171 | 172 | Actions []string // read, write, or * | |
| 172 | 173 | } | |
| 173 | 174 | ||
| @@ -308,6 +309,11 @@ func AdminScopeLogs(actions ...string) string { | |||
| 308 | 309 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceLogs, strings.Join(actions, ",")) | |
| 309 | 310 | } | |
| 310 | 311 | ||
| 312 | + | // AdminScopeVolumes creates an admin scope for volumes with the given actions. | |
| 313 | + | func AdminScopeVolumes(actions ...string) string { | |
| 314 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceVolumes, strings.Join(actions, ",")) | |
| 315 | + | } | |
| 316 | + | ||
| 311 | 317 | // AuthStatus represents status of an authentication session. | |
| 312 | 318 | type AuthStatus struct { | |
| 313 | 319 | Valid bool | |
| @@ -80,6 +80,7 @@ const ( | |||||
| 80 | AdminResourceConfig = "config" | 80 | AdminResourceConfig = "config" | ||
| 81 | AdminResourceStatus = "status" | 81 | AdminResourceStatus = "status" | ||
| 82 | AdminResourceLogs = "logs" | 82 | AdminResourceLogs = "logs" | ||
| 83 | + | AdminResourceVolumes = "volumes" | |||
| 83 | AdminResourceAll = "*" | 84 | AdminResourceAll = "*" | ||
| 84 | ) | 85 | ) | ||
| 85 | 86 | ||||
| @@ -167,7 +168,7 @@ func (s *Scope) IsRepositoryScope() bool { | |||||
| 167 | // AdminScope represents an admin API scope (admin:resource:actions). | 168 | // AdminScope represents an admin API scope (admin:resource:actions). | ||
| 168 | // Format: admin:routes:read,write or admin:*:* for full access. | 169 | // Format: admin:routes:read,write or admin:*:* for full access. | ||
| 169 | type AdminScope struct { | 170 | type AdminScope struct { | ||
| 170 | - | Resource string // routes, secrets, config, status, or * | 171 | + | Resource string // routes, secrets, config, status, logs, volumes, or * |
| 171 | Actions []string // read, write, or * | 172 | Actions []string // read, write, or * | ||
| 172 | } | 173 | } | ||
| 173 | 174 | ||||
| @@ -308,6 +309,11 @@ func AdminScopeLogs(actions ...string) string { | |||||
| 308 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceLogs, strings.Join(actions, ",")) | 309 | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceLogs, strings.Join(actions, ",")) | ||
| 309 | } | 310 | } | ||
| 310 | 311 | ||||
| 312 | + | // AdminScopeVolumes creates an admin scope for volumes with the given actions. | |||
| 313 | + | func AdminScopeVolumes(actions ...string) string { | |||
| 314 | + | return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceVolumes, strings.Join(actions, ",")) | |||
| 315 | + | } | |||
| 316 | + | ||||
| 311 | // AuthStatus represents status of an authentication session. | 317 | // AuthStatus represents status of an authentication session. | ||
| 312 | type AuthStatus struct { | 318 | type AuthStatus struct { | ||
| 313 | Valid bool | 319 | Valid bool | ||
M internal/domain/auth_test.go +7 -0
| @@ -512,6 +512,13 @@ func TestScopesGrantAdminAccess(t *testing.T) { | |||
| 512 | 512 | action: AdminActionRead, | |
| 513 | 513 | want: true, | |
| 514 | 514 | }, | |
| 515 | + | { | |
| 516 | + | name: "volumes read grants volumes resource", | |
| 517 | + | granted: []string{"admin:volumes:read"}, | |
| 518 | + | resource: AdminResourceVolumes, | |
| 519 | + | action: AdminActionRead, | |
| 520 | + | want: true, | |
| 521 | + | }, | |
| 515 | 522 | { | |
| 516 | 523 | name: "repository scope is ignored for admin access", | |
| 517 | 524 | granted: []string{"repository:myrepo:push"}, | |
| @@ -512,6 +512,13 @@ func TestScopesGrantAdminAccess(t *testing.T) { | |||||
| 512 | action: AdminActionRead, | 512 | action: AdminActionRead, | ||
| 513 | want: true, | 513 | want: true, | ||
| 514 | }, | 514 | }, | ||
| 515 | + | { | |||
| 516 | + | name: "volumes read grants volumes resource", | |||
| 517 | + | granted: []string{"admin:volumes:read"}, | |||
| 518 | + | resource: AdminResourceVolumes, | |||
| 519 | + | action: AdminActionRead, | |||
| 520 | + | want: true, | |||
| 521 | + | }, | |||
| 515 | { | 522 | { | ||
| 516 | name: "repository scope is ignored for admin access", | 523 | name: "repository scope is ignored for admin access", | ||
| 517 | granted: []string{"repository:myrepo:push"}, | 524 | granted: []string{"repository:myrepo:push"}, | ||
M internal/usecase/volumes/service.go +8 -1
| @@ -54,7 +54,7 @@ func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.Volume | |||
| 54 | 54 | var removed []*domain.VolumeInfo | |
| 55 | 55 | ||
| 56 | 56 | for _, vol := range vols { | |
| 57 | - | if vol.InUse { | |
| 57 | + | if !isGordonManagedVolume(vol) || vol.InUse { | |
| 58 | 58 | continue | |
| 59 | 59 | } | |
| 60 | 60 | ||
| @@ -72,3 +72,10 @@ func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.Volume | |||
| 72 | 72 | ||
| 73 | 73 | return report, removed, nil | |
| 74 | 74 | } | |
| 75 | + | ||
| 76 | + | func isGordonManagedVolume(vol *domain.VolumeInfo) bool { | |
| 77 | + | if vol == nil || vol.Labels == nil { | |
| 78 | + | return false | |
| 79 | + | } | |
| 80 | + | return vol.Labels[domain.LabelManaged] == "true" | |
| 81 | + | } | |
| @@ -54,7 +54,7 @@ func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.Volume | |||||
| 54 | var removed []*domain.VolumeInfo | 54 | var removed []*domain.VolumeInfo | ||
| 55 | 55 | ||||
| 56 | for _, vol := range vols { | 56 | for _, vol := range vols { | ||
| 57 | - | if vol.InUse { | 57 | + | if !isGordonManagedVolume(vol) || vol.InUse { |
| 58 | continue | 58 | continue | ||
| 59 | } | 59 | } | ||
| 60 | 60 | ||||
| @@ -72,3 +72,10 @@ func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.Volume | |||||
| 72 | 72 | ||||
| 73 | return report, removed, nil | 73 | return report, removed, nil | ||
| 74 | } | 74 | } | ||
| 75 | + | ||||
| 76 | + | func isGordonManagedVolume(vol *domain.VolumeInfo) bool { | |||
| 77 | + | if vol == nil || vol.Labels == nil { | |||
| 78 | + | return false | |||
| 79 | + | } | |||
| 80 | + | return vol.Labels[domain.LabelManaged] == "true" | |||
| 81 | + | } | |||
M internal/usecase/volumes/service_test.go +46 -5
| @@ -31,8 +31,8 @@ func TestService_PruneVolumes_RemovesOrphaned(t *testing.T) { | |||
| 31 | 31 | runtime := outmocks.NewMockContainerRuntime(t) | |
| 32 | 32 | ||
| 33 | 33 | vols := []*domain.VolumeInfo{ | |
| 34 | - | {Name: "vol-in-use", InUse: true, Containers: []string{"web"}}, | |
| 35 | - | {Name: "vol-orphaned", InUse: false, Size: 1024}, | |
| 34 | + | {Name: "vol-in-use", InUse: true, Containers: []string{"web"}, Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 35 | + | {Name: "vol-orphaned", InUse: false, Size: 1024, Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 36 | 36 | } | |
| 37 | 37 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | |
| 38 | 38 | runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil) | |
| @@ -50,7 +50,7 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) { | |||
| 50 | 50 | runtime := outmocks.NewMockContainerRuntime(t) | |
| 51 | 51 | ||
| 52 | 52 | vols := []*domain.VolumeInfo{ | |
| 53 | - | {Name: "vol-orphaned", InUse: false, Size: 2048}, | |
| 53 | + | {Name: "vol-orphaned", InUse: false, Size: 2048, Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 54 | 54 | } | |
| 55 | 55 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | |
| 56 | 56 | ||
| @@ -62,6 +62,47 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) { | |||
| 62 | 62 | assert.Len(t, removed, 1) | |
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | + | func TestService_PruneVolumes_OnlyRemovesUnusedGordonManagedVolumes(t *testing.T) { | |
| 66 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 67 | + | ||
| 68 | + | vols := []*domain.VolumeInfo{ | |
| 69 | + | {Name: "gordon-used", InUse: true, Size: 100, Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 70 | + | {Name: "gordon-unused", InUse: false, Size: 200, Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 71 | + | {Name: "other-unused", InUse: false, Size: 300}, | |
| 72 | + | {Name: "other-labelled-false", InUse: false, Size: 400, Labels: map[string]string{domain.LabelManaged: "false"}}, | |
| 73 | + | {Name: "other-used", InUse: true, Size: 500}, | |
| 74 | + | } | |
| 75 | + | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | |
| 76 | + | runtime.EXPECT().RemoveVolume(context.Background(), "gordon-unused", false).Return(nil) | |
| 77 | + | ||
| 78 | + | svc := NewService(runtime) | |
| 79 | + | report, removed, err := svc.PruneVolumes(context.Background(), false) | |
| 80 | + | require.NoError(t, err) | |
| 81 | + | assert.Equal(t, 1, report.VolumesRemoved) | |
| 82 | + | assert.Equal(t, int64(200), report.SpaceReclaimed) | |
| 83 | + | require.Len(t, removed, 1) | |
| 84 | + | assert.Equal(t, "gordon-unused", removed[0].Name) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestService_PruneVolumes_DryRunReportsOnlyUnusedGordonManagedVolumes(t *testing.T) { | |
| 88 | + | runtime := outmocks.NewMockContainerRuntime(t) | |
| 89 | + | ||
| 90 | + | vols := []*domain.VolumeInfo{ | |
| 91 | + | {Name: "gordon-used", InUse: true, Size: 100, Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 92 | + | {Name: "gordon-unused", InUse: false, Size: 200, Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 93 | + | {Name: "other-unused", InUse: false, Size: 300}, | |
| 94 | + | } | |
| 95 | + | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | |
| 96 | + | ||
| 97 | + | svc := NewService(runtime) | |
| 98 | + | report, removed, err := svc.PruneVolumes(context.Background(), true) | |
| 99 | + | require.NoError(t, err) | |
| 100 | + | assert.Equal(t, 1, report.VolumesRemoved) | |
| 101 | + | assert.Equal(t, int64(200), report.SpaceReclaimed) | |
| 102 | + | require.Len(t, removed, 1) | |
| 103 | + | assert.Equal(t, "gordon-unused", removed[0].Name) | |
| 104 | + | } | |
| 105 | + | ||
| 65 | 106 | func TestService_PruneVolumes_AllInUse(t *testing.T) { | |
| 66 | 107 | runtime := outmocks.NewMockContainerRuntime(t) | |
| 67 | 108 | ||
| @@ -91,8 +132,8 @@ func TestService_PruneVolumes_SkipsFailedRemoval(t *testing.T) { | |||
| 91 | 132 | runtime := outmocks.NewMockContainerRuntime(t) | |
| 92 | 133 | ||
| 93 | 134 | vols := []*domain.VolumeInfo{ | |
| 94 | - | {Name: "vol-fail", InUse: false, Size: 1024}, | |
| 95 | - | {Name: "vol-ok", InUse: false, Size: 2048}, | |
| 135 | + | {Name: "vol-fail", InUse: false, Size: 1024, Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 136 | + | {Name: "vol-ok", InUse: false, Size: 2048, Labels: map[string]string{domain.LabelManaged: "true"}}, | |
| 96 | 137 | } | |
| 97 | 138 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | |
| 98 | 139 | runtime.EXPECT().RemoveVolume(context.Background(), "vol-fail", false).Return(fmt.Errorf("volume in use")) | |
| @@ -31,8 +31,8 @@ func TestService_PruneVolumes_RemovesOrphaned(t *testing.T) { | |||||
| 31 | runtime := outmocks.NewMockContainerRuntime(t) | 31 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 32 | 32 | ||||
| 33 | vols := []*domain.VolumeInfo{ | 33 | vols := []*domain.VolumeInfo{ | ||
| 34 | - | {Name: "vol-in-use", InUse: true, Containers: []string{"web"}}, | 34 | + | {Name: "vol-in-use", InUse: true, Containers: []string{"web"}, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 35 | - | {Name: "vol-orphaned", InUse: false, Size: 1024}, | 35 | + | {Name: "vol-orphaned", InUse: false, Size: 1024, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 36 | } | 36 | } | ||
| 37 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | 37 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | ||
| 38 | runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil) | 38 | runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil) | ||
| @@ -50,7 +50,7 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) { | |||||
| 50 | runtime := outmocks.NewMockContainerRuntime(t) | 50 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 51 | 51 | ||||
| 52 | vols := []*domain.VolumeInfo{ | 52 | vols := []*domain.VolumeInfo{ | ||
| 53 | - | {Name: "vol-orphaned", InUse: false, Size: 2048}, | 53 | + | {Name: "vol-orphaned", InUse: false, Size: 2048, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 54 | } | 54 | } | ||
| 55 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | 55 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | ||
| 56 | 56 | ||||
| @@ -62,6 +62,47 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) { | |||||
| 62 | assert.Len(t, removed, 1) | 62 | assert.Len(t, removed, 1) | ||
| 63 | } | 63 | } | ||
| 64 | 64 | ||||
| 65 | + | func TestService_PruneVolumes_OnlyRemovesUnusedGordonManagedVolumes(t *testing.T) { | |||
| 66 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 67 | + | ||||
| 68 | + | vols := []*domain.VolumeInfo{ | |||
| 69 | + | {Name: "gordon-used", InUse: true, Size: 100, Labels: map[string]string{domain.LabelManaged: "true"}}, | |||
| 70 | + | {Name: "gordon-unused", InUse: false, Size: 200, Labels: map[string]string{domain.LabelManaged: "true"}}, | |||
| 71 | + | {Name: "other-unused", InUse: false, Size: 300}, | |||
| 72 | + | {Name: "other-labelled-false", InUse: false, Size: 400, Labels: map[string]string{domain.LabelManaged: "false"}}, | |||
| 73 | + | {Name: "other-used", InUse: true, Size: 500}, | |||
| 74 | + | } | |||
| 75 | + | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | |||
| 76 | + | runtime.EXPECT().RemoveVolume(context.Background(), "gordon-unused", false).Return(nil) | |||
| 77 | + | ||||
| 78 | + | svc := NewService(runtime) | |||
| 79 | + | report, removed, err := svc.PruneVolumes(context.Background(), false) | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | assert.Equal(t, 1, report.VolumesRemoved) | |||
| 82 | + | assert.Equal(t, int64(200), report.SpaceReclaimed) | |||
| 83 | + | require.Len(t, removed, 1) | |||
| 84 | + | assert.Equal(t, "gordon-unused", removed[0].Name) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestService_PruneVolumes_DryRunReportsOnlyUnusedGordonManagedVolumes(t *testing.T) { | |||
| 88 | + | runtime := outmocks.NewMockContainerRuntime(t) | |||
| 89 | + | ||||
| 90 | + | vols := []*domain.VolumeInfo{ | |||
| 91 | + | {Name: "gordon-used", InUse: true, Size: 100, Labels: map[string]string{domain.LabelManaged: "true"}}, | |||
| 92 | + | {Name: "gordon-unused", InUse: false, Size: 200, Labels: map[string]string{domain.LabelManaged: "true"}}, | |||
| 93 | + | {Name: "other-unused", InUse: false, Size: 300}, | |||
| 94 | + | } | |||
| 95 | + | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | |||
| 96 | + | ||||
| 97 | + | svc := NewService(runtime) | |||
| 98 | + | report, removed, err := svc.PruneVolumes(context.Background(), true) | |||
| 99 | + | require.NoError(t, err) | |||
| 100 | + | assert.Equal(t, 1, report.VolumesRemoved) | |||
| 101 | + | assert.Equal(t, int64(200), report.SpaceReclaimed) | |||
| 102 | + | require.Len(t, removed, 1) | |||
| 103 | + | assert.Equal(t, "gordon-unused", removed[0].Name) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 65 | func TestService_PruneVolumes_AllInUse(t *testing.T) { | 106 | func TestService_PruneVolumes_AllInUse(t *testing.T) { | ||
| 66 | runtime := outmocks.NewMockContainerRuntime(t) | 107 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 67 | 108 | ||||
| @@ -91,8 +132,8 @@ func TestService_PruneVolumes_SkipsFailedRemoval(t *testing.T) { | |||||
| 91 | runtime := outmocks.NewMockContainerRuntime(t) | 132 | runtime := outmocks.NewMockContainerRuntime(t) | ||
| 92 | 133 | ||||
| 93 | vols := []*domain.VolumeInfo{ | 134 | vols := []*domain.VolumeInfo{ | ||
| 94 | - | {Name: "vol-fail", InUse: false, Size: 1024}, | 135 | + | {Name: "vol-fail", InUse: false, Size: 1024, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 95 | - | {Name: "vol-ok", InUse: false, Size: 2048}, | 136 | + | {Name: "vol-ok", InUse: false, Size: 2048, Labels: map[string]string{domain.LabelManaged: "true"}}, |
| 96 | } | 137 | } | ||
| 97 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | 138 | runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil) | ||
| 98 | runtime.EXPECT().RemoveVolume(context.Background(), "vol-fail", false).Return(fmt.Errorf("volume in use")) | 139 | runtime.EXPECT().RemoveVolume(context.Background(), "vol-fail", false).Return(fmt.Errorf("volume in use")) | ||