detached 3520c0f

fix: scope volume pruning and unique backup files

commit
3520c0f5937a7280509c8e1fe954dfad79be2504
author
brice · · 2026-04-25 09:39 UTC
parent
9167cd9
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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`
139139
140140 Registry scopes: `push`, `pull`, `push,pull`
141141
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`
143143
144144 Combine scopes with commas:
145145
@@ -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):
9393 | `admin:config:write` | Config write access |
9494 | `admin:status:read` | Read-only status/health |
9595 | `admin:logs:read` | Read-only logs access |
96+| `admin:volumes:read` | List Docker volumes |
97+| `admin:volumes:write` | Prune eligible Gordon-managed volumes |
9698 | `admin:secrets:read` | List secret keys |
9799 | `admin:secrets:write` | Set/delete secrets |
98100
@@ -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 {
9898 return NewHandler(deps)
9999 }
100100
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+
101159 // Routes endpoint tests
102160
103161 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 tests159 // 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) {
1919
2020 ctx := r.Context()
2121
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")
2424 return
2525 }
2626
@@ -63,8 +63,8 @@ func (h *Handler) handlePruneVolumes(w http.ResponseWriter, r *http.Request) {
6363
6464 ctx := r.Context()
6565
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")
6868 return
6969 }
7070
@@ -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 return24 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 return68 return
69 }69 }
70 70
M internal/adapters/out/filesystem/backup_storage.go +36 -4
@@ -2,6 +2,8 @@ package filesystem
22
33 import (
44 "context"
5+ "crypto/rand"
6+ "encoding/hex"
57 "errors"
68 "fmt"
79 "io"
@@ -62,11 +64,12 @@ func (s *BackupStorage) Store(_ context.Context, domainName, dbName string, sche
6264 return "", fmt.Errorf("failed to create backup path: %w", err)
6365 }
6466
65- fileName := timestamp.UTC().Format(backupTimestampLayout) + ".bak"
67+ fileName := timestamp.UTC().Format(time.RFC3339Nano) + "-" + randomBackupSuffix() + ".bak"
68+ fileName = sanitizeBackupFileName(fileName)
6669 finalPath := filepath.Join(backupDir, fileName)
6770 tmpPath := finalPath + ".tmp"
6871
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)
7073 if err != nil {
7174 return "", fmt.Errorf("failed to create temp backup file: %w", err)
7275 }
@@ -118,7 +121,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom
118121 if walkErr != nil {
119122 return walkErr
120123 }
121- if d.IsDir() {
124+ if d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") {
122125 return nil
123126 }
124127
@@ -144,7 +147,7 @@ func (s *BackupStorage) List(_ context.Context, domainName string, schedule *dom
144147
145148 startedAt := info.ModTime().UTC()
146149 base := filepath.Base(path)
147- if ts, err := time.Parse(backupTimestampLayout+".bak", base); err == nil {
150+ if ts, ok := parseBackupStartedAt(base); ok {
148151 startedAt = ts.UTC()
149152 }
150153
@@ -267,6 +270,35 @@ func retentionKeepCount(policy domain.RetentionPolicy, schedule domain.BackupSch
267270 }
268271 }
269272
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+
270302 func sanitizeBackupPathComponent(input string) string {
271303 clean := strings.TrimSpace(input)
272304 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 walkErr122 return walkErr
120 }123 }
121- if d.IsDir() {124+ if d.IsDir() || !strings.HasSuffix(d.Name(), ".bak") {
122 return nil125 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) {
3131 assert.Equal(t, payload, data)
3232 }
3333
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+
3467 func TestBackupStorage_ListWithScheduleFilter(t *testing.T) {
3568 storage, err := NewBackupStorage(t.TempDir(), testLogger())
3669 require.NoError(t, err)
@@ -71,7 +104,7 @@ func TestBackupStorage_ApplyRetention(t *testing.T) {
71104 require.NoError(t, err)
72105
73106 base := time.Date(2026, 2, 7, 6, 0, 0, 0, time.UTC)
74- for i := 0; i < 4; i++ {
107+ for i := range 4 {
75108 _, err := storage.Store(context.Background(), "app.example.com", "postgres", domain.ScheduleDaily, base.Add(time.Duration(i)*time.Hour), bytes.NewReader([]byte("data")))
76109 require.NoError(t, err)
77110 }
@@ -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 (
8080 AdminResourceConfig = "config"
8181 AdminResourceStatus = "status"
8282 AdminResourceLogs = "logs"
83+ AdminResourceVolumes = "volumes"
8384 AdminResourceAll = "*"
8485 )
8586
@@ -167,7 +168,7 @@ func (s *Scope) IsRepositoryScope() bool {
167168 // AdminScope represents an admin API scope (admin:resource:actions).
168169 // Format: admin:routes:read,write or admin:*:* for full access.
169170 type AdminScope struct {
170- Resource string // routes, secrets, config, status, or *
171+ Resource string // routes, secrets, config, status, logs, volumes, or *
171172 Actions []string // read, write, or *
172173 }
173174
@@ -308,6 +309,11 @@ func AdminScopeLogs(actions ...string) string {
308309 return fmt.Sprintf("%s:%s:%s", ScopeTypeAdmin, AdminResourceLogs, strings.Join(actions, ","))
309310 }
310311
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+
311317 // AuthStatus represents status of an authentication session.
312318 type AuthStatus struct {
313319 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 bool319 Valid bool
M internal/domain/auth_test.go +7 -0
@@ -512,6 +512,13 @@ func TestScopesGrantAdminAccess(t *testing.T) {
512512 action: AdminActionRead,
513513 want: true,
514514 },
515+ {
516+ name: "volumes read grants volumes resource",
517+ granted: []string{"admin:volumes:read"},
518+ resource: AdminResourceVolumes,
519+ action: AdminActionRead,
520+ want: true,
521+ },
515522 {
516523 name: "repository scope is ignored for admin access",
517524 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
5454 var removed []*domain.VolumeInfo
5555
5656 for _, vol := range vols {
57- if vol.InUse {
57+ if !isGordonManagedVolume(vol) || vol.InUse {
5858 continue
5959 }
6060
@@ -72,3 +72,10 @@ func (s *Service) PruneVolumes(ctx context.Context, dryRun bool) (*domain.Volume
7272
7373 return report, removed, nil
7474 }
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.VolumeInfo54 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 continue58 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, nil73 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) {
3131 runtime := outmocks.NewMockContainerRuntime(t)
3232
3333 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"}},
3636 }
3737 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
3838 runtime.EXPECT().RemoveVolume(context.Background(), "vol-orphaned", false).Return(nil)
@@ -50,7 +50,7 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) {
5050 runtime := outmocks.NewMockContainerRuntime(t)
5151
5252 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"}},
5454 }
5555 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
5656
@@ -62,6 +62,47 @@ func TestService_PruneVolumes_DryRunDoesNotRemove(t *testing.T) {
6262 assert.Len(t, removed, 1)
6363 }
6464
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+
65106 func TestService_PruneVolumes_AllInUse(t *testing.T) {
66107 runtime := outmocks.NewMockContainerRuntime(t)
67108
@@ -91,8 +132,8 @@ func TestService_PruneVolumes_SkipsFailedRemoval(t *testing.T) {
91132 runtime := outmocks.NewMockContainerRuntime(t)
92133
93134 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"}},
96137 }
97138 runtime.EXPECT().ListVolumes(context.Background()).Return(vols, nil)
98139 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"))

Keyboard shortcuts

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

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

Branches 7

Tags 106

↑↓ moveenter openesc close