detached 963eb7f

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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
12801280 container, err := h.containerSvc.Deploy(domain.WithInternalDeploy(ctx), *route)
12811281 if err != nil {
12821282 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+ }
12831293 h.sendError(w, http.StatusInternalServerError, "failed to deploy container")
12841294 return
12851295 }
@@ -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 return1294 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) {
13461346 }
13471347 }
13481348
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+
13491416 func TestHandler_Restart_ContainerNotFound(t *testing.T) {
13501417 configSvc := inmocks.NewMockConfigService(t)
13511418 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)

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