feat(admin): return structured deploy failure responses
- commit
- 963eb7f3525b7ae36112c44feb3b042b9088a9b1
- author
- brice · · 2026-04-06 12:45 UTC
- committer
- brice · · 2026-04-06 12:49 UTC
- parent
- 770a7a2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +86 -0
A internal/adapters/dto/admin_deploy_error.go +9 -0
| @@ -0,0 +1,9 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | // DeployErrorResponse represents a structured deployment failure response. | |
| 4 | + | type DeployErrorResponse struct { | |
| 5 | + | Error string `json:"error"` | |
| 6 | + | Cause string `json:"cause,omitempty"` | |
| 7 | + | Hint string `json:"hint,omitempty"` | |
| 8 | + | Logs []string `json:"logs,omitempty"` | |
| 9 | + | } | |
| @@ -0,0 +1,9 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | // DeployErrorResponse represents a structured deployment failure response. | |||
| 4 | + | type DeployErrorResponse struct { | |||
| 5 | + | Error string `json:"error"` | |||
| 6 | + | Cause string `json:"cause,omitempty"` | |||
| 7 | + | Hint string `json:"hint,omitempty"` | |||
| 8 | + | Logs []string `json:"logs,omitempty"` | |||
| 9 | + | } | |||
M internal/adapters/in/http/admin/handler.go +10 -0
| @@ -1280,6 +1280,16 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||
| 1280 | 1280 | container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) | |
| 1281 | 1281 | if err != nil { | |
| 1282 | 1282 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | |
| 1283 | + | var deployErr *domain.DeployFailureError | |
| 1284 | + | if errors.As(err, &deployErr) { | |
| 1285 | + | h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{ | |
| 1286 | + | Error: deployErr.Error(), | |
| 1287 | + | Cause: deployErr.Cause, | |
| 1288 | + | Hint: deployErr.Hint, | |
| 1289 | + | Logs: deployErr.Logs, | |
| 1290 | + | }) | |
| 1291 | + | return | |
| 1292 | + | } | |
| 1283 | 1293 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | |
| 1284 | 1294 | return | |
| 1285 | 1295 | } | |
| @@ -1280,6 +1280,16 @@ func (h *Handler) handleDeploy(w http.ResponseWriter, r *http.Request, path stri | |||||
| 1280 | container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) | 1280 | container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route) | ||
| 1281 | if err != nil { | 1281 | if err != nil { | ||
| 1282 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | 1282 | log.Error().Err(err).Str("domain", deployDomain).Msg("failed to deploy container") | ||
| 1283 | + | var deployErr *domain.DeployFailureError | |||
| 1284 | + | if errors.As(err, &deployErr) { | |||
| 1285 | + | h.sendJSON(w, http.StatusInternalServerError, dto.DeployErrorResponse{ | |||
| 1286 | + | Error: deployErr.Error(), | |||
| 1287 | + | Cause: deployErr.Cause, | |||
| 1288 | + | Hint: deployErr.Hint, | |||
| 1289 | + | Logs: deployErr.Logs, | |||
| 1290 | + | }) | |||
| 1291 | + | return | |||
| 1292 | + | } | |||
| 1283 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | 1293 | h.sendError(w, http.StatusInternalServerError, "failed to deploy container") | ||
| 1284 | return | 1294 | return | ||
| 1285 | } | 1295 | } | ||
M internal/adapters/in/http/admin/handler_test.go +67 -0
| @@ -1346,6 +1346,73 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) { | |||
| 1346 | 1346 | } | |
| 1347 | 1347 | } | |
| 1348 | 1348 | ||
| 1349 | + | func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |
| 1350 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1351 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 1352 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 1353 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 1354 | + | route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"} | |
| 1355 | + | deployErr := &domain.DeployFailureError{ | |
| 1356 | + | Summary: "failed to deploy", | |
| 1357 | + | Cause: "image pull failed", | |
| 1358 | + | Hint: "push the image before retrying", | |
| 1359 | + | Logs: []string{ | |
| 1360 | + | "pulling manifest", | |
| 1361 | + | "manifest unknown", | |
| 1362 | + | }, | |
| 1363 | + | Err: errors.New("manifest unknown: internal registry detail"), | |
| 1364 | + | } | |
| 1365 | + | ||
| 1366 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 1367 | + | d.ConfigSvc = configSvc | |
| 1368 | + | d.AuthSvc = authSvc | |
| 1369 | + | d.ContainerSvc = containerSvc | |
| 1370 | + | d.SecretSvc = secretSvc | |
| 1371 | + | }) | |
| 1372 | + | ||
| 1373 | + | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once() | |
| 1374 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once() | |
| 1375 | + | ||
| 1376 | + | req := httptest.NewRequest("POST", "/admin/deploy/app.example.com", nil) | |
| 1377 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| 1378 | + | rec := httptest.NewRecorder() | |
| 1379 | + | ||
| 1380 | + | handler.ServeHTTP(rec, req) | |
| 1381 | + | ||
| 1382 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 1383 | + | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, rec.Body.String()) | |
| 1384 | + | assert.NotContains(t, rec.Body.String(), "internal registry detail") | |
| 1385 | + | } | |
| 1386 | + | ||
| 1387 | + | func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) { | |
| 1388 | + | configSvc := inmocks.NewMockConfigService(t) | |
| 1389 | + | authSvc := inmocks.NewMockAuthService(t) | |
| 1390 | + | containerSvc := inmocks.NewMockContainerService(t) | |
| 1391 | + | secretSvc := inmocks.NewMockSecretService(t) | |
| 1392 | + | route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"} | |
| 1393 | + | deployErr := errors.New("manifest unknown: internal registry detail") | |
| 1394 | + | ||
| 1395 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 1396 | + | d.ConfigSvc = configSvc | |
| 1397 | + | d.AuthSvc = authSvc | |
| 1398 | + | d.ContainerSvc = containerSvc | |
| 1399 | + | d.SecretSvc = secretSvc | |
| 1400 | + | }) | |
| 1401 | + | ||
| 1402 | + | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once() | |
| 1403 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once() | |
| 1404 | + | ||
| 1405 | + | req := httptest.NewRequest("POST", "/admin/deploy/app.example.com", nil) | |
| 1406 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |
| 1407 | + | rec := httptest.NewRecorder() | |
| 1408 | + | ||
| 1409 | + | handler.ServeHTTP(rec, req) | |
| 1410 | + | ||
| 1411 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |
| 1412 | + | assert.JSONEq(t, `{"error":"failed to deploy container"}`, rec.Body.String()) | |
| 1413 | + | assert.NotContains(t, rec.Body.String(), "internal registry detail") | |
| 1414 | + | } | |
| 1415 | + | ||
| 1349 | 1416 | func TestHandler_Restart_ContainerNotFound(t *testing.T) { | |
| 1350 | 1417 | configSvc := inmocks.NewMockConfigService(t) | |
| 1351 | 1418 | authSvc := inmocks.NewMockAuthService(t) | |
| @@ -1346,6 +1346,73 @@ func TestHandler_Restart_InvalidDomain(t *testing.T) { | |||||
| 1346 | } | 1346 | } | ||
| 1347 | } | 1347 | } | ||
| 1348 | 1348 | ||||
| 1349 | + | func TestHandler_Deploy_StructuredDeployFailure(t *testing.T) { | |||
| 1350 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1351 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1352 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1353 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1354 | + | route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"} | |||
| 1355 | + | deployErr := &domain.DeployFailureError{ | |||
| 1356 | + | Summary: "failed to deploy", | |||
| 1357 | + | Cause: "image pull failed", | |||
| 1358 | + | Hint: "push the image before retrying", | |||
| 1359 | + | Logs: []string{ | |||
| 1360 | + | "pulling manifest", | |||
| 1361 | + | "manifest unknown", | |||
| 1362 | + | }, | |||
| 1363 | + | Err: errors.New("manifest unknown: internal registry detail"), | |||
| 1364 | + | } | |||
| 1365 | + | ||||
| 1366 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 1367 | + | d.ConfigSvc = configSvc | |||
| 1368 | + | d.AuthSvc = authSvc | |||
| 1369 | + | d.ContainerSvc = containerSvc | |||
| 1370 | + | d.SecretSvc = secretSvc | |||
| 1371 | + | }) | |||
| 1372 | + | ||||
| 1373 | + | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once() | |||
| 1374 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once() | |||
| 1375 | + | ||||
| 1376 | + | req := httptest.NewRequest("POST", "/admin/deploy/app.example.com", nil) | |||
| 1377 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 1378 | + | rec := httptest.NewRecorder() | |||
| 1379 | + | ||||
| 1380 | + | handler.ServeHTTP(rec, req) | |||
| 1381 | + | ||||
| 1382 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 1383 | + | assert.JSONEq(t, `{"error":"failed to deploy","cause":"image pull failed","hint":"push the image before retrying","logs":["pulling manifest","manifest unknown"]}`, rec.Body.String()) | |||
| 1384 | + | assert.NotContains(t, rec.Body.String(), "internal registry detail") | |||
| 1385 | + | } | |||
| 1386 | + | ||||
| 1387 | + | func TestHandler_Deploy_GenericErrorStillUsesLegacyBody(t *testing.T) { | |||
| 1388 | + | configSvc := inmocks.NewMockConfigService(t) | |||
| 1389 | + | authSvc := inmocks.NewMockAuthService(t) | |||
| 1390 | + | containerSvc := inmocks.NewMockContainerService(t) | |||
| 1391 | + | secretSvc := inmocks.NewMockSecretService(t) | |||
| 1392 | + | route := &domain.Route{Domain: "app.example.com", Image: "registry.local/myapp:latest"} | |||
| 1393 | + | deployErr := errors.New("manifest unknown: internal registry detail") | |||
| 1394 | + | ||||
| 1395 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 1396 | + | d.ConfigSvc = configSvc | |||
| 1397 | + | d.AuthSvc = authSvc | |||
| 1398 | + | d.ContainerSvc = containerSvc | |||
| 1399 | + | d.SecretSvc = secretSvc | |||
| 1400 | + | }) | |||
| 1401 | + | ||||
| 1402 | + | configSvc.EXPECT().GetRoute(mock.Anything, "app.example.com").Return(route, nil).Once() | |||
| 1403 | + | containerSvc.EXPECT().Deploy(mock.Anything, *route).Return(nil, deployErr).Once() | |||
| 1404 | + | ||||
| 1405 | + | req := httptest.NewRequest("POST", "/admin/deploy/app.example.com", nil) | |||
| 1406 | + | req = req.WithContext(ctxWithScopes("admin:config:write")) | |||
| 1407 | + | rec := httptest.NewRecorder() | |||
| 1408 | + | ||||
| 1409 | + | handler.ServeHTTP(rec, req) | |||
| 1410 | + | ||||
| 1411 | + | assert.Equal(t, http.StatusInternalServerError, rec.Code) | |||
| 1412 | + | assert.JSONEq(t, `{"error":"failed to deploy container"}`, rec.Body.String()) | |||
| 1413 | + | assert.NotContains(t, rec.Body.String(), "internal registry detail") | |||
| 1414 | + | } | |||
| 1415 | + | ||||
| 1349 | func TestHandler_Restart_ContainerNotFound(t *testing.T) { | 1416 | func TestHandler_Restart_ContainerNotFound(t *testing.T) { | ||
| 1350 | configSvc := inmocks.NewMockConfigService(t) | 1417 | configSvc := inmocks.NewMockConfigService(t) | ||
| 1351 | authSvc := inmocks.NewMockAuthService(t) | 1418 | authSvc := inmocks.NewMockAuthService(t) | ||