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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 33 | 33 | authSvc in.AuthService | |
| 34 | 34 | containerSvc in.ContainerService | |
| 35 | 35 | backupSvc in.BackupService | |
| 36 | + | imageSvc in.ImageService | |
| 36 | 37 | healthSvc in.HealthService | |
| 37 | 38 | secretSvc in.SecretService | |
| 38 | 39 | logSvc in.LogService | |
| @@ -132,12 +133,19 @@ func NewHandler( | |||
| 132 | 133 | eventBus out.EventPublisher, | |
| 133 | 134 | log zerowrap.Logger, | |
| 134 | 135 | backupSvc in.BackupService, | |
| 136 | + | imageSvcs ...in.ImageService, | |
| 135 | 137 | ) *Handler { | |
| 138 | + | var imageSvc in.ImageService | |
| 139 | + | if len(imageSvcs) > 0 { | |
| 140 | + | imageSvc = imageSvcs[0] | |
| 141 | + | } | |
| 142 | + | ||
| 136 | 143 | return &Handler{ | |
| 137 | 144 | configSvc: configSvc, | |
| 138 | 145 | authSvc: authSvc, | |
| 139 | 146 | containerSvc: containerSvc, | |
| 140 | 147 | backupSvc: backupSvc, | |
| 148 | + | imageSvc: imageSvc, | |
| 141 | 149 | healthSvc: healthSvc, | |
| 142 | 150 | secretSvc: secretSvc, | |
| 143 | 151 | logSvc: logSvc, | |
| @@ -207,6 +215,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 207 | 215 | {"/deploy", h.handleDeploy}, | |
| 208 | 216 | {"/restart", h.handleRestart}, | |
| 209 | 217 | {"/tags", h.handleTags}, | |
| 218 | + | {"/images", h.handleImages}, | |
| 210 | 219 | {"/logs", h.handleLogs}, | |
| 211 | 220 | } | |
| 212 | 221 | for _, route := range prefixRoutes { | |
| @@ -33,6 +33,7 @@ type Handler struct { | |||||
| 33 | authSvc in.AuthService | 33 | authSvc in.AuthService | ||
| 34 | containerSvc in.ContainerService | 34 | containerSvc in.ContainerService | ||
| 35 | backupSvc in.BackupService | 35 | backupSvc in.BackupService | ||
| 36 | + | imageSvc in.ImageService | |||
| 36 | healthSvc in.HealthService | 37 | healthSvc in.HealthService | ||
| 37 | secretSvc in.SecretService | 38 | secretSvc in.SecretService | ||
| 38 | logSvc in.LogService | 39 | 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 ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "encoding/json" | |
| 7 | + | "errors" | |
| 7 | 8 | "fmt" | |
| 8 | 9 | "net/http" | |
| 9 | 10 | "net/http/httptest" | |
| 10 | 11 | "testing" | |
| 12 | + | "time" | |
| 11 | 13 | ||
| 12 | 14 | "github.com/bnema/zerowrap" | |
| 13 | 15 | "github.com/stretchr/testify/assert" | |
| @@ -17,6 +19,25 @@ import ( | |||
| 17 | 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 | 41 | func testLogger() zerowrap.Logger { | |
| 21 | 42 | return zerowrap.Default() | |
| 22 | 43 | } | |
| @@ -1290,3 +1311,231 @@ func TestHandler_BackupsDetectDomain(t *testing.T) { | |||
| 1290 | 1311 | assert.Equal(t, http.StatusOK, rec.Code) | |
| 1291 | 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 | + | } | |
| @@ -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 | + | } | |||