v3.3.0 b164fe9

apps_test.go RawHistoryFolder

1package cli
2
3// Tests for the app CLI surface (apps.go). The commands go through one
4// ControlPlane served by the generated mock: JSON parity (text and --json
5// carry equivalent semantics), no secret values in output, plan-contract
6// errors, and outcome-unknown guidance.
7
8import (
9	"bytes"
10	"context"
11	"encoding/json"
12	"errors"
13	"net/http"
14	"os"
15	"path/filepath"
16	"strings"
17	"testing"
18
19	"github.com/spf13/cobra"
20	"github.com/stretchr/testify/assert"
21	"github.com/stretchr/testify/mock"
22	"github.com/stretchr/testify/require"
23
24	"github.com/bnema/gordon/internal/adapters/dto"
25	climocks "github.com/bnema/gordon/internal/adapters/in/cli/mocks"
26	"github.com/bnema/gordon/internal/adapters/in/cli/remote"
27	"github.com/bnema/gordon/internal/domain"
28)
29
30// appPlane returns the generated control plane mock for one test.
31func appPlane(t *testing.T) *climocks.MockControlPlane {
32	t.Helper()
33	return climocks.NewMockControlPlane(t)
34}
35
36func writeManifest(t *testing.T, content string) string {
37	t.Helper()
38	path := filepath.Join(t.TempDir(), "app.toml")
39	require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
40	return path
41}
42
43func TestRunAppsApply_RejectsDryRunWithDeploy(t *testing.T) {
44	plane := appPlane(t)
45	err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "x.toml", true, true, false)
46	require.Error(t, err)
47	assert.Contains(t, err.Error(), "--dry-run")
48	assert.Contains(t, err.Error(), "--deploy")
49}
50
51func TestRunAppsApply_RequiresFile(t *testing.T) {
52	plane := appPlane(t)
53	err := runAppsApply(context.Background(), plane, strings.NewReader(""), &bytes.Buffer{}, &bytes.Buffer{}, "", false, false, false)
54	require.Error(t, err)
55	assert.Contains(t, err.Error(), "--file")
56}
57
58func TestRunAppsApply_JSONParity(t *testing.T) {
59	want := &dto.AppApplyResponse{
60		App: "blog", FormerRevision: "rev-a", ResultingRevision: "rev-b",
61		Pending: true, Intent: "apply-123",
62		Diff: dto.AppDiffSection{Added: []string{"service.web"}},
63	}
64	plane := appPlane(t)
65	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(want, nil).Once()
66	var out bytes.Buffer
67	require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
68		writeManifest(t, "[app]\nname = \"blog\"\n"), false, false, true))
69	var got dto.AppApplyResponse
70	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
71	assert.Equal(t, *want, got)
72}
73
74func TestRunAppsApply_ChainsAcceptedRevision(t *testing.T) {
75	plane := appPlane(t)
76	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(
77		&dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once()
78	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
79		&dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
80	var out bytes.Buffer
81	require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
82		writeManifest(t, "x"), false, true, false))
83	assert.Contains(t, out.String(), "rev-b")
84}
85
86func TestRunAppsApply_ChainedJSONIsOneDocument(t *testing.T) {
87	plane := appPlane(t)
88	plane.EXPECT().ApplyApp(mock.Anything, mock.Anything).Return(
89		&dto.AppApplyResponse{App: "blog", ResultingRevision: "rev-b", Pending: true, Intent: "apply-1"}, nil).Once()
90	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
91		&dto.AppDeployResponse{Op: "op-1", App: "blog", Revision: "rev-b", Outcome: "success"}, "key-1", nil).Once()
92	var out bytes.Buffer
93	require.NoError(t, runAppsApply(context.Background(), plane, strings.NewReader(""), &out, &bytes.Buffer{},
94		writeManifest(t, "x"), false, true, true))
95	var got struct {
96		Apply  dto.AppApplyResponse  `json:"apply"`
97		Deploy dto.AppDeployResponse `json:"deploy"`
98	}
99	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
100	assert.Equal(t, "apply-1", got.Apply.Intent)
101	assert.Equal(t, "op-1", got.Deploy.Op)
102}
103
104func TestRunAppsList_JSONParityAndSorting(t *testing.T) {
105	plane := appPlane(t)
106	plane.EXPECT().ListApps(mock.Anything).Return([]dto.AppSummaryDTO{
107		{App: "zeta", Converged: true},
108		{App: "alpha", Stopped: true},
109	}, nil)
110	var out bytes.Buffer
111	require.NoError(t, runAppsList(context.Background(), plane, &out, true))
112	var got []dto.AppSummaryDTO
113	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
114	require.Len(t, got, 2)
115
116	out.Reset()
117	require.NoError(t, runAppsList(context.Background(), plane, &out, false))
118	text := out.String()
119	assert.Less(t, strings.Index(text, "alpha"), strings.Index(text, "zeta"), "text lists apps sorted")
120	assert.Contains(t, text, "stopped")
121}
122
123func TestRunAppsList_EmptyJSONIsArray(t *testing.T) {
124	plane := appPlane(t)
125	plane.EXPECT().ListApps(mock.Anything).Return(nil, nil).Once()
126	var out bytes.Buffer
127	require.NoError(t, runAppsList(context.Background(), plane, &out, true))
128	assert.JSONEq(t, `[]`, strings.TrimSpace(out.String()))
129}
130
131func TestRenderActiveServices_SingleWriterLegend(t *testing.T) {
132	for _, tc := range []struct {
133		name    string
134		flagged []string
135	}{
136		{"none", nil},
137		{"one", []string{"db"}},
138		{"two", []string{"db", "web"}},
139	} {
140		t.Run(tc.name, func(t *testing.T) {
141			active := dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{
142				"db": {EffectiveRevision: "rev-1"}, "web": {EffectiveRevision: "rev-1"}, "worker": {EffectiveRevision: "rev-1"},
143			}}
144			for _, name := range tc.flagged {
145				svc := active.Services[name]
146				svc.RestartUnsafe = true
147				active.Services[name] = svc
148			}
149			text := strings.Join(renderActiveServices(active), "\n")
150			assert.Equal(t, len(tc.flagged), strings.Count(text, "rev-1 single-writer"))
151			legends := 0
152			if len(tc.flagged) > 0 {
153				legends = 1
154			}
155			assert.Equal(t, legends, strings.Count(text, singleWriterLegend))
156		})
157	}
158}
159
160func TestRunAppsShow_JSONParity(t *testing.T) {
161	want := &dto.AppShowResponse{
162		App:     "blog",
163		Desired: dto.AppDesiredDTO{Revision: "rev-b", Status: "pending"},
164		Active: dto.AppActiveDTO{Converged: false, Services: map[string]dto.AppActiveServiceDTO{
165			"web": {EffectiveRevision: "rev-a", Container: "ctr-a", RestartUnsafe: true},
166		}},
167		Intent: dto.AppIntentDTO{Stopped: true},
168		LastOp: &dto.AppLastOpDTO{Op: "op-9", Outcome: "failed"},
169	}
170	plane := appPlane(t)
171	plane.EXPECT().ShowApp(mock.Anything, "blog").Return(want, nil)
172	var out bytes.Buffer
173	require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, true))
174	var got dto.AppShowResponse
175	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
176	assert.Equal(t, *want, got)
177
178	out.Reset()
179	require.NoError(t, runAppsShow(context.Background(), plane, "blog", &out, false))
180	assert.Contains(t, out.String(), "single-writer: has volumes or binds", "text explains the single-writer mark")
181	assert.NotContains(t, out.String(), "restart_unsafe", "text does not print the bare JSON token")
182}
183
184func TestRunAppsSecretsSet_NeverEchoesValues(t *testing.T) {
185	plane := appPlane(t)
186	var gotApp string
187	var gotReq dto.AppSecretSetRequest
188	plane.EXPECT().SetAppSecrets(mock.Anything, mock.Anything, mock.Anything).
189		Run(func(_ context.Context, app string, req dto.AppSecretSetRequest) {
190			gotApp, gotReq = app, req
191		}).Return(nil).Once()
192	var out bytes.Buffer
193	require.NoError(t, runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
194		&out, "blog", []string{"password=s3cr3t-hunter2", "token=abc"}, "web", false, false))
195	text := out.String()
196	assert.NotContains(t, text, "s3cr3t-hunter2")
197	assert.NotContains(t, text, "abc")
198	assert.Contains(t, text, "password")
199	assert.Contains(t, text, "token")
200	assert.Equal(t, "blog", gotApp)
201	assert.Equal(t, "web", gotReq.Service)
202	assert.Equal(t, map[string]string{"password": "s3cr3t-hunter2", "token": "abc"}, gotReq.Secrets)
203}
204
205func TestRunAppsSecretsSet_RequiresService(t *testing.T) {
206	plane := appPlane(t)
207	err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
208		&bytes.Buffer{}, "blog", []string{"k=v"}, "", false, false)
209	require.Error(t, err)
210	assert.Contains(t, err.Error(), "--service")
211}
212
213func TestRunAppsSecretsSet_StdinAndValidation(t *testing.T) {
214	plane := appPlane(t)
215	var gotReq dto.AppSecretSetRequest
216	plane.EXPECT().SetAppSecrets(mock.Anything, "blog", mock.Anything).
217		Run(func(_ context.Context, _ string, req dto.AppSecretSetRequest) { gotReq = req }).Return(nil).Once()
218	var out bytes.Buffer
219	stdin := strings.NewReader("from_stdin=stdin-value\n\nspaced=  preserved  \n")
220	require.NoError(t, runAppsSecretsSet(context.Background(), plane, stdin,
221		&out, "blog", []string{"from_flag=flag-value"}, "web", true, false))
222	assert.Equal(t, map[string]string{
223		"from_stdin": "stdin-value",
224		"spaced":     "  preserved  ",
225		"from_flag":  "flag-value",
226	}, gotReq.Secrets)
227
228	err := runAppsSecretsSet(context.Background(), plane, strings.NewReader(""),
229		&bytes.Buffer{}, "blog", []string{"no-equals-here"}, "web", false, false)
230	require.Error(t, err)
231	assert.Contains(t, err.Error(), "KEY=VALUE")
232}
233
234func TestRunAppDeploy_OutcomeUnknownMentionsKey(t *testing.T) {
235	plane := appPlane(t)
236	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(
237		nil, "key-abc", &remote.OutcomeUnknownError{Method: "POST", Path: "/apps/blog/deploy", Err: errors.New("boom")}).Once()
238	err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &bytes.Buffer{}, &bytes.Buffer{}, false)
239	require.Error(t, err)
240	assert.Contains(t, err.Error(), "outcome-unknown")
241	assert.Contains(t, err.Error(), "key-abc")
242	assert.Contains(t, err.Error(), "by-key")
243}
244
245func TestRunAppDeploy_ConflictRendersJournal(t *testing.T) {
246	plane := appPlane(t)
247	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{
248		StatusCode: http.StatusConflict, Status: "409 Conflict",
249		Response: dto.AppDeployResponse{
250			Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
251			Services: map[string]dto.AppServiceResultDTO{
252				"web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
253			},
254		},
255	}).Once()
256	var out bytes.Buffer
257	err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, false)
258	require.Error(t, err, "conflict must retain failure semantics")
259	text := out.String()
260	assert.Contains(t, text, "web:")
261	assert.Contains(t, text, "nope")
262	assert.Contains(t, err.Error(), "failed")
263	assert.Contains(t, err.Error(), "op-9")
264	assert.Contains(t, err.Error(), "key-9")
265	assert.Contains(t, err.Error(), "by-key")
266}
267
268func TestRunAppDeploy_ConflictJSONRendersJournal(t *testing.T) {
269	plane := appPlane(t)
270	plane.EXPECT().DeployApp(mock.Anything, "blog", mock.Anything).Return(nil, "key-9", &remote.AppOpConflictError{
271		StatusCode: http.StatusConflict, Status: "409 Conflict",
272		Response: dto.AppDeployResponse{
273			Op: "op-9", App: "blog", Revision: "rev-b", Outcome: "failed",
274			Services: map[string]dto.AppServiceResultDTO{
275				"web": {Result: "failed", EffectiveRevision: "rev-b", Error: "nope"},
276			},
277		},
278	}).Once()
279	var out bytes.Buffer
280	err := runAppDeploy(context.Background(), plane, "blog", dto.AppDeployRequest{}, &out, &bytes.Buffer{}, true)
281	require.Error(t, err)
282	var got dto.AppDeployResponse
283	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
284	assert.Equal(t, "op-9", got.Op)
285	assert.Equal(t, "failed", got.Services["web"].Result)
286}
287
288func TestRunAppLifecycle_ConflictRendersJournal(t *testing.T) {
289	plane := appPlane(t)
290	plane.EXPECT().StopApp(mock.Anything, "blog").Return(nil, "key-7", &remote.AppOpConflictError{
291		StatusCode: http.StatusConflict, Status: "409 Conflict",
292		Response: dto.AppDeployResponse{
293			Op: "op-7", App: "blog", Outcome: "partial",
294			Services: map[string]dto.AppServiceResultDTO{
295				"web": {Result: "failed", EffectiveRevision: "rev-b", Error: "busy"},
296			},
297		},
298	}).Once()
299	var out bytes.Buffer
300	err := runAppLifecycle(context.Background(), plane.StopApp, "stop", "blog", &out, false)
301	require.Error(t, err)
302	assert.Contains(t, out.String(), "busy")
303	assert.Contains(t, err.Error(), "key-7")
304	assert.Contains(t, err.Error(), "by-key")
305}
306
307func TestRenderAppDeployResponse_SortedServices(t *testing.T) {
308	resp := &dto.AppDeployResponse{
309		Op: "op-1", App: "blog", Outcome: "partial",
310		Services: map[string]dto.AppServiceResultDTO{
311			"web":    {Result: "deployed", EffectiveRevision: "rev-b"},
312			"db":     {Result: "failed", EffectiveRevision: "rev-a", Error: "nope", RestartUnsafe: true},
313			"runner": {Result: domain.AppServiceRemoved},
314		},
315		CleanupWarnings: []dto.AppCleanupWarningDTO{{Service: "web", Leftover: "ctr-old", Detail: "retire failed"}},
316		Effective:       &dto.AppEffectiveDTO{Services: map[string]string{"web": "rev-b", "db": "rev-a"}},
317		Retained:        &dto.AppRetainedDTO{Volumes: []string{"blog-db"}},
318	}
319	var out bytes.Buffer
320	require.NoError(t, renderAppDeployResponse(&out, resp))
321	text := out.String()
322	assert.Less(t, strings.Index(text, "db:"), strings.Index(text, "web:"))
323	assert.Contains(t, text, "runner: removed\n")
324	assert.Contains(t, text, "cleanup:")
325	assert.Contains(t, text, "blog-db")
326}
327
328func TestAppLogRef_Selection(t *testing.T) {
329	multi := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
330		"web": {Container: "ctr-web"},
331		"db":  {Container: "ctr-db"},
332	}}}
333	_, err := appLogRef(multi, "")
334	require.Error(t, err)
335	assert.Contains(t, err.Error(), "--service")
336
337	ref, err := appLogRef(multi, "db")
338	require.NoError(t, err)
339	assert.Equal(t, "blog/db", ref)
340
341	_, err = appLogRef(multi, "missing")
342	require.Error(t, err)
343
344	single := &dto.AppShowResponse{App: "solo", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
345		"web": {Container: "ctr-solo"},
346	}}}
347	ref, err = appLogRef(single, "")
348	require.NoError(t, err)
349	assert.Equal(t, "solo/web", ref)
350
351	empty := &dto.AppShowResponse{App: "new", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
352		"web": {},
353	}}}
354	_, err = appLogRef(empty, "web")
355	require.Error(t, err)
356	assert.Contains(t, err.Error(), "no recorded container")
357}
358
359func TestResolveAppPlane_FailsWithoutDaemon(t *testing.T) {
360	t.Setenv("XDG_CONFIG_HOME", t.TempDir())
361	t.Setenv("GORDON_REMOTE", "")
362	t.Setenv("GORDON_TOKEN", "")
363	origRemote, origToken, origInsecure := remoteFlag, tokenFlag, insecureTLSFlag
364	t.Cleanup(func() { remoteFlag, tokenFlag, insecureTLSFlag = origRemote, origToken, origInsecure })
365	remoteFlag, tokenFlag, insecureTLSFlag = "", "", false
366
367	restore := newLocalAppClient
368	newLocalAppClient = func() (*remote.Client, error) { return nil, remote.ErrDaemonUnavailable }
369	t.Cleanup(func() { newLocalAppClient = restore })
370
371	_, err := resolveAppPlane()
372	require.Error(t, err)
373	assert.Contains(t, err.Error(), "daemon-unavailable")
374}
375
376// TestAppCommandSurface pins the frozen CLI surface (05-api-cli.md §5):
377// every command exists with its frozen Use string and flags. The commands
378// stay unregistered until cutover; this test is what keeps them referenced.
379func TestAppCommandSurface(t *testing.T) {
380	root := newAppsCmd()
381	require.Equal(t, "apps", root.Use)
382	subs := map[string]bool{}
383	for _, sub := range root.Commands() {
384		subs[sub.Name()] = true
385	}
386	for _, name := range []string{"apply", "list", "show", "diff", "secrets", "deploy", "restart", "stop", "start", "remove", "status", "logs"} {
387		assert.True(t, subs[name], "apps subcommand %s", name)
388	}
389
390	secrets := newAppsSecretsCmd()
391	secretSubs := map[string]bool{}
392	for _, sub := range secrets.Commands() {
393		secretSubs[sub.Name()] = true
394	}
395	assert.True(t, secretSubs["set"])
396	assert.True(t, secretSubs["delete"])
397
398	flagged := map[*cobra.Command][]string{
399		newAppsApplyCmd():         {"file", "dry-run", "deploy", "json"},
400		newAppsListCmd():          {"json"},
401		newAppsShowCmd():          {"json"},
402		newAppsDiffCmd():          {"json"},
403		newAppsSecretsSetCmd():    {"service", "stdin", "json"},
404		newAppsSecretsDeleteCmd(): {"service", "json"},
405		newAppDeployCmd():         {"revision", "service", "json"},
406		newAppRestartCmd():        {"service", "json"},
407		newAppStopCmd():           {"json"},
408		newAppStartCmd():          {"json"},
409		newAppRemoveCmd():         {"json"},
410		newAppStatusCmd():         {"json"},
411		newAppLogsCmd():           {"service", "follow", "tail", "json"},
412	}
413	for cmd, flags := range flagged {
414		for _, flag := range flags {
415			assert.NotNil(t, cmd.Flags().Lookup(flag), "%s must define --%s", cmd.Use, flag)
416		}
417	}
418
419	assert.Equal(t, "deploy APP", newAppDeployCmd().Use)
420	assert.Equal(t, "restart APP", newAppRestartCmd().Use)
421	assert.Equal(t, "stop APP", newAppStopCmd().Use)
422	assert.Equal(t, "start APP", newAppStartCmd().Use)
423	assert.Equal(t, "remove APP", newAppRemoveCmd().Use)
424	assert.Equal(t, "status APP", newAppStatusCmd().Use)
425	assert.Equal(t, "logs APP", newAppLogsCmd().Use)
426}
427
428func TestRunAppsDiff_TextAndJSON(t *testing.T) {
429	diff := &dto.AppDiffResponse{
430		App:  "blog",
431		Diff: dto.AppDiffSection{Added: []string{"b"}, Removed: []string{"a"}, Changed: []string{"c"}},
432	}
433	plane := appPlane(t)
434	plane.EXPECT().DiffApp(mock.Anything, "blog").Return(diff, nil).Times(2)
435	var out bytes.Buffer
436	require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false))
437	text := out.String()
438	assert.Contains(t, text, "added:")
439	assert.Contains(t, text, "removed:")
440	assert.Contains(t, text, "changed:")
441
442	out.Reset()
443	require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, true))
444	var got dto.AppDiffResponse
445	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
446	assert.Equal(t, *diff, got)
447
448	plane.EXPECT().DiffApp(mock.Anything, "blog").Return(&dto.AppDiffResponse{App: "blog"}, nil).Once()
449	out.Reset()
450	require.NoError(t, runAppsDiff(context.Background(), plane, "blog", &out, false))
451	assert.Contains(t, out.String(), "No differences")
452}
453
454func TestRunAppsSecretsDelete(t *testing.T) {
455	plane := appPlane(t)
456	plane.EXPECT().DeleteAppSecret(mock.Anything, "blog", dto.AppSecretDeleteRequest{Service: "web", Key: "password"}).Return(nil).Times(2)
457	var out bytes.Buffer
458	require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", false))
459	assert.Contains(t, out.String(), "password")
460
461	out.Reset()
462	require.NoError(t, runAppsSecretsDelete(context.Background(), plane, &out, "blog", "password", "web", true))
463	var got map[string]string
464	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
465	assert.Equal(t, "password", got["deleted"])
466
467	err := runAppsSecretsDelete(context.Background(), plane, &bytes.Buffer{}, "blog", "password", "", false)
468	require.Error(t, err)
469	assert.Contains(t, err.Error(), "--service")
470}
471
472func TestRunAppRestart_AndLifecycle(t *testing.T) {
473	resp := &dto.AppDeployResponse{Op: "op-2", App: "blog", Revision: "rev-b", Outcome: "success"}
474	plane := appPlane(t)
475	plane.EXPECT().RestartApp(mock.Anything, "blog", "", false).Return(resp, "key-lc", nil).Times(2)
476	plane.EXPECT().StopApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
477	plane.EXPECT().StartApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
478	plane.EXPECT().RemoveApp(mock.Anything, "blog").Return(resp, "key-lc", nil).Once()
479	var out bytes.Buffer
480	require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, false))
481	assert.Contains(t, out.String(), "success")
482
483	out.Reset()
484	require.NoError(t, runAppRestart(context.Background(), plane, "blog", "", false, &out, true))
485	var got dto.AppDeployResponse
486	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
487	assert.Equal(t, *resp, got)
488
489	for _, fn := range []appLifecycleFunc{plane.StopApp, plane.StartApp, plane.RemoveApp} {
490		out.Reset()
491		require.NoError(t, runAppLifecycle(context.Background(), fn, "stop", "blog", &out, false))
492		assert.Contains(t, out.String(), "op-2")
493	}
494
495	unknown := appPlane(t)
496	unknown.EXPECT().StopApp(mock.Anything, "blog").Return(
497		nil, "key-x", &remote.OutcomeUnknownError{Method: "POST", Path: "/x", Err: errors.New("boom")}).Once()
498	err := runAppLifecycle(context.Background(), unknown.StopApp, "stop", "blog", &bytes.Buffer{}, false)
499	require.Error(t, err)
500	assert.Contains(t, err.Error(), "key-x")
501}
502
503func TestRunAppStatus_Observed(t *testing.T) {
504	show := &dto.AppShowResponse{
505		App: "blog",
506		Active: dto.AppActiveDTO{Converged: true, Services: map[string]dto.AppActiveServiceDTO{
507			"web": {EffectiveRevision: "rev-b", Container: "ctr-b"},
508		}},
509	}
510	plane := appPlane(t)
511	plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(2)
512	var out bytes.Buffer
513	require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, false))
514	assert.Contains(t, out.String(), "ctr-b")
515
516	out.Reset()
517	require.NoError(t, runAppStatus(context.Background(), plane, "blog", &out, true))
518	var got dto.AppShowResponse
519	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
520	assert.Equal(t, *show, got)
521}
522
523// fakeAppLogReader scripts container log reads for app log tests.
524type fakeAppLogReader struct {
525	lines  []string
526	stream []string
527}
528
529func (f *fakeAppLogReader) GetContainerLogs(_ context.Context, _ string, _ int) ([]string, error) {
530	return f.lines, nil
531}
532
533func (f *fakeAppLogReader) StreamContainerLogs(_ context.Context, _ string, _ int) (<-chan string, error) {
534	ch := make(chan string, len(f.stream))
535	for _, line := range f.stream {
536		ch <- line
537	}
538	close(ch)
539	return ch, nil
540}
541
542func TestRunAppLogs_TextJSONFollow(t *testing.T) {
543	show := &dto.AppShowResponse{App: "blog", Active: dto.AppActiveDTO{Services: map[string]dto.AppActiveServiceDTO{
544		"web": {Container: "ctr-web"},
545	}}}
546	plane := appPlane(t)
547	plane.EXPECT().ShowApp(mock.Anything, "blog").Return(show, nil).Times(3)
548	reader := &fakeAppLogReader{lines: []string{"l1", "l2"}, stream: []string{"s1"}}
549
550	var out bytes.Buffer
551	require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "", false, 50, &out, false))
552	assert.Equal(t, "l1\nl2\n", out.String())
553
554	out.Reset()
555	require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", false, 50, &out, true))
556	var got map[string]any
557	require.NoError(t, json.Unmarshal(out.Bytes(), &got))
558	assert.Equal(t, "blog", got["app"])
559
560	out.Reset()
561	require.NoError(t, runAppLogs(context.Background(), plane, reader, "blog", "web", true, 50, &out, false))
562	assert.Equal(t, "s1\n", out.String())
563}
564
565func TestRunAppsSecretsList_RendersMetadataAndWrapsErrors(t *testing.T) {
566	plane := appPlane(t)
567	plane.EXPECT().ListAppSecrets(mock.Anything, "blog", "web").Return([]dto.AppSecretMetadataDTO{
568		{Service: "web", Key: "DATABASE_URL", Name: "database-url", Source: "desired", Presence: "unknown"},
569	}, nil).Once()
570	var out bytes.Buffer
571	require.NoError(t, runAppsSecretsList(context.Background(), plane, &out, "blog", "web", false))
572	assert.Contains(t, out.String(), "web/DATABASE_URL")
573	assert.Contains(t, out.String(), "desired")
574
575	errPlane := appPlane(t)
576	errPlane.EXPECT().ListAppSecrets(mock.Anything, "blog", "").Return(nil, errors.New("boom")).Once()
577	err := runAppsSecretsList(context.Background(), errPlane, &bytes.Buffer{}, "blog", "", false)
578	require.Error(t, err)
579	assert.Contains(t, err.Error(), "list app secrets for blog")
580	assert.Contains(t, err.Error(), "boom")
581}

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