v3.0.0 f19f453

handler_apps_test.go RawHistoryFolder

1package admin
2
3import (
4	"bytes"
5	"encoding/json"
6	"fmt"
7	"io"
8	"net/http"
9	"net/http/httptest"
10	"sort"
11	"testing"
12	"time"
13
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/mock"
16	"github.com/stretchr/testify/require"
17
18	"github.com/bnema/gordon/internal/adapters/dto"
19	in "github.com/bnema/gordon/internal/boundaries/in"
20	inmocks "github.com/bnema/gordon/internal/boundaries/in/mocks"
21	"github.com/bnema/gordon/internal/domain"
22)
23
24func appsTestHandler(t *testing.T, appSvc *inmocks.MockAppService) *Handler {
25	t.Helper()
26	return NewHandler(HandlerDeps{
27		ConfigSvc:     inmocks.NewMockConfigService(t),
28		AuthSvc:       inmocks.NewMockAuthService(t),
29		ContainerSvc:  inmocks.NewMockContainerService(t),
30		HealthSvc:     inmocks.NewMockHealthService(t),
31		Log:           testLogger(),
32		ReloadTrigger: noopReloadTrigger{},
33		AppSvc:        appSvc,
34	})
35}
36
37func appsRequest(t *testing.T, handler *Handler, method, target string, body any, scopes ...string) *httptest.ResponseRecorder {
38	t.Helper()
39	var reader io.Reader
40	if body != nil {
41		raw, err := json.Marshal(body)
42		require.NoError(t, err)
43		reader = bytes.NewReader(raw)
44	}
45	req := httptest.NewRequest(method, target, reader)
46	if method != http.MethodGet {
47		req.Header.Set("Idempotency-Key", "test-operation-key")
48	}
49	req = req.WithContext(ctxWithScopes(scopes...))
50	rec := httptest.NewRecorder()
51	handler.ServeHTTP(rec, req)
52	return rec
53}
54
55// appsServerDo sends a request through a test server backed by the apps
56// handler and returns the real HTTP status code and body. The scopes are
57// injected by newScopedTestServer because the server, not the client, owns
58// the request context.
59func appsServerDo(t *testing.T, srv *httptest.Server, method, target string, body any) (int, []byte) {
60	t.Helper()
61	var reader io.Reader
62	if body != nil {
63		raw, err := json.Marshal(body)
64		require.NoError(t, err)
65		reader = bytes.NewReader(raw)
66	}
67	req, err := http.NewRequest(method, srv.URL+target, reader)
68	require.NoError(t, err)
69	if method != http.MethodGet {
70		req.Header.Set("Idempotency-Key", "test-operation-key")
71	}
72	resp, err := srv.Client().Do(req)
73	require.NoError(t, err)
74	defer func() { require.NoError(t, resp.Body.Close()) }()
75	assert.Equal(t, "application/json", resp.Header.Get("Content-Type"))
76	raw, err := io.ReadAll(resp.Body)
77	require.NoError(t, err)
78	return resp.StatusCode, raw
79}
80
81const validAppManifest = `
82name = "blog"
83[services.web]
84image = "img:1"
85[[services.web.http]]
86host = "blog.example.com"
87port = 8080
88`
89
90func TestHandler_AppApply_Persists(t *testing.T) {
91	appSvc := inmocks.NewMockAppService(t)
92	handler := appsTestHandler(t, appSvc)
93
94	appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
95		&in.AppApplyResult{App: "blog", ResultingRevision: "rev-1", Pending: true, IntentID: "apply-1"},
96		nil, nil,
97	).Once()
98
99	rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
100		dto.AppApplyRequest{ManifestTOML: validAppManifest}, "admin:apps:write")
101	require.Equal(t, http.StatusOK, rec.Code)
102	var resp dto.AppApplyResponse
103	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
104	assert.Equal(t, "blog", resp.App)
105	assert.Equal(t, "rev-1", resp.ResultingRevision)
106	assert.True(t, resp.Pending)
107}
108
109func TestHandler_AppApply_RejectsBadManifest(t *testing.T) {
110	appSvc := inmocks.NewMockAppService(t)
111	handler := appsTestHandler(t, appSvc)
112
113	rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
114		dto.AppApplyRequest{ManifestTOML: "name = \"Bad!\"\n"}, "admin:apps:write")
115	assert.Equal(t, http.StatusBadRequest, rec.Code)
116	var envelope dto.AppError
117	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
118	assert.Equal(t, "invalid-manifest", envelope.Error)
119}
120
121func TestHandler_AppDeploy_MapsOp(t *testing.T) {
122	appSvc := inmocks.NewMockAppService(t)
123	handler := appsTestHandler(t, appSvc)
124
125	op := &domain.AppOperation{
126		Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
127		Outcome: domain.AppOutcomeSuccess,
128		Steps: []domain.AppOperationStep{
129			{ID: "preflight", State: domain.AppStepSucceeded},
130			{ID: "service.web.replace", State: domain.AppStepSucceeded, Before: "c-old", After: "c-new"},
131		},
132	}
133	appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
134	appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
135		App: "blog", Converged: true, ConvergedRevision: "rev-1",
136		Services: map[string]in.AppServiceView{"web": {EffectiveRevision: "rev-1", Container: "c-new"}},
137		Retained: in.AppRetainedView{Volumes: []string{"gordon-blog--web--vol--data"}, Secrets: []string{"gordon/apps/app-1/web/database-url"}},
138	}, nil).Once()
139
140	rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
141		dto.AppDeployRequest{}, "admin:apps:write")
142	require.Equal(t, http.StatusOK, rec.Code)
143	var resp dto.AppDeployResponse
144	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
145	assert.Equal(t, "op-1", resp.Op)
146	assert.Equal(t, "success", resp.Outcome)
147	require.Contains(t, resp.Services, "web")
148	assert.Equal(t, "deployed", resp.Services["web"].Result)
149	assert.Equal(t, "c-new", resp.Services["web"].After)
150	// Effective and retained state comes from the app read model, never
151	// from the journal outcome.
152	require.NotNil(t, resp.Effective)
153	assert.True(t, resp.Effective.Converged)
154	assert.Equal(t, "rev-1", resp.Effective.Services["web"])
155	require.NotNil(t, resp.Retained)
156	assert.Equal(t, []string{"gordon-blog--web--vol--data"}, resp.Retained.Volumes)
157}
158
159// TestHandler_AppDeploy_MapsCleanupWarnings proves bounded leftovers of a
160// successful mutation reach the wire instead of being dropped.
161func TestHandler_AppDeploy_MapsCleanupWarnings(t *testing.T) {
162	appSvc := inmocks.NewMockAppService(t)
163	handler := appsTestHandler(t, appSvc)
164
165	op := &domain.AppOperation{
166		Op: "op-1", Kind: "deploy", App: "blog", InputRevision: "rev-1",
167		Outcome: domain.AppOutcomeSuccess,
168		Steps:   []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}},
169		Warnings: []domain.AppOperationWarning{{
170			Service: "web", Leftover: "ctr-old", Detail: "remove: still present",
171		}},
172	}
173	appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(op, nil).Once()
174	appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog"}, nil).Once()
175
176	rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/deploy",
177		dto.AppDeployRequest{}, "admin:apps:write")
178	require.Equal(t, http.StatusOK, rec.Code)
179	var resp dto.AppDeployResponse
180	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
181	require.Len(t, resp.CleanupWarnings, 1)
182	assert.Equal(t, "web", resp.CleanupWarnings[0].Service)
183	assert.Equal(t, "ctr-old", resp.CleanupWarnings[0].Leftover)
184	assert.Equal(t, "remove: still present", resp.CleanupWarnings[0].Detail)
185}
186
187// TestHandler_AppDeploy_RunningReturns202 proves a newly owned deploy whose
188// effects are still executing answers 202 Accepted with the durable
189// operation identity and a stable running status, so the client can recover
190// completion through GET operations/by-key. The claim and its journal are
191// already persisted when the handler answers.
192func TestHandler_AppDeploy_RunningReturns202(t *testing.T) {
193	appSvc := inmocks.NewMockAppService(t)
194	handler := appsTestHandler(t, appSvc)
195
196	running := &domain.AppOperation{
197		Op: "op-run", Kind: "deploy", App: "blog", InputRevision: "rev-1",
198		Steps: []domain.AppOperationStep{
199			{ID: "preflight", State: domain.AppStepSucceeded},
200			{ID: "service.web.replace", State: domain.AppStepPending},
201		},
202	}
203	appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, nil).Once()
204	appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
205
206	srv := newScopedTestServer(t, handler, "admin:apps:write")
207	status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
208	require.Equal(t, http.StatusAccepted, status)
209
210	var resp dto.AppDeployResponse
211	require.NoError(t, json.Unmarshal(body, &resp))
212	assert.Equal(t, "op-run", resp.Op)
213	assert.Equal(t, "running", resp.Status)
214	assert.Empty(t, resp.Outcome, "a running operation has no terminal outcome")
215	require.Contains(t, resp.Services, "web")
216	assert.Equal(t, "pending", resp.Services["web"].Result)
217}
218
219// TestHandler_AppDeploy_TerminalReplayReturnsOK proves a duplicate idempotency
220// key whose stored operation already reached a successful terminal outcome
221// replays with 200 and the stored status instead of 202.
222func TestHandler_AppDeploy_TerminalReplayReturnsOK(t *testing.T) {
223	appSvc := inmocks.NewMockAppService(t)
224	handler := appsTestHandler(t, appSvc)
225
226	terminal := &domain.AppOperation{
227		Op: "op-done", Kind: "deploy", App: "blog", InputRevision: "rev-1",
228		Outcome: domain.AppOutcomeSuccess,
229		Steps:   []domain.AppOperationStep{{ID: "service.web.replace", State: domain.AppStepSucceeded}},
230	}
231	appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(terminal, nil).Once()
232	appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
233
234	srv := newScopedTestServer(t, handler, "admin:apps:write")
235	status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
236	require.Equal(t, http.StatusOK, status)
237
238	var resp dto.AppDeployResponse
239	require.NoError(t, json.Unmarshal(body, &resp))
240	assert.Equal(t, "op-done", resp.Op)
241	assert.Equal(t, "success", resp.Status)
242	assert.Equal(t, "success", resp.Outcome)
243}
244
245// TestHandler_AppDeploy_TerminalFailureReplayIsVisible proves a replay of a
246// failed operation keeps its conflict mapping while still surfacing the
247// stored failure and per-service result, never a 202.
248func TestHandler_AppDeploy_TerminalFailureReplayIsVisible(t *testing.T) {
249	appSvc := inmocks.NewMockAppService(t)
250	handler := appsTestHandler(t, appSvc)
251
252	failed := &domain.AppOperation{
253		Op: "op-fail", Kind: "deploy", App: "blog", InputRevision: "rev-1",
254		Outcome: domain.AppOutcomeFailed,
255		Steps: []domain.AppOperationStep{{
256			ID: "service.web.replace", State: domain.AppStepFailed, Error: "boom",
257		}},
258	}
259	replayErr := fmt.Errorf("deployment: operation op-fail previously ended with outcome failed: %w", domain.ErrAppStateConflict)
260	appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(failed, replayErr).Once()
261	appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
262
263	srv := newScopedTestServer(t, handler, "admin:apps:write")
264	status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
265	require.Equal(t, http.StatusConflict, status)
266
267	var resp dto.AppDeployResponse
268	require.NoError(t, json.Unmarshal(body, &resp))
269	assert.Equal(t, "op-fail", resp.Op)
270	assert.Equal(t, "failed", resp.Status)
271	assert.Equal(t, "failed", resp.Outcome)
272	require.Contains(t, resp.Services, "web")
273	assert.Equal(t, "failed", resp.Services["web"].Result)
274	assert.Equal(t, "boom", resp.Services["web"].Error)
275}
276
277// assertRunningReplayJournal pins the replay contract shared by a running
278// replay before and after any service step has started: 409, the operation
279// journal DTO with a running status and no terminal outcome, and never the
280// mapped preflight error envelope. The top-level key set is asserted so both
281// cases are proven to share one wire shape.
282func assertRunningReplayJournal(t *testing.T, status int, body []byte, opID, lastStep string) {
283	t.Helper()
284	require.Equal(t, http.StatusConflict, status)
285
286	var shape map[string]json.RawMessage
287	require.NoError(t, json.Unmarshal(body, &shape))
288	_, hasError := shape["error"]
289	assert.False(t, hasError, "a running replay must not degrade to the preflight error envelope")
290	keys := make([]string, 0, len(shape))
291	for key := range shape {
292		keys = append(keys, key)
293	}
294	sort.Strings(keys)
295	assert.Equal(t,
296		[]string{"app", "effective", "op", "outcome", "retained", "revision", "services", "status", "steps"},
297		keys, "a running replay always returns the operation journal shape")
298
299	var resp dto.AppDeployResponse
300	require.NoError(t, json.Unmarshal(body, &resp))
301	assert.Equal(t, opID, resp.Op)
302	assert.Equal(t, "running", resp.Status)
303	assert.Empty(t, resp.Outcome, "a running operation has no terminal outcome")
304	found := false
305	for _, step := range resp.Steps {
306		if step.ID == lastStep {
307			found = true
308			assert.Equal(t, domain.AppStepPending, step.State)
309		}
310	}
311	assert.True(t, found, "the journal must expose step %q", lastStep)
312}
313
314// TestHandler_AppDeploy_RunningReplayBeforeServiceSteps proves a same-key
315// replay of an operation still in flight answers 409 with the stored journal
316// even before any service step has started. A non-terminal claim is a running
317// journal, not a preflight failure, so the mapped error envelope never
318// replaces it.
319func TestHandler_AppDeploy_RunningReplayBeforeServiceSteps(t *testing.T) {
320	appSvc := inmocks.NewMockAppService(t)
321	handler := appsTestHandler(t, appSvc)
322
323	running := &domain.AppOperation{
324		Op: "op-run-pre", Kind: "deploy", App: "blog", InputRevision: "rev-1",
325		Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
326	}
327	replayErr := fmt.Errorf("deployment: operation op-run-pre has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
328	appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, replayErr).Once()
329	appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
330
331	srv := newScopedTestServer(t, handler, "admin:apps:write")
332	status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
333
334	assertRunningReplayJournal(t, status, body, "op-run-pre", "preflight")
335}
336
337// TestHandler_AppDeploy_RunningReplayAfterServiceSteps is the after-steps
338// twin: the same running replay, now with a pending service step, must answer
339// the identical 409 journal shape. Only the journal contents may differ from
340// the before-steps case, never the envelope.
341func TestHandler_AppDeploy_RunningReplayAfterServiceSteps(t *testing.T) {
342	appSvc := inmocks.NewMockAppService(t)
343	handler := appsTestHandler(t, appSvc)
344
345	running := &domain.AppOperation{
346		Op: "op-run-post", Kind: "deploy", App: "blog", InputRevision: "rev-1",
347		Steps: []domain.AppOperationStep{
348			{ID: "preflight", State: domain.AppStepSucceeded},
349			{ID: "service.web.replace", State: domain.AppStepPending},
350		},
351	}
352	replayErr := fmt.Errorf("deployment: operation op-run-post has not reached a terminal outcome: %w", domain.ErrAppStateConflict)
353	appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(running, replayErr).Once()
354	appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{App: "blog", Services: map[string]in.AppServiceView{}}, nil).Once()
355
356	srv := newScopedTestServer(t, handler, "admin:apps:write")
357	status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
358
359	assertRunningReplayJournal(t, status, body, "op-run-post", "service.web.replace")
360}
361
362func TestHandler_AppDeploy_ServiceScopeRequiredIs400(t *testing.T) {
363	appSvc := inmocks.NewMockAppService(t)
364	handler := appsTestHandler(t, appSvc)
365	scopeErr := fmt.Errorf("app blog has several services (db, web): pass --service NAME or --all: %w", domain.ErrAppServiceScope)
366	appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", false, "test-operation-key").Return(nil, scopeErr).Once()
367
368	srv := newScopedTestServer(t, handler, "admin:apps:write")
369	status, body := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{})
370	require.Equal(t, http.StatusBadRequest, status)
371
372	var resp dto.AppError
373	require.NoError(t, json.Unmarshal(body, &resp))
374	assert.Equal(t, "service-scope-required", resp.Error)
375	assert.Contains(t, resp.Message, "(db, web)")
376}
377
378func TestHandler_AppDeploy_PassesAll(t *testing.T) {
379	appSvc := inmocks.NewMockAppService(t)
380	handler := appsTestHandler(t, appSvc)
381	appSvc.EXPECT().Deploy(mock.Anything, "blog", "", "", true, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
382
383	srv := newScopedTestServer(t, handler, "admin:apps:write")
384	status, _ := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/deploy", dto.AppDeployRequest{All: true})
385	require.Equal(t, http.StatusConflict, status)
386}
387
388func TestHandler_AppRestart_PassesServiceAndAll(t *testing.T) {
389	appSvc := inmocks.NewMockAppService(t)
390	handler := appsTestHandler(t, appSvc)
391	appSvc.EXPECT().Restart(mock.Anything, "blog", "", true, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
392	appSvc.EXPECT().Restart(mock.Anything, "blog", "web", false, "test-operation-key").Return(nil, domain.ErrAppStateConflict).Once()
393
394	srv := newScopedTestServer(t, handler, "admin:apps:write")
395	status, _ := appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/restart?all=true", nil)
396	require.Equal(t, http.StatusConflict, status)
397	status, _ = appsServerDo(t, srv, http.MethodPost, "/admin/apps/blog/restart?service=web", nil)
398	require.Equal(t, http.StatusConflict, status)
399}
400
401// TestHandler_AppDeploy_RejectsMissingIdempotencyKey keeps the request
402// validation contract: a deploy without a valid key never reaches the
403// service.
404func TestHandler_AppDeploy_RejectsMissingIdempotencyKey(t *testing.T) {
405	appSvc := inmocks.NewMockAppService(t)
406	handler := appsTestHandler(t, appSvc)
407
408	srv := newScopedTestServer(t, handler, "admin:apps:write")
409	req, err := http.NewRequest(http.MethodPost, srv.URL+"/admin/apps/blog/deploy", nil)
410	require.NoError(t, err)
411	resp, err := srv.Client().Do(req)
412	require.NoError(t, err)
413	defer func() { require.NoError(t, resp.Body.Close()) }()
414
415	require.Equal(t, http.StatusBadRequest, resp.StatusCode)
416}
417
418func TestHandler_AppList_RequiresScope(t *testing.T) {
419	appSvc := inmocks.NewMockAppService(t)
420	handler := appsTestHandler(t, appSvc)
421
422	rec := appsRequest(t, handler, http.MethodGet, "/admin/apps", nil, "admin:status:read")
423	assert.Equal(t, http.StatusForbidden, rec.Code)
424
425	appSvc.EXPECT().List(mock.Anything).Return(nil, nil).Once()
426	rec = appsRequest(t, handler, http.MethodGet, "/admin/apps", nil, "admin:apps:read")
427	assert.Equal(t, http.StatusOK, rec.Code)
428}
429
430func TestHandler_AppSecretsSet_Forwards(t *testing.T) {
431	appSvc := inmocks.NewMockAppService(t)
432	handler := appsTestHandler(t, appSvc)
433
434	appSvc.EXPECT().SetSecrets(mock.Anything, "blog", "web", map[string]string{"DATABASE_URL": "v"}).Return(nil).Once()
435	rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/secrets/set",
436		dto.AppSecretSetRequest{Service: "web", Secrets: map[string]string{"DATABASE_URL": "v"}}, "admin:apps:write")
437	assert.Equal(t, http.StatusOK, rec.Code)
438}
439
440// TestHandler_AppLifecycle_UnknownAppIsNotFound proves a lifecycle
441// mutation of a name with no app identity is a 404, not a 500 or a silent
442// success.
443func TestHandler_AppLifecycle_UnknownAppIsNotFound(t *testing.T) {
444	appSvc := inmocks.NewMockAppService(t)
445	handler := appsTestHandler(t, appSvc)
446
447	appSvc.EXPECT().Stop(mock.Anything, "ghost", "test-operation-key").
448		Return(nil, fmt.Errorf("deployment: app %q does not exist: %w", "ghost", domain.ErrAppNotFound)).Once()
449
450	rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/ghost/stop", nil, "admin:apps:write")
451	require.Equal(t, http.StatusNotFound, rec.Code)
452	var envelope dto.AppError
453	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
454	assert.Equal(t, "app-not-found", envelope.Error)
455}
456
457// TestHandler_AppLifecycle_ReplayConflictCarriesJournal proves a replayed
458// key that never finished returns 409 with the stored journal, so the
459// caller can inspect what was recorded instead of guessing.
460func TestHandler_AppLifecycle_ReplayConflictCarriesJournal(t *testing.T) {
461	appSvc := inmocks.NewMockAppService(t)
462	handler := appsTestHandler(t, appSvc)
463
464	op := &domain.AppOperation{
465		Op: "test-operation-key", Kind: "remove", App: "blog",
466		Steps: []domain.AppOperationStep{{ID: "service.web.remove", State: domain.AppStepPending}},
467	}
468	appSvc.EXPECT().Remove(mock.Anything, "blog", "test-operation-key").
469		Return(op, domain.ErrAppStateConflict).Once()
470	// A removal that already retired the incarnation has no app read
471	// model left, so the response carries the journal alone.
472	appSvc.EXPECT().Show(mock.Anything, "blog").
473		Return(nil, domain.ErrAppNotFound).Once()
474
475	rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/blog/remove", nil, "admin:apps:write")
476	require.Equal(t, http.StatusConflict, rec.Code)
477	var resp dto.AppDeployResponse
478	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
479	assert.Equal(t, "test-operation-key", resp.Op)
480	assert.Nil(t, resp.Effective)
481	assert.Nil(t, resp.Retained)
482}
483
484func TestHandler_AppShow_MapsReadModel(t *testing.T) {
485	appSvc := inmocks.NewMockAppService(t)
486	handler := appsTestHandler(t, appSvc)
487
488	started := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
489	appSvc.EXPECT().Show(mock.Anything, "blog").Return(&in.AppDetail{
490		App:               "blog",
491		DesiredRevision:   "rev-2",
492		DesiredStatus:     "pending",
493		Pending:           true,
494		Converged:         false,
495		ConvergedRevision: "rev-1",
496		Stopped:           true,
497		Services: map[string]in.AppServiceView{
498			"web": {EffectiveRevision: "rev-1", Digest: "sha256:x", Container: "ctr-1", RestartUnsafe: true},
499		},
500		Retained: in.AppRetainedView{
501			Volumes: []string{"gordon-blog--web--vol--data"},
502			Secrets: []string{"gordon/apps/app-1/web/database-url"},
503			Images:  []string{"registry.example.com/blog/web:1.4.2"},
504		},
505		LastOp:          "op-9",
506		LastOpKind:      "deploy",
507		LastOutcome:     "partial",
508		LastOpStartedAt: started,
509	}, nil).Once()
510
511	rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog", nil, "admin:apps:read")
512	require.Equal(t, http.StatusOK, rec.Code)
513	var resp dto.AppShowResponse
514	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
515
516	assert.Equal(t, "rev-2", resp.Desired.Revision)
517	assert.True(t, resp.Desired.Pending)
518	assert.False(t, resp.Active.Converged)
519	assert.Equal(t, "rev-1", resp.Active.ConvergedRevision)
520	assert.True(t, resp.Intent.Stopped)
521	assert.True(t, resp.Active.Services["web"].RestartUnsafe, "restart safety must reach the wire")
522	assert.Equal(t, "ctr-1", resp.Active.Services["web"].Container)
523	assert.Equal(t, []string{"gordon-blog--web--vol--data"}, resp.Retained.Volumes)
524	assert.Equal(t, []string{"registry.example.com/blog/web:1.4.2"}, resp.Retained.Images)
525	require.NotNil(t, resp.LastOp)
526	assert.Equal(t, "op-9", resp.LastOp.Op)
527	assert.Equal(t, "deploy", resp.LastOp.Kind)
528	assert.Equal(t, "partial", resp.LastOp.Outcome)
529	assert.Equal(t, started, resp.LastOp.StartedAt)
530}
531
532// TestHandler_AppShow_UnknownAppIsNotFound proves showing a name with no
533// app identity is 404, not an empty success.
534func TestHandler_AppShow_UnknownAppIsNotFound(t *testing.T) {
535	appSvc := inmocks.NewMockAppService(t)
536	handler := appsTestHandler(t, appSvc)
537
538	appSvc.EXPECT().Show(mock.Anything, "ghost").
539		Return(nil, fmt.Errorf("apps: app %q does not exist: %w", "ghost", domain.ErrAppNotFound)).Once()
540
541	rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/ghost", nil, "admin:apps:read")
542	require.Equal(t, http.StatusNotFound, rec.Code)
543	var envelope dto.AppError
544	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
545	assert.Equal(t, "app-not-found", envelope.Error)
546}
547
548func TestHandler_AppList_MapsReadModel(t *testing.T) {
549	appSvc := inmocks.NewMockAppService(t)
550	handler := appsTestHandler(t, appSvc)
551
552	appSvc.EXPECT().List(mock.Anything).Return([]in.AppSummary{{
553		App: "blog", Desired: "rev-2", DesiredStatus: "pending",
554		Active: "rev-1", Converged: false, Pending: true, LastOutcome: "partial",
555	}}, nil).Once()
556
557	rec := appsRequest(t, handler, http.MethodGet, "/admin/apps", nil, "admin:apps:read")
558	require.Equal(t, http.StatusOK, rec.Code)
559	var envelope struct {
560		Apps []dto.AppSummaryDTO `json:"apps"`
561	}
562	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
563	require.Len(t, envelope.Apps, 1)
564	assert.Equal(t, "rev-2", envelope.Apps[0].Desired)
565	assert.Equal(t, "pending", envelope.Apps[0].DesiredStatus)
566	assert.True(t, envelope.Apps[0].Pending)
567	assert.Equal(t, "partial", envelope.Apps[0].LastOutcome)
568}
569
570func TestHandler_AppOpLookup_Recovers(t *testing.T) {
571	appSvc := inmocks.NewMockAppService(t)
572	handler := appsTestHandler(t, appSvc)
573
574	op := &domain.AppOperation{Op: "op-9", Kind: "deploy", App: "blog", InputRevision: "rev-1", Outcome: "success"}
575	appSvc.EXPECT().OperationByKey(mock.Anything, "blog", "op-9").Return(op, nil).Once()
576	rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog/operations/by-key/op-9", nil, "admin:apps:read")
577	require.Equal(t, http.StatusOK, rec.Code)
578	var resp dto.AppDeployResponse
579	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
580	assert.Equal(t, "op-9", resp.Op)
581}
582
583// TestHandler_AppOpLookup_GatesDiagnosticsByLogsScope proves failure
584// diagnostics are returned only to callers holding the logs read scope; an
585// apps-read actor receives the stable error alone.
586func TestHandler_AppOpLookup_GatesDiagnosticsByLogsScope(t *testing.T) {
587	appSvc := inmocks.NewMockAppService(t)
588	handler := appsTestHandler(t, appSvc)
589
590	op := &domain.AppOperation{
591		Op: "op-9", Kind: "deploy", App: "blog", InputRevision: "rev-1", Outcome: "failed",
592		Steps: []domain.AppOperationStep{{
593			ID: "service.web.replace", State: domain.AppStepFailed,
594			Error: "deployment: readiness failed", Diagnostics: []string{"SECRET=supersecret"},
595		}},
596	}
597	appSvc.EXPECT().OperationByKey(mock.Anything, "blog", "op-9").Return(op, nil).Twice()
598
599	appsRead := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog/operations/by-key/op-9", nil, "admin:apps:read")
600	require.Equal(t, http.StatusOK, appsRead.Code)
601	assert.NotContains(t, appsRead.Body.String(), "supersecret", "apps-read must not receive application diagnostics")
602	assert.NotContains(t, appsRead.Body.String(), "diagnostics")
603
604	logsRead := appsRequest(t, handler, http.MethodGet, "/admin/apps/blog/operations/by-key/op-9",
605		nil, "admin:apps:read", "admin:logs:read")
606	require.Equal(t, http.StatusOK, logsRead.Code)
607	var resp dto.AppDeployResponse
608	require.NoError(t, json.Unmarshal(logsRead.Body.Bytes(), &resp))
609	require.Len(t, resp.Steps, 1)
610	require.Len(t, resp.Steps[0].Diagnostics, 1)
611	assert.Equal(t, "SECRET=supersecret", resp.Steps[0].Diagnostics[0])
612}
613
614// TestHandler_AppApply_DryRunOmitsRevision proves a dry-run apply never
615// reports a revision: nothing was persisted, so resulting_revision is
616// omitted instead of echoing the app name.
617func TestHandler_AppApply_DryRunOmitsRevision(t *testing.T) {
618	appSvc := inmocks.NewMockAppService(t)
619	handler := appsTestHandler(t, appSvc)
620
621	appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, true).Return(
622		nil,
623		&in.AppDryRunResult{App: "blog", Valid: true},
624		nil,
625	).Once()
626
627	rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
628		dto.AppApplyRequest{ManifestTOML: validAppManifest, DryRun: true}, "admin:apps:write")
629	require.Equal(t, http.StatusOK, rec.Code)
630	var resp dto.AppApplyResponse
631	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
632	assert.Equal(t, "blog", resp.App)
633	assert.Empty(t, resp.ResultingRevision)
634	assert.NotContains(t, rec.Body.String(), "resulting_revision")
635}
636
637func TestHandler_AppSecretsList_MissingAppIs404(t *testing.T) {
638	appSvc := inmocks.NewMockAppService(t)
639	handler := appsTestHandler(t, appSvc)
640
641	appSvc.EXPECT().ListSecrets(mock.Anything, "ghost", "").Return(
642		nil,
643		fmt.Errorf("apps: app %q does not exist: %w", "ghost", domain.ErrAppNotFound),
644	).Once()
645
646	rec := appsRequest(t, handler, http.MethodGet, "/admin/apps/ghost/secrets", nil, "admin:apps:read")
647	require.Equal(t, http.StatusNotFound, rec.Code)
648	assert.Contains(t, rec.Body.String(), "app-not-found")
649}
650
651func TestHandler_AppApply_MapsDevicePolicyViolation(t *testing.T) {
652	appSvc := inmocks.NewMockAppService(t)
653	handler := appsTestHandler(t, appSvc)
654
655	appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
656		nil, nil,
657		fmt.Errorf("%w: app %q service %q device %q: refused by administrative device policy", domain.ErrDevicePolicy, "blog", "web", "test_gpu"),
658	).Once()
659
660	rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
661		dto.AppApplyRequest{ManifestTOML: validAppManifest}, "admin:apps:write")
662	assert.Equal(t, http.StatusBadRequest, rec.Code)
663	var envelope dto.AppError
664	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
665	assert.Equal(t, "device-policy-violation", envelope.Error)
666}
667
668func TestHandler_AppApply_MapsRuntimeUnsupported(t *testing.T) {
669	appSvc := inmocks.NewMockAppService(t)
670	handler := appsTestHandler(t, appSvc)
671
672	appSvc.EXPECT().Apply(mock.Anything, mock.Anything, mock.Anything, false).Return(
673		nil, nil,
674		fmt.Errorf("%w: podman 4.9.5 below minimum 5.4", domain.ErrRuntimeUnsupported),
675	).Once()
676
677	rec := appsRequest(t, handler, http.MethodPost, "/admin/apps/apply",
678		dto.AppApplyRequest{ManifestTOML: validAppManifest}, "admin:apps:write")
679	assert.Equal(t, http.StatusBadRequest, rec.Code)
680	var envelope dto.AppError
681	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
682	assert.Equal(t, "runtime-unsupported", envelope.Error)
683}

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