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}