v2.31.0-dev23 58a92f3

service_test.go RawHistoryFolder

1package services
2
3import (
4	"context"
5	"errors"
6	"io"
7	"net"
8	"os"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16
17	"github.com/bnema/gordon/internal/adapters/out/secrets"
18	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
19	"github.com/bnema/gordon/internal/domain"
20)
21
22func TestService_ReconcileCreatesMissingEnabledService(t *testing.T) {
23	rt := outmocks.NewMockContainerRuntime(t)
24	svc := sampleService()
25	var created *domain.ContainerConfig
26	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
27	rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{"/data"}, nil).Once()
28	rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
29		created = args.Get(1).(*domain.ContainerConfig)
30	}).Return(&domain.Container{ID: "created-1"}, nil).Once()
31	rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
32
33	err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
34
35	require.NoError(t, err)
36	require.NotNil(t, created)
37	assert.Equal(t, "game:latest", created.Image)
38	assert.Equal(t, "gordon-service-game", created.Name)
39	assert.Equal(t, []string{"PUBLIC=value"}, created.Env)
40	assert.Equal(t, domain.RestartPolicyAlways, created.RestartPolicy)
41	assert.Equal(t, "true", created.Labels[domain.LabelManaged])
42	assert.Equal(t, "true", created.Labels[domain.LabelService])
43	assert.Equal(t, "game", created.Labels[domain.LabelServiceName])
44	assert.Equal(t, "true", created.Labels[domain.LabelServiceCleanupPreserveVolumes])
45	assert.Equal(t, "true", created.Labels[domain.LabelServiceCleanupRemoveContainer])
46	assert.NotEmpty(t, created.Labels[domain.LabelServiceConfigHash])
47	assert.Equal(t, "gordon-service-game-data", created.Labels[domain.LabelServiceManagedVolumes])
48	assert.Equal(t, []domain.ContainerPortPublish{{HostIP: "127.0.0.1", HostPort: 38015, ContainerPort: 28015, Protocol: domain.NetworkProtocolUDP}}, created.PortPublishes)
49	assert.Equal(t, map[string]string{"/data": "gordon-service-game-data"}, created.Volumes)
50}
51
52func TestService_ReconcileStartsStoppedExistingService(t *testing.T) {
53	rt := outmocks.NewMockContainerRuntime(t)
54	svc := sampleService()
55	hash, err := serviceConfigHash(svc)
56	require.NoError(t, err)
57	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("existing-1", svc.Name, hash, "exited")}, nil).Once()
58	rt.On("StartContainer", mock.Anything, "existing-1").Return(nil).Once()
59
60	err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
61
62	require.NoError(t, err)
63}
64
65func TestService_ReconcileChecksReadinessForExistingMatchingService(t *testing.T) {
66	rt := outmocks.NewMockContainerRuntime(t)
67	svc := sampleService()
68	svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: time.Second}
69	hash, err := serviceConfigHash(svc)
70	require.NoError(t, err)
71	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("existing-1", svc.Name, hash, "running")}, nil).Once()
72	rt.On("CopyFromContainer", mock.Anything, "existing-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("ready")), nil).Once()
73
74	err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
75
76	require.NoError(t, err)
77}
78
79func TestService_ReconcileRecreatesStaleService(t *testing.T) {
80	rt := outmocks.NewMockContainerRuntime(t)
81	svc := sampleService()
82	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("old-1", svc.Name, "old-hash", "running")}, nil).Once()
83	rt.On("StopContainer", mock.Anything, "old-1").Return(nil).Once()
84	rt.On("RemoveContainer", mock.Anything, "old-1", true).Return(nil).Once()
85	rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once()
86	rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ID: "new-1"}, nil).Once()
87	rt.On("StartContainer", mock.Anything, "new-1").Return(nil).Once()
88
89	err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
90
91	require.NoError(t, err)
92}
93
94func TestService_ReconcileRecreateDoesNotRemoveManagedVolumes(t *testing.T) {
95	rt := outmocks.NewMockContainerRuntime(t)
96	svc := sampleService()
97	svc.Cleanup = domain.StandaloneServiceCleanup{PreserveVolumes: false, RemoveContainer: true}
98	old := managedContainer("old-1", svc.Name, "old-hash", "running")
99	old.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-game-data"
100	old.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-game-data", Type: "volume", Destination: "/data"}}
101	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{old}, nil).Once()
102	rt.On("StopContainer", mock.Anything, "old-1").Return(nil).Once()
103	rt.On("RemoveContainer", mock.Anything, "old-1", true).Return(nil).Once()
104	rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once()
105	rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Return(&domain.Container{ID: "new-1"}, nil).Once()
106	rt.On("StartContainer", mock.Anything, "new-1").Return(nil).Once()
107
108	err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
109
110	require.NoError(t, err)
111}
112
113func TestService_ReconcileRemovesDuplicateMatchingServiceContainers(t *testing.T) {
114	rt := outmocks.NewMockContainerRuntime(t)
115	svc := sampleService()
116	hash, err := serviceConfigHash(svc)
117	require.NoError(t, err)
118	older := managedContainer("existing-1", svc.Name, hash, "running")
119	duplicate := managedContainer("existing-2", svc.Name, hash, "running")
120	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{duplicate, older}, nil).Once()
121	rt.On("StopContainer", mock.Anything, "existing-2").Return(nil).Once()
122	rt.On("RemoveContainer", mock.Anything, "existing-2", true).Return(nil).Once()
123
124	err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
125
126	require.NoError(t, err)
127}
128
129func TestService_ReconcileKeepsRunningDuplicateBeforeLowerStaleID(t *testing.T) {
130	rt := outmocks.NewMockContainerRuntime(t)
131	svc := sampleService()
132	hash, err := serviceConfigHash(svc)
133	require.NoError(t, err)
134	staleLowerID := managedContainer("existing-1", svc.Name, hash, "exited")
135	runningPrimary := managedContainer("existing-2", svc.Name, hash, "running")
136	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{staleLowerID, runningPrimary}, nil).Once()
137	rt.On("RemoveContainer", mock.Anything, "existing-1", true).Return(nil).Once()
138
139	err = NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
140
141	require.NoError(t, err)
142}
143
144func TestService_ReconcileStopsAndRemovesDisabledService(t *testing.T) {
145	rt := outmocks.NewMockContainerRuntime(t)
146	svc := sampleService()
147	svc.Enabled = false
148	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{managedContainer("disabled-1", svc.Name, "old-hash", "running")}, nil).Once()
149	rt.On("StopContainer", mock.Anything, "disabled-1").Return(nil).Once()
150	rt.On("RemoveContainer", mock.Anything, "disabled-1", true).Return(nil).Once()
151
152	err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
153
154	require.NoError(t, err)
155}
156
157func TestService_ReconcileStopsAndRemovesOmittedManagedService(t *testing.T) {
158	rt := outmocks.NewMockContainerRuntime(t)
159	container := managedContainer("removed-1", "removed", "old-hash", "running")
160	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
161	rt.On("StopContainer", mock.Anything, "removed-1").Return(nil).Once()
162	rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
163
164	err := NewService(rt).Reconcile(context.Background(), nil)
165
166	require.NoError(t, err)
167}
168
169func TestService_ReconcileRemovesNamedVolumeMountsWhenCleanupDoesNotPreserveVolumes(t *testing.T) {
170	rt := outmocks.NewMockContainerRuntime(t)
171	svc := sampleService()
172	svc.Enabled = false
173	svc.Cleanup = domain.StandaloneServiceCleanup{PreserveVolumes: false, RemoveContainer: true}
174	container := managedContainer("disabled-1", svc.Name, "old-hash", "running")
175	container.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-game-data"
176	container.VolumeMounts = []domain.ContainerVolumeMount{
177		{Name: "gordon-service-game-data", Type: "volume", Destination: "/data"},
178		{Name: "user-shared-data", Type: "volume", Destination: "/shared"},
179		{Name: "", Type: "volume", Destination: "/anonymous"},
180		{Name: "host-data", Type: "bind", Destination: "/host"},
181	}
182	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
183	rt.On("StopContainer", mock.Anything, "disabled-1").Return(nil).Once()
184	rt.On("RemoveContainer", mock.Anything, "disabled-1", true).Return(nil).Once()
185	rt.On("RemoveVolume", mock.Anything, "gordon-service-game-data", true).Return(nil).Once()
186
187	err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
188
189	require.NoError(t, err)
190}
191
192func TestService_ReconcileUsesCleanupLabelsForOmittedService(t *testing.T) {
193	rt := outmocks.NewMockContainerRuntime(t)
194	container := managedContainer("removed-1", "removed", "old-hash", "exited")
195	container.Labels[domain.LabelServiceCleanupPreserveVolumes] = "false"
196	container.Labels[domain.LabelServiceCleanupRemoveContainer] = "true"
197	container.Labels[domain.LabelServiceManagedVolumes] = "gordon-service-removed-data"
198	container.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-removed-data", Type: "volume", Destination: "/data"}}
199	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
200	rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
201	rt.On("RemoveVolume", mock.Anything, "gordon-service-removed-data", true).Return(nil).Once()
202
203	err := NewService(rt).Reconcile(context.Background(), nil)
204
205	require.NoError(t, err)
206}
207
208func TestService_ReconcileDefaultsOmittedServiceCleanupToPreserveVolumes(t *testing.T) {
209	rt := outmocks.NewMockContainerRuntime(t)
210	container := managedContainer("removed-1", "removed", "old-hash", "exited")
211	container.VolumeMounts = []domain.ContainerVolumeMount{{Name: "gordon-service-removed-data", Type: "volume", Destination: "/data"}}
212	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{container}, nil).Once()
213	rt.On("RemoveContainer", mock.Anything, "removed-1", true).Return(nil).Once()
214
215	err := NewService(rt).Reconcile(context.Background(), nil)
216
217	require.NoError(t, err)
218}
219
220func TestService_ReconcileUsesExplicitReadOnlyVolumes(t *testing.T) {
221	rt := outmocks.NewMockContainerRuntime(t)
222	svc := sampleService()
223	svc.Volumes = []domain.StandaloneServiceVolume{{Source: "cfg", Target: "/cfg", ReadOnly: true}}
224	var created *domain.ContainerConfig
225	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
226	rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
227		created = args.Get(1).(*domain.ContainerConfig)
228	}).Return(&domain.Container{ID: "created-1"}, nil).Once()
229	rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
230
231	err := NewService(rt).Reconcile(context.Background(), []domain.StandaloneService{svc})
232
233	require.NoError(t, err)
234	assert.Nil(t, created.Volumes)
235	assert.Equal(t, map[string]string{"/cfg": "cfg"}, created.ReadOnlyVolumes)
236}
237
238func TestService_StatusReturnsManagedServices(t *testing.T) {
239	rt := outmocks.NewMockContainerRuntime(t)
240	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{
241		managedContainer("id-1", "alpha", "hash-1", "running"),
242		{ID: "other", Labels: map[string]string{}},
243	}, nil).Once()
244
245	statuses, err := NewService(rt).Status(context.Background())
246
247	require.NoError(t, err)
248	require.Len(t, statuses, 1)
249	assert.Equal(t, "alpha", statuses[0].Name)
250	assert.Equal(t, "id-1", statuses[0].ContainerID)
251	assert.Equal(t, domain.ContainerStatusRunning, statuses[0].Status)
252	assert.Equal(t, "hash-1", statuses[0].ConfigHash)
253}
254
255func TestServiceEnvLoadsEnvFileAndMergesInlineEnv(t *testing.T) {
256	envFile := writeTempEnvFile(t, "FROM_FILE=file\nOVERRIDE=file\n# ignored\n")
257	svc := sampleService()
258	svc.EnvFile = envFile
259	svc.Env = []string{"OVERRIDE=inline", "INLINE=value"}
260
261	env, err := NewService(nil).serviceEnv(context.Background(), svc)
262
263	require.NoError(t, err)
264	assert.Equal(t, []string{"FROM_FILE=file", "INLINE=value", "OVERRIDE=inline"}, env)
265}
266
267func TestServiceConfigHashIncludesResolvedEnvFileAndSecretValues(t *testing.T) {
268	envFile := writeTempEnvFile(t, "FROM_FILE=one\n")
269	provider := outmocks.NewMockSecretProvider(t)
270	svc := sampleService()
271	svc.EnvFile = envFile
272	svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}
273	provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-one", nil).Once()
274	first, err := NewServiceWithSecretProvider(nil, provider).serviceConfigHash(context.Background(), svc)
275	require.NoError(t, err)
276
277	require.NoError(t, os.WriteFile(envFile, []byte("FROM_FILE=two\n"), 0o600))
278	provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-two", nil).Once()
279	second, err := NewServiceWithSecretProvider(nil, provider).serviceConfigHash(context.Background(), svc)
280	require.NoError(t, err)
281
282	assert.NotEqual(t, first, second)
283}
284
285func TestService_ReconcileResolvesSecretEnvOnceForHashAndContainer(t *testing.T) {
286	rt := outmocks.NewMockContainerRuntime(t)
287	provider := outmocks.NewMockSecretProvider(t)
288	svc := sampleService()
289	svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}
290	var created *domain.ContainerConfig
291	provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("secret-value", nil).Once()
292	rt.On("ListContainers", mock.Anything, true).Return([]*domain.Container{}, nil).Once()
293	rt.On("InspectImageVolumes", mock.Anything, svc.Image).Return([]string{}, nil).Once()
294	rt.On("CreateContainer", mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).Run(func(args mock.Arguments) {
295		created = args.Get(1).(*domain.ContainerConfig)
296	}).Return(&domain.Container{ID: "created-1"}, nil).Once()
297	rt.On("StartContainer", mock.Anything, "created-1").Return(nil).Once()
298
299	err := NewServiceWithSecretProvider(rt, provider).Reconcile(context.Background(), []domain.StandaloneService{svc})
300
301	require.NoError(t, err)
302	require.NotNil(t, created)
303	assert.Contains(t, created.Env, "RCON_PASSWORD=secret-value")
304	assert.NotEmpty(t, created.Labels[domain.LabelServiceConfigHash])
305}
306
307func TestServiceEnvResolvesServiceScopedSecretsWithoutLeakingValues(t *testing.T) {
308	rt := outmocks.NewMockContainerRuntime(t)
309	provider := outmocks.NewMockSecretProvider(t)
310	svc := sampleService()
311	svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "rcon", Key: "RCON_PASSWORD"}}
312	provider.On("GetSecret", mock.Anything, "service/game/rcon").Return("super-secret-value", errors.New("provider failed")).Once()
313
314	env, err := NewServiceWithSecretProvider(rt, provider).serviceEnv(context.Background(), svc)
315
316	require.Error(t, err)
317	assert.Nil(t, env)
318	assert.NotContains(t, err.Error(), "super-secret-value")
319}
320
321func TestServiceSecretPathIsPassProviderSafe(t *testing.T) {
322	path := serviceSecretPath("game", "rcon")
323
324	require.NoError(t, secrets.ValidatePath(path))
325	assert.Equal(t, "service/game/rcon", path)
326}
327
328func TestServiceSecretPathPreservesExplicitProviderPath(t *testing.T) {
329	path := serviceSecretPath("game", "secrets/service.yaml:game.rcon")
330
331	assert.Equal(t, "secrets/service.yaml:game.rcon", path)
332}
333
334func TestServiceEnvUsesExplicitProviderSecretPaths(t *testing.T) {
335	provider := outmocks.NewMockSecretProvider(t)
336	svc := sampleService()
337	svc.Secrets = []domain.StandaloneServiceSecretRef{{Name: "secrets/service.yaml:game.rcon", Key: "RCON_PASSWORD"}}
338	provider.On("GetSecret", mock.Anything, "secrets/service.yaml:game.rcon").Return("secret-value", nil).Once()
339
340	env, err := NewServiceWithSecretProvider(nil, provider).serviceEnv(context.Background(), svc)
341
342	require.NoError(t, err)
343	assert.Contains(t, env, "RCON_PASSWORD=secret-value")
344}
345
346func TestTCPReadinessAddressMapsIPv6WildcardToIPv6Loopback(t *testing.T) {
347	svc := sampleService()
348	svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "[::]:38016"}}
349
350	address, err := tcpReadinessAddress(svc)
351
352	require.NoError(t, err)
353	assert.Equal(t, "[::1]:38016", address)
354}
355
356func TestWaitTCPReadinessDialsResolvedLoopbackPublish(t *testing.T) {
357	listener, err := net.Listen("tcp", "127.0.0.1:0")
358	require.NoError(t, err)
359	defer listener.Close()
360	accepted := make(chan struct{})
361	go func() {
362		conn, acceptErr := listener.Accept()
363		if acceptErr == nil {
364			_ = conn.Close()
365			close(accepted)
366		}
367	}()
368	svc := sampleService()
369	svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP, Timeout: time.Second}
370	svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: listener.Addr().String()}}
371
372	err = NewService(nil).waitReadiness(context.Background(), "container-1", svc)
373
374	require.NoError(t, err)
375	<-accepted
376}
377
378func TestWaitLogReadinessReadsContainerFileUntilTextAppears(t *testing.T) {
379	rt := outmocks.NewMockContainerRuntime(t)
380	svc := sampleService()
381	svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: time.Second}
382	rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("starting")), nil).Once()
383	rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(io.NopCloser(strings.NewReader("server ready")), nil).Once()
384
385	err := NewService(rt).waitReadiness(context.Background(), "container-1", svc)
386
387	require.NoError(t, err)
388}
389
390func TestWaitReadinessHonorsContextTimeout(t *testing.T) {
391	svc := sampleService()
392	svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessTCP}
393	svc.Ports = []domain.StandaloneServicePort{{Name: "admin", Container: 28016, Protocol: domain.NetworkProtocolTCP, Publish: "127.0.0.1:1"}}
394	ctx, cancel := context.WithTimeout(context.Background(), time.Nanosecond)
395	defer cancel()
396
397	err := NewService(nil).waitReadiness(ctx, "container-1", svc)
398
399	require.Error(t, err)
400	assert.ErrorIs(t, err, context.DeadlineExceeded)
401}
402
403func TestWaitLogReadinessPreservesLastReadErrorOnTimeout(t *testing.T) {
404	rt := outmocks.NewMockContainerRuntime(t)
405	svc := sampleService()
406	svc.Readiness = domain.StandaloneServiceReadiness{Type: domain.StandaloneServiceReadinessLog, Path: "/logs/server.log", Contains: "ready", Timeout: 25 * time.Millisecond}
407	readErr := errors.New("copy failed")
408	rt.On("CopyFromContainer", mock.Anything, "container-1", "/logs/server.log").Return(nil, readErr)
409
410	err := NewService(rt).waitReadiness(context.Background(), "container-1", svc)
411
412	require.Error(t, err)
413	assert.ErrorIs(t, err, readErr)
414	assert.ErrorIs(t, err, context.DeadlineExceeded)
415}
416
417func TestNormalizeCleanupDefaultsPreserveVolumes(t *testing.T) {
418	cleanup := normalizeCleanup(domain.StandaloneServiceCleanup{})
419
420	assert.True(t, cleanup.PreserveVolumes)
421	assert.True(t, cleanup.RemoveContainer)
422}
423
424func sampleService() domain.StandaloneService {
425	return domain.StandaloneService{
426		Name:    "game",
427		Image:   "game:latest",
428		Enabled: true,
429		Env:     []string{"PUBLIC=value"},
430		Cleanup: domain.StandaloneServiceCleanup{PreserveVolumes: true, RemoveContainer: true},
431		Ports:   []domain.StandaloneServicePort{{Name: "game", Container: 28015, Protocol: domain.NetworkProtocolUDP, Publish: "127.0.0.1:38015"}},
432	}
433}
434
435func managedContainer(id, name, hash, status string) *domain.Container {
436	return &domain.Container{
437		ID:     id,
438		Name:   "gordon-service-" + name,
439		Status: status,
440		Labels: map[string]string{
441			domain.LabelService:           "true",
442			domain.LabelServiceName:       name,
443			domain.LabelServiceConfigHash: hash,
444		},
445	}
446}
447
448func writeTempEnvFile(t *testing.T, content string) string {
449	t.Helper()
450	file, err := os.CreateTemp(t.TempDir(), "service-*.env")
451	require.NoError(t, err)
452	_, err = file.WriteString(content)
453	require.NoError(t, err)
454	require.NoError(t, file.Close())
455	return file.Name()
456}

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 8

Tags 106

↑↓ moveenter openesc close