detached 4677f2d

feat(admin): add image list and prune endpoints

commit
4677f2d5a3287c37493075e0c4306bd9b49df1b8
author
brice · · 2026-02-08 06:40 UTC
committer
brice · · 2026-02-08 09:40 UTC
parent
ca495a5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +434 -0
A internal/adapters/dto/admin_images.go +42 -0
@@ -0,0 +1,42 @@
1+package dto
2+
3+import "time"
4+
5+// Image represents a runtime image in admin API responses.
6+type Image struct {
7+ Repository string `json:"repository"`
8+ Tag string `json:"tag"`
9+ Size int64 `json:"size"`
10+ Created time.Time `json:"created"`
11+ ID string `json:"id"`
12+ Dangling bool `json:"dangling"`
13+}
14+
15+// ImagesResponse is returned by image list endpoints.
16+type ImagesResponse struct {
17+ Images []Image `json:"images"`
18+}
19+
20+// ImagePruneRequest triggers image pruning.
21+type ImagePruneRequest struct {
22+ KeepLast *int `json:"keep_last,omitempty"`
23+}
24+
25+// RuntimePruneResult represents runtime prune results.
26+type RuntimePruneResult struct {
27+ DeletedCount int `json:"deleted_count"`
28+ SpaceReclaimed int64 `json:"space_reclaimed"`
29+}
30+
31+// RegistryPruneResult represents registry prune results.
32+type RegistryPruneResult struct {
33+ TagsRemoved int `json:"tags_removed"`
34+ BlobsRemoved int `json:"blobs_removed"`
35+ SpaceReclaimed int64 `json:"space_reclaimed"`
36+}
37+
38+// ImagePruneResponse is returned by image prune endpoints.
39+type ImagePruneResponse struct {
40+ Runtime RuntimePruneResult `json:"runtime"`
41+ Registry RegistryPruneResult `json:"registry"`
42+}
@@ -0,0 +1,42 @@
1+package dto
2+
3+import "time"
4+
5+// Image represents a runtime image in admin API responses.
6+type Image struct {
7+ Repository string `json:"repository"`
8+ Tag string `json:"tag"`
9+ Size int64 `json:"size"`
10+ Created time.Time `json:"created"`
11+ ID string `json:"id"`
12+ Dangling bool `json:"dangling"`
13+}
14+
15+// ImagesResponse is returned by image list endpoints.
16+type ImagesResponse struct {
17+ Images []Image `json:"images"`
18+}
19+
20+// ImagePruneRequest triggers image pruning.
21+type ImagePruneRequest struct {
22+ KeepLast *int `json:"keep_last,omitempty"`
23+}
24+
25+// RuntimePruneResult represents runtime prune results.
26+type RuntimePruneResult struct {
27+ DeletedCount int `json:"deleted_count"`
28+ SpaceReclaimed int64 `json:"space_reclaimed"`
29+}
30+
31+// RegistryPruneResult represents registry prune results.
32+type RegistryPruneResult struct {
33+ TagsRemoved int `json:"tags_removed"`
34+ BlobsRemoved int `json:"blobs_removed"`
35+ SpaceReclaimed int64 `json:"space_reclaimed"`
36+}
37+
38+// ImagePruneResponse is returned by image prune endpoints.
39+type ImagePruneResponse struct {
40+ Runtime RuntimePruneResult `json:"runtime"`
41+ Registry RegistryPruneResult `json:"registry"`
42+}
M internal/adapters/in/http/admin/handler.go +9 -0
@@ -33,6 +33,7 @@ type Handler struct {
3333 authSvc in.AuthService
3434 containerSvc in.ContainerService
3535 backupSvc in.BackupService
36+ imageSvc in.ImageService
3637 healthSvc in.HealthService
3738 secretSvc in.SecretService
3839 logSvc in.LogService
@@ -132,12 +133,19 @@ func NewHandler(
132133 eventBus out.EventPublisher,
133134 log zerowrap.Logger,
134135 backupSvc in.BackupService,
136+ imageSvcs ...in.ImageService,
135137 ) *Handler {
138+ var imageSvc in.ImageService
139+ if len(imageSvcs) > 0 {
140+ imageSvc = imageSvcs[0]
141+ }
142+
136143 return &Handler{
137144 configSvc: configSvc,
138145 authSvc: authSvc,
139146 containerSvc: containerSvc,
140147 backupSvc: backupSvc,
148+ imageSvc: imageSvc,
141149 healthSvc: healthSvc,
142150 secretSvc: secretSvc,
143151 logSvc: logSvc,
@@ -207,6 +215,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
207215 {"/deploy", h.handleDeploy},
208216 {"/restart", h.handleRestart},
209217 {"/tags", h.handleTags},
218+ {"/images", h.handleImages},
210219 {"/logs", h.handleLogs},
211220 }
212221 for _, route := range prefixRoutes {
@@ -33,6 +33,7 @@ type Handler struct {
33 authSvc in.AuthService33 authSvc in.AuthService
34 containerSvc in.ContainerService34 containerSvc in.ContainerService
35 backupSvc in.BackupService35 backupSvc in.BackupService
36+ imageSvc in.ImageService
36 healthSvc in.HealthService37 healthSvc in.HealthService
37 secretSvc in.SecretService38 secretSvc in.SecretService
38 logSvc in.LogService39 logSvc in.LogService
@@ -132,12 +133,19 @@ func NewHandler(
132 eventBus out.EventPublisher,133 eventBus out.EventPublisher,
133 log zerowrap.Logger,134 log zerowrap.Logger,
134 backupSvc in.BackupService,135 backupSvc in.BackupService,
136+ imageSvcs ...in.ImageService,
135 ) *Handler {137 ) *Handler {
138+ var imageSvc in.ImageService
139+ if len(imageSvcs) > 0 {
140+ imageSvc = imageSvcs[0]
141+ }
142+
136 return &Handler{143 return &Handler{
137 configSvc: configSvc,144 configSvc: configSvc,
138 authSvc: authSvc,145 authSvc: authSvc,
139 containerSvc: containerSvc,146 containerSvc: containerSvc,
140 backupSvc: backupSvc,147 backupSvc: backupSvc,
148+ imageSvc: imageSvc,
141 healthSvc: healthSvc,149 healthSvc: healthSvc,
142 secretSvc: secretSvc,150 secretSvc: secretSvc,
143 logSvc: logSvc,151 logSvc: logSvc,
@@ -207,6 +215,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) {
207 {"/deploy", h.handleDeploy},215 {"/deploy", h.handleDeploy},
208 {"/restart", h.handleRestart},216 {"/restart", h.handleRestart},
209 {"/tags", h.handleTags},217 {"/tags", h.handleTags},
218+ {"/images", h.handleImages},
210 {"/logs", h.handleLogs},219 {"/logs", h.handleLogs},
211 }220 }
212 for _, route := range prefixRoutes {221 for _, route := range prefixRoutes {
M internal/adapters/in/http/admin/handler_test.go +249 -0
@@ -4,10 +4,12 @@ import (
44 "bytes"
55 "context"
66 "encoding/json"
7+ "errors"
78 "fmt"
89 "net/http"
910 "net/http/httptest"
1011 "testing"
12+ "time"
1113
1214 "github.com/bnema/zerowrap"
1315 "github.com/stretchr/testify/assert"
@@ -17,6 +19,25 @@ import (
1719 "github.com/bnema/gordon/internal/domain"
1820 )
1921
22+type stubImageService struct {
23+ listImagesFunc func(context.Context) ([]domain.ImageInfo, error)
24+ pruneFunc func(context.Context, int) (domain.ImagePruneReport, error)
25+}
26+
27+func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
28+ if s.listImagesFunc == nil {
29+ return nil, nil
30+ }
31+ return s.listImagesFunc(ctx)
32+}
33+
34+func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
35+ if s.pruneFunc == nil {
36+ return domain.ImagePruneReport{}, nil
37+ }
38+ return s.pruneFunc(ctx, keepLast)
39+}
40+
2041 func testLogger() zerowrap.Logger {
2142 return zerowrap.Default()
2243 }
@@ -1290,3 +1311,231 @@ func TestHandler_BackupsDetectDomain(t *testing.T) {
12901311 assert.Equal(t, http.StatusOK, rec.Code)
12911312 assert.Contains(t, rec.Body.String(), "postgres")
12921313 }
1314+
1315+func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
1316+ configSvc := inmocks.NewMockConfigService(t)
1317+ authSvc := inmocks.NewMockAuthService(t)
1318+ containerSvc := inmocks.NewMockContainerService(t)
1319+ secretSvc := inmocks.NewMockSecretService(t)
1320+
1321+ createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
1322+ imageSvc := &stubImageService{
1323+ listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
1324+ return []domain.ImageInfo{
1325+ {
1326+ Repository: "myapp",
1327+ Tag: "latest",
1328+ Size: 1024,
1329+ Created: createdAt,
1330+ ID: "sha256:abc123",
1331+ Dangling: false,
1332+ },
1333+ {
1334+ Repository: "",
1335+ Tag: "",
1336+ Size: 512,
1337+ Created: createdAt,
1338+ ID: "sha256:def456",
1339+ Dangling: true,
1340+ },
1341+ }, nil
1342+ },
1343+ }
1344+
1345+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1346+
1347+ req := httptest.NewRequest("GET", "/admin/images", nil)
1348+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1349+ rec := httptest.NewRecorder()
1350+
1351+ handler.ServeHTTP(rec, req)
1352+
1353+ assert.Equal(t, http.StatusOK, rec.Code)
1354+
1355+ var response struct {
1356+ Images []struct {
1357+ Repository string `json:"repository"`
1358+ Tag string `json:"tag"`
1359+ Size int64 `json:"size"`
1360+ Created time.Time `json:"created"`
1361+ ID string `json:"id"`
1362+ Dangling bool `json:"dangling"`
1363+ } `json:"images"`
1364+ }
1365+
1366+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1367+ assert.Len(t, response.Images, 2)
1368+ assert.Equal(t, "myapp", response.Images[0].Repository)
1369+ assert.Equal(t, "latest", response.Images[0].Tag)
1370+ assert.Equal(t, int64(1024), response.Images[0].Size)
1371+ assert.Equal(t, createdAt, response.Images[0].Created)
1372+ assert.Equal(t, "sha256:abc123", response.Images[0].ID)
1373+ assert.False(t, response.Images[0].Dangling)
1374+ assert.True(t, response.Images[1].Dangling)
1375+}
1376+
1377+func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1378+ configSvc := inmocks.NewMockConfigService(t)
1379+ authSvc := inmocks.NewMockAuthService(t)
1380+ containerSvc := inmocks.NewMockContainerService(t)
1381+ secretSvc := inmocks.NewMockSecretService(t)
1382+
1383+ tests := []struct {
1384+ name string
1385+ body string
1386+ expectedKeepLast int
1387+ }{
1388+ {name: "missing keep_last uses zero", body: `{}`, expectedKeepLast: 0},
1389+ {name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3},
1390+ }
1391+
1392+ for _, tt := range tests {
1393+ t.Run(tt.name, func(t *testing.T) {
1394+ called := false
1395+ imageSvc := &stubImageService{
1396+ pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) {
1397+ called = true
1398+ assert.Equal(t, tt.expectedKeepLast, keepLast)
1399+ return domain.ImagePruneReport{
1400+ Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
1401+ Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
1402+ }, nil
1403+ },
1404+ }
1405+
1406+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1407+
1408+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1409+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1410+ rec := httptest.NewRecorder()
1411+
1412+ handler.ServeHTTP(rec, req)
1413+
1414+ assert.Equal(t, http.StatusOK, rec.Code)
1415+ assert.True(t, called)
1416+ assert.Contains(t, rec.Body.String(), "deleted_count")
1417+ assert.Contains(t, rec.Body.String(), "tags_removed")
1418+ })
1419+ }
1420+}
1421+
1422+func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1423+ t.Run("list failure maps to internal server error", func(t *testing.T) {
1424+ configSvc := inmocks.NewMockConfigService(t)
1425+ authSvc := inmocks.NewMockAuthService(t)
1426+ containerSvc := inmocks.NewMockContainerService(t)
1427+ secretSvc := inmocks.NewMockSecretService(t)
1428+
1429+ imageSvc := &stubImageService{
1430+ listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
1431+ return nil, errors.New("runtime list failed: confidential details")
1432+ },
1433+ }
1434+
1435+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1436+
1437+ req := httptest.NewRequest("GET", "/admin/images", nil)
1438+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1439+ rec := httptest.NewRecorder()
1440+
1441+ handler.ServeHTTP(rec, req)
1442+
1443+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1444+ assert.Contains(t, rec.Body.String(), "failed to list images")
1445+ assert.NotContains(t, rec.Body.String(), "confidential details")
1446+ })
1447+
1448+ t.Run("prune failure maps to internal server error", func(t *testing.T) {
1449+ configSvc := inmocks.NewMockConfigService(t)
1450+ authSvc := inmocks.NewMockAuthService(t)
1451+ containerSvc := inmocks.NewMockContainerService(t)
1452+ secretSvc := inmocks.NewMockSecretService(t)
1453+
1454+ imageSvc := &stubImageService{
1455+ pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) {
1456+ return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
1457+ },
1458+ }
1459+
1460+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1461+
1462+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
1463+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1464+ rec := httptest.NewRecorder()
1465+
1466+ handler.ServeHTTP(rec, req)
1467+
1468+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1469+ assert.Contains(t, rec.Body.String(), "failed to prune images")
1470+ assert.NotContains(t, rec.Body.String(), "confidential details")
1471+ })
1472+}
1473+
1474+func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1475+ t.Run("rejects negative keep_last", func(t *testing.T) {
1476+ configSvc := inmocks.NewMockConfigService(t)
1477+ authSvc := inmocks.NewMockAuthService(t)
1478+ containerSvc := inmocks.NewMockContainerService(t)
1479+ secretSvc := inmocks.NewMockSecretService(t)
1480+
1481+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1482+
1483+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
1484+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1485+ rec := httptest.NewRecorder()
1486+
1487+ handler.ServeHTTP(rec, req)
1488+
1489+ assert.Equal(t, http.StatusBadRequest, rec.Code)
1490+ assert.Contains(t, rec.Body.String(), "keep_last must be")
1491+ })
1492+
1493+ t.Run("returns service unavailable when image service missing", func(t *testing.T) {
1494+ configSvc := inmocks.NewMockConfigService(t)
1495+ authSvc := inmocks.NewMockAuthService(t)
1496+ containerSvc := inmocks.NewMockContainerService(t)
1497+ secretSvc := inmocks.NewMockSecretService(t)
1498+
1499+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1500+
1501+ req := httptest.NewRequest("GET", "/admin/images", nil)
1502+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1503+ rec := httptest.NewRecorder()
1504+
1505+ handler.ServeHTTP(rec, req)
1506+
1507+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
1508+ assert.Contains(t, rec.Body.String(), "image service not available")
1509+ })
1510+}
1511+
1512+func TestHandler_Images_Authorization(t *testing.T) {
1513+ configSvc := inmocks.NewMockConfigService(t)
1514+ authSvc := inmocks.NewMockAuthService(t)
1515+ containerSvc := inmocks.NewMockContainerService(t)
1516+ secretSvc := inmocks.NewMockSecretService(t)
1517+
1518+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1519+
1520+ t.Run("images list requires status:read", func(t *testing.T) {
1521+ req := httptest.NewRequest("GET", "/admin/images", nil)
1522+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1523+ rec := httptest.NewRecorder()
1524+
1525+ handler.ServeHTTP(rec, req)
1526+
1527+ assert.Equal(t, http.StatusForbidden, rec.Code)
1528+ assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
1529+ })
1530+
1531+ t.Run("images prune requires config:write", func(t *testing.T) {
1532+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`))
1533+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1534+ rec := httptest.NewRecorder()
1535+
1536+ handler.ServeHTTP(rec, req)
1537+
1538+ assert.Equal(t, http.StatusForbidden, rec.Code)
1539+ assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
1540+ })
1541+}
@@ -4,10 +4,12 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "encoding/json"6 "encoding/json"
7+ "errors"
7 "fmt"8 "fmt"
8 "net/http"9 "net/http"
9 "net/http/httptest"10 "net/http/httptest"
10 "testing"11 "testing"
12+ "time"
11 13
12 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
13 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
@@ -17,6 +19,25 @@ import (
17 "github.com/bnema/gordon/internal/domain"19 "github.com/bnema/gordon/internal/domain"
18 )20 )
19 21
22+type stubImageService struct {
23+ listImagesFunc func(context.Context) ([]domain.ImageInfo, error)
24+ pruneFunc func(context.Context, int) (domain.ImagePruneReport, error)
25+}
26+
27+func (s *stubImageService) ListImages(ctx context.Context) ([]domain.ImageInfo, error) {
28+ if s.listImagesFunc == nil {
29+ return nil, nil
30+ }
31+ return s.listImagesFunc(ctx)
32+}
33+
34+func (s *stubImageService) Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error) {
35+ if s.pruneFunc == nil {
36+ return domain.ImagePruneReport{}, nil
37+ }
38+ return s.pruneFunc(ctx, keepLast)
39+}
40+
20 func testLogger() zerowrap.Logger {41 func testLogger() zerowrap.Logger {
21 return zerowrap.Default()42 return zerowrap.Default()
22 }43 }
@@ -1290,3 +1311,231 @@ func TestHandler_BackupsDetectDomain(t *testing.T) {
1290 assert.Equal(t, http.StatusOK, rec.Code)1311 assert.Equal(t, http.StatusOK, rec.Code)
1291 assert.Contains(t, rec.Body.String(), "postgres")1312 assert.Contains(t, rec.Body.String(), "postgres")
1292 }1313 }
1314+
1315+func TestHandler_ImagesGet_ReturnsMappedList(t *testing.T) {
1316+ configSvc := inmocks.NewMockConfigService(t)
1317+ authSvc := inmocks.NewMockAuthService(t)
1318+ containerSvc := inmocks.NewMockContainerService(t)
1319+ secretSvc := inmocks.NewMockSecretService(t)
1320+
1321+ createdAt := time.Date(2026, time.February, 8, 14, 30, 0, 0, time.UTC)
1322+ imageSvc := &stubImageService{
1323+ listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
1324+ return []domain.ImageInfo{
1325+ {
1326+ Repository: "myapp",
1327+ Tag: "latest",
1328+ Size: 1024,
1329+ Created: createdAt,
1330+ ID: "sha256:abc123",
1331+ Dangling: false,
1332+ },
1333+ {
1334+ Repository: "",
1335+ Tag: "",
1336+ Size: 512,
1337+ Created: createdAt,
1338+ ID: "sha256:def456",
1339+ Dangling: true,
1340+ },
1341+ }, nil
1342+ },
1343+ }
1344+
1345+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1346+
1347+ req := httptest.NewRequest("GET", "/admin/images", nil)
1348+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1349+ rec := httptest.NewRecorder()
1350+
1351+ handler.ServeHTTP(rec, req)
1352+
1353+ assert.Equal(t, http.StatusOK, rec.Code)
1354+
1355+ var response struct {
1356+ Images []struct {
1357+ Repository string `json:"repository"`
1358+ Tag string `json:"tag"`
1359+ Size int64 `json:"size"`
1360+ Created time.Time `json:"created"`
1361+ ID string `json:"id"`
1362+ Dangling bool `json:"dangling"`
1363+ } `json:"images"`
1364+ }
1365+
1366+ assert.NoError(t, json.NewDecoder(rec.Body).Decode(&response))
1367+ assert.Len(t, response.Images, 2)
1368+ assert.Equal(t, "myapp", response.Images[0].Repository)
1369+ assert.Equal(t, "latest", response.Images[0].Tag)
1370+ assert.Equal(t, int64(1024), response.Images[0].Size)
1371+ assert.Equal(t, createdAt, response.Images[0].Created)
1372+ assert.Equal(t, "sha256:abc123", response.Images[0].ID)
1373+ assert.False(t, response.Images[0].Dangling)
1374+ assert.True(t, response.Images[1].Dangling)
1375+}
1376+
1377+func TestHandler_ImagesPrune_AcceptsOptionalKeepLast(t *testing.T) {
1378+ configSvc := inmocks.NewMockConfigService(t)
1379+ authSvc := inmocks.NewMockAuthService(t)
1380+ containerSvc := inmocks.NewMockContainerService(t)
1381+ secretSvc := inmocks.NewMockSecretService(t)
1382+
1383+ tests := []struct {
1384+ name string
1385+ body string
1386+ expectedKeepLast int
1387+ }{
1388+ {name: "missing keep_last uses zero", body: `{}`, expectedKeepLast: 0},
1389+ {name: "provided keep_last", body: `{"keep_last": 3}`, expectedKeepLast: 3},
1390+ }
1391+
1392+ for _, tt := range tests {
1393+ t.Run(tt.name, func(t *testing.T) {
1394+ called := false
1395+ imageSvc := &stubImageService{
1396+ pruneFunc: func(_ context.Context, keepLast int) (domain.ImagePruneReport, error) {
1397+ called = true
1398+ assert.Equal(t, tt.expectedKeepLast, keepLast)
1399+ return domain.ImagePruneReport{
1400+ Runtime: domain.RuntimePruneResult{DeletedCount: 2, SpaceReclaimed: 4096},
1401+ Registry: domain.RegistryPruneResult{TagsRemoved: 1, BlobsRemoved: 3, SpaceReclaimed: 2048},
1402+ }, nil
1403+ },
1404+ }
1405+
1406+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1407+
1408+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(tt.body))
1409+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1410+ rec := httptest.NewRecorder()
1411+
1412+ handler.ServeHTTP(rec, req)
1413+
1414+ assert.Equal(t, http.StatusOK, rec.Code)
1415+ assert.True(t, called)
1416+ assert.Contains(t, rec.Body.String(), "deleted_count")
1417+ assert.Contains(t, rec.Body.String(), "tags_removed")
1418+ })
1419+ }
1420+}
1421+
1422+func TestHandler_Images_ErrorMappingForServiceFailures(t *testing.T) {
1423+ t.Run("list failure maps to internal server error", func(t *testing.T) {
1424+ configSvc := inmocks.NewMockConfigService(t)
1425+ authSvc := inmocks.NewMockAuthService(t)
1426+ containerSvc := inmocks.NewMockContainerService(t)
1427+ secretSvc := inmocks.NewMockSecretService(t)
1428+
1429+ imageSvc := &stubImageService{
1430+ listImagesFunc: func(context.Context) ([]domain.ImageInfo, error) {
1431+ return nil, errors.New("runtime list failed: confidential details")
1432+ },
1433+ }
1434+
1435+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1436+
1437+ req := httptest.NewRequest("GET", "/admin/images", nil)
1438+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1439+ rec := httptest.NewRecorder()
1440+
1441+ handler.ServeHTTP(rec, req)
1442+
1443+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1444+ assert.Contains(t, rec.Body.String(), "failed to list images")
1445+ assert.NotContains(t, rec.Body.String(), "confidential details")
1446+ })
1447+
1448+ t.Run("prune failure maps to internal server error", func(t *testing.T) {
1449+ configSvc := inmocks.NewMockConfigService(t)
1450+ authSvc := inmocks.NewMockAuthService(t)
1451+ containerSvc := inmocks.NewMockContainerService(t)
1452+ secretSvc := inmocks.NewMockSecretService(t)
1453+
1454+ imageSvc := &stubImageService{
1455+ pruneFunc: func(context.Context, int) (domain.ImagePruneReport, error) {
1456+ return domain.ImagePruneReport{}, errors.New("prune failed: confidential details")
1457+ },
1458+ }
1459+
1460+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, imageSvc)
1461+
1462+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 2}`))
1463+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1464+ rec := httptest.NewRecorder()
1465+
1466+ handler.ServeHTTP(rec, req)
1467+
1468+ assert.Equal(t, http.StatusInternalServerError, rec.Code)
1469+ assert.Contains(t, rec.Body.String(), "failed to prune images")
1470+ assert.NotContains(t, rec.Body.String(), "confidential details")
1471+ })
1472+}
1473+
1474+func TestHandler_ImagesPrune_ValidationAndAvailability(t *testing.T) {
1475+ t.Run("rejects negative keep_last", func(t *testing.T) {
1476+ configSvc := inmocks.NewMockConfigService(t)
1477+ authSvc := inmocks.NewMockAuthService(t)
1478+ containerSvc := inmocks.NewMockContainerService(t)
1479+ secretSvc := inmocks.NewMockSecretService(t)
1480+
1481+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1482+
1483+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": -1}`))
1484+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1485+ rec := httptest.NewRecorder()
1486+
1487+ handler.ServeHTTP(rec, req)
1488+
1489+ assert.Equal(t, http.StatusBadRequest, rec.Code)
1490+ assert.Contains(t, rec.Body.String(), "keep_last must be")
1491+ })
1492+
1493+ t.Run("returns service unavailable when image service missing", func(t *testing.T) {
1494+ configSvc := inmocks.NewMockConfigService(t)
1495+ authSvc := inmocks.NewMockAuthService(t)
1496+ containerSvc := inmocks.NewMockContainerService(t)
1497+ secretSvc := inmocks.NewMockSecretService(t)
1498+
1499+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil)
1500+
1501+ req := httptest.NewRequest("GET", "/admin/images", nil)
1502+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1503+ rec := httptest.NewRecorder()
1504+
1505+ handler.ServeHTTP(rec, req)
1506+
1507+ assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
1508+ assert.Contains(t, rec.Body.String(), "image service not available")
1509+ })
1510+}
1511+
1512+func TestHandler_Images_Authorization(t *testing.T) {
1513+ configSvc := inmocks.NewMockConfigService(t)
1514+ authSvc := inmocks.NewMockAuthService(t)
1515+ containerSvc := inmocks.NewMockContainerService(t)
1516+ secretSvc := inmocks.NewMockSecretService(t)
1517+
1518+ handler := NewHandler(configSvc, authSvc, containerSvc, inmocks.NewMockHealthService(t), secretSvc, nil, inmocks.NewMockRegistryService(t), nil, testLogger(), nil, &stubImageService{})
1519+
1520+ t.Run("images list requires status:read", func(t *testing.T) {
1521+ req := httptest.NewRequest("GET", "/admin/images", nil)
1522+ req = req.WithContext(ctxWithScopes("admin:config:write"))
1523+ rec := httptest.NewRecorder()
1524+
1525+ handler.ServeHTTP(rec, req)
1526+
1527+ assert.Equal(t, http.StatusForbidden, rec.Code)
1528+ assert.Contains(t, rec.Body.String(), "insufficient permissions for status:read")
1529+ })
1530+
1531+ t.Run("images prune requires config:write", func(t *testing.T) {
1532+ req := httptest.NewRequest("POST", "/admin/images/prune", bytes.NewBufferString(`{"keep_last": 1}`))
1533+ req = req.WithContext(ctxWithScopes("admin:status:read"))
1534+ rec := httptest.NewRecorder()
1535+
1536+ handler.ServeHTTP(rec, req)
1537+
1538+ assert.Equal(t, http.StatusForbidden, rec.Code)
1539+ assert.Contains(t, rec.Body.String(), "insufficient permissions for config:write")
1540+ })
1541+}
A internal/adapters/in/http/admin/images.go +117 -0
@@ -0,0 +1,117 @@
1+package admin
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "io"
7+ "net/http"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "github.com/bnema/gordon/internal/adapters/dto"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func toImageResponse(image domain.ImageInfo) dto.Image {
16+ return dto.Image{
17+ Repository: image.Repository,
18+ Tag: image.Tag,
19+ Size: image.Size,
20+ Created: image.Created,
21+ ID: image.ID,
22+ Dangling: image.Dangling,
23+ }
24+}
25+
26+func toImagePruneResponse(report domain.ImagePruneReport) dto.ImagePruneResponse {
27+ return dto.ImagePruneResponse{
28+ Runtime: dto.RuntimePruneResult{
29+ DeletedCount: report.Runtime.DeletedCount,
30+ SpaceReclaimed: report.Runtime.SpaceReclaimed,
31+ },
32+ Registry: dto.RegistryPruneResult{
33+ TagsRemoved: report.Registry.TagsRemoved,
34+ BlobsRemoved: report.Registry.BlobsRemoved,
35+ SpaceReclaimed: report.Registry.SpaceReclaimed,
36+ },
37+ }
38+}
39+
40+func (h *Handler) handleImages(w http.ResponseWriter, r *http.Request, path string) {
41+ if path != "/images" && path != "/images/prune" {
42+ h.sendError(w, http.StatusNotFound, "route not found")
43+ return
44+ }
45+
46+ if h.imageSvc == nil {
47+ h.sendError(w, http.StatusServiceUnavailable, "image service not available")
48+ return
49+ }
50+
51+ switch {
52+ case path == "/images" && r.Method == http.MethodGet:
53+ h.handleImagesGet(w, r)
54+ case path == "/images/prune" && r.Method == http.MethodPost:
55+ h.handleImagesPrune(w, r)
56+ default:
57+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
58+ }
59+}
60+
61+func (h *Handler) handleImagesGet(w http.ResponseWriter, r *http.Request) {
62+ ctx := r.Context()
63+
64+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
65+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
66+ return
67+ }
68+
69+ images, err := h.imageSvc.ListImages(ctx)
70+ if err != nil {
71+ h.sendError(w, http.StatusInternalServerError, "failed to list images")
72+ return
73+ }
74+
75+ response := make([]dto.Image, 0, len(images))
76+ for _, image := range images {
77+ response = append(response, toImageResponse(image))
78+ }
79+
80+ h.sendJSON(w, http.StatusOK, dto.ImagesResponse{Images: response})
81+}
82+
83+func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) {
84+ ctx := r.Context()
85+ log := zerowrap.FromCtx(ctx)
86+
87+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
88+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
89+ return
90+ }
91+
92+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
93+ var req dto.ImagePruneRequest
94+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
95+ log.Warn().Err(err).Msg("invalid image prune JSON")
96+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
97+ return
98+ }
99+
100+ keepLast := 0
101+ if req.KeepLast != nil {
102+ if *req.KeepLast < 0 {
103+ h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0")
104+ return
105+ }
106+ keepLast = *req.KeepLast
107+ }
108+
109+ report, err := h.imageSvc.Prune(ctx, keepLast)
110+ if err != nil {
111+ log.Error().Err(err).Int("keep_last", keepLast).Msg("image prune failed")
112+ h.sendError(w, http.StatusInternalServerError, "failed to prune images")
113+ return
114+ }
115+
116+ h.sendJSON(w, http.StatusOK, toImagePruneResponse(report))
117+}
@@ -0,0 +1,117 @@
1+package admin
2+
3+import (
4+ "encoding/json"
5+ "errors"
6+ "io"
7+ "net/http"
8+
9+ "github.com/bnema/zerowrap"
10+
11+ "github.com/bnema/gordon/internal/adapters/dto"
12+ "github.com/bnema/gordon/internal/domain"
13+)
14+
15+func toImageResponse(image domain.ImageInfo) dto.Image {
16+ return dto.Image{
17+ Repository: image.Repository,
18+ Tag: image.Tag,
19+ Size: image.Size,
20+ Created: image.Created,
21+ ID: image.ID,
22+ Dangling: image.Dangling,
23+ }
24+}
25+
26+func toImagePruneResponse(report domain.ImagePruneReport) dto.ImagePruneResponse {
27+ return dto.ImagePruneResponse{
28+ Runtime: dto.RuntimePruneResult{
29+ DeletedCount: report.Runtime.DeletedCount,
30+ SpaceReclaimed: report.Runtime.SpaceReclaimed,
31+ },
32+ Registry: dto.RegistryPruneResult{
33+ TagsRemoved: report.Registry.TagsRemoved,
34+ BlobsRemoved: report.Registry.BlobsRemoved,
35+ SpaceReclaimed: report.Registry.SpaceReclaimed,
36+ },
37+ }
38+}
39+
40+func (h *Handler) handleImages(w http.ResponseWriter, r *http.Request, path string) {
41+ if path != "/images" && path != "/images/prune" {
42+ h.sendError(w, http.StatusNotFound, "route not found")
43+ return
44+ }
45+
46+ if h.imageSvc == nil {
47+ h.sendError(w, http.StatusServiceUnavailable, "image service not available")
48+ return
49+ }
50+
51+ switch {
52+ case path == "/images" && r.Method == http.MethodGet:
53+ h.handleImagesGet(w, r)
54+ case path == "/images/prune" && r.Method == http.MethodPost:
55+ h.handleImagesPrune(w, r)
56+ default:
57+ h.sendError(w, http.StatusMethodNotAllowed, "method not allowed")
58+ }
59+}
60+
61+func (h *Handler) handleImagesGet(w http.ResponseWriter, r *http.Request) {
62+ ctx := r.Context()
63+
64+ if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) {
65+ h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read")
66+ return
67+ }
68+
69+ images, err := h.imageSvc.ListImages(ctx)
70+ if err != nil {
71+ h.sendError(w, http.StatusInternalServerError, "failed to list images")
72+ return
73+ }
74+
75+ response := make([]dto.Image, 0, len(images))
76+ for _, image := range images {
77+ response = append(response, toImageResponse(image))
78+ }
79+
80+ h.sendJSON(w, http.StatusOK, dto.ImagesResponse{Images: response})
81+}
82+
83+func (h *Handler) handleImagesPrune(w http.ResponseWriter, r *http.Request) {
84+ ctx := r.Context()
85+ log := zerowrap.FromCtx(ctx)
86+
87+ if !HasAccess(ctx, domain.AdminResourceConfig, domain.AdminActionWrite) {
88+ h.sendError(w, http.StatusForbidden, "insufficient permissions for config:write")
89+ return
90+ }
91+
92+ r.Body = http.MaxBytesReader(w, r.Body, maxAdminRequestSize)
93+ var req dto.ImagePruneRequest
94+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
95+ log.Warn().Err(err).Msg("invalid image prune JSON")
96+ h.sendError(w, http.StatusBadRequest, "invalid JSON")
97+ return
98+ }
99+
100+ keepLast := 0
101+ if req.KeepLast != nil {
102+ if *req.KeepLast < 0 {
103+ h.sendError(w, http.StatusBadRequest, "keep_last must be >= 0")
104+ return
105+ }
106+ keepLast = *req.KeepLast
107+ }
108+
109+ report, err := h.imageSvc.Prune(ctx, keepLast)
110+ if err != nil {
111+ log.Error().Err(err).Int("keep_last", keepLast).Msg("image prune failed")
112+ h.sendError(w, http.StatusInternalServerError, "failed to prune images")
113+ return
114+ }
115+
116+ h.sendJSON(w, http.StatusOK, toImagePruneResponse(report))
117+}
A internal/boundaries/in/images.go +17 -0
@@ -0,0 +1,17 @@
1+// Package in defines input ports (interfaces) for use cases.
2+package in
3+
4+import (
5+ "context"
6+
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+// ImageService defines image listing and prune operations.
11+type ImageService interface {
12+ // ListImages returns runtime images.
13+ ListImages(ctx context.Context) ([]domain.ImageInfo, error)
14+
15+ // Prune removes unused images and applies retention.
16+ Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error)
17+}
@@ -0,0 +1,17 @@
1+// Package in defines input ports (interfaces) for use cases.
2+package in
3+
4+import (
5+ "context"
6+
7+ "github.com/bnema/gordon/internal/domain"
8+)
9+
10+// ImageService defines image listing and prune operations.
11+type ImageService interface {
12+ // ListImages returns runtime images.
13+ ListImages(ctx context.Context) ([]domain.ImageInfo, error)
14+
15+ // Prune removes unused images and applies retention.
16+ Prune(ctx context.Context, keepLast int) (domain.ImagePruneReport, error)
17+}

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