deploy_mock_test.go RawHistoryFolder

1package deployment_test
2
3import (
4	"context"
5	"errors"
6	"io"
7	"strings"
8	"testing"
9	"time"
10
11	"github.com/bnema/zerowrap"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/mock"
14	"github.com/stretchr/testify/require"
15
16	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
17	"github.com/bnema/gordon/internal/domain"
18	"github.com/bnema/gordon/internal/usecase/deployment"
19)
20
21func mockDeps(t *testing.T) (*outmocks.MockAppState, *outmocks.MockContainerRuntime, *outmocks.MockImageResolver, *outmocks.MockSecretProvider) {
22	t.Helper()
23	state := outmocks.NewMockAppState(t)
24	// Every app mutation reconciles an interrupted predecessor first. These
25	// tests exercise the mutation itself, so they see no interrupted
26	// operation; tests that seed one use their own expectation or a real
27	// store.
28	state.EXPECT().LoadLatestOperation(mock.Anything, mock.Anything).
29		Return(domain.AppOperation{}, false, nil).Maybe()
30	// ExecuteDeploy always reloads the claimed operation from the store by
31	// app/op identity. Mock-backed execution sees the freshly claimed
32	// non-terminal journal shape (a single pending preflight step) unless a
33	// test overrides it with expectStoredOperation.
34	state.EXPECT().LoadOperation(mock.Anything, mock.Anything, mock.Anything).
35		RunAndReturn(func(_ context.Context, app, opID string) (domain.AppOperation, error) {
36			return domain.AppOperation{
37				Kind: "deploy", App: app, Op: opID,
38				Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
39			}, nil
40		}).Maybe()
41	return state,
42		outmocks.NewMockContainerRuntime(t),
43		outmocks.NewMockImageResolver(t),
44		outmocks.NewMockSecretProvider(t)
45}
46
47// expectStoredOperation makes one store record authoritative for the next
48// ExecuteDeploy reload. The operation identity is echoed from the request so
49// an unkeyed claim matches too. It replaces the catch-all default installed
50// by mockDeps, letting a test prove that a terminal or mismatched store
51// record overrides a stale in-process journal.
52func expectStoredOperation(state *outmocks.MockAppState, op domain.AppOperation) {
53	kept := state.ExpectedCalls[:0]
54	for _, call := range state.ExpectedCalls {
55		if call.Method != "LoadOperation" {
56			kept = append(kept, call)
57		}
58	}
59	state.ExpectedCalls = kept
60	state.EXPECT().LoadOperation(mock.Anything, op.App, mock.Anything).
61		RunAndReturn(func(_ context.Context, app, opID string) (domain.AppOperation, error) {
62			op.App = app
63			op.Op = opID
64			return op, nil
65		})
66}
67
68func mockRevision() domain.AppDesiredRevision {
69	svc := webService()
70	svc.Image = "docker.io/example/web:1.4.2"
71	svc.Readiness.Timeout = time.Second
72	return domain.AppDesiredRevision{
73		Revision: "rev-1", App: "blog",
74		Spec: domain.AppSpec{Name: "blog", Env: map[string]string{}, Services: []domain.AppService{svc}},
75	}
76}
77
78// expectDeployPreflight wires recover + revision + digest + secrets +
79// image volumes + checkpoint + ownership + the two journal writes that
80// frame execution (preflight table, then per-service/outcome updates).
81func expectDeployPreflight(
82	state *outmocks.MockAppState,
83	runtime *outmocks.MockContainerRuntime,
84	images *outmocks.MockImageResolver,
85	secrets *outmocks.MockSecretProvider,
86	rev domain.AppDesiredRevision,
87) {
88	state.EXPECT().Recover(mock.Anything).Return(nil)
89	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
90	images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
91	secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
92	runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return(nil, nil)
93	state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
94	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
95	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
96}
97
98// expectNetworkProvision registers the two runtime calls every
99// create/recovery path makes when the incarnation network does not yet
100// exist: inspect existing networks (none), then create the private one.
101func expectNetworkProvision(runtime *outmocks.MockContainerRuntime, appID string, times int) {
102	runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{}, nil).Times(times)
103	runtime.EXPECT().CreateNetwork(mock.Anything, domain.AppPrivateNetworkName("gordon", appID), mock.Anything).Return(nil).Times(times)
104}
105
106func TestDeploy_PreflightFailureReturnsJournaledOperation(t *testing.T) {
107	ctx := context.Background()
108	state, runtime, images, secrets := mockDeps(t)
109	rev := mockRevision()
110
111	state.EXPECT().Recover(mock.Anything).Return(nil).Once()
112	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil).Once()
113	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil).Twice()
114	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil).Once()
115	images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("", assert.AnError).Once()
116	// The store record carries the revision the claim was started for.
117	expectStoredOperation(state, domain.AppOperation{
118		Kind: "deploy", App: "blog", InputRevision: rev.Revision,
119		Steps: []domain.AppOperationStep{{ID: "preflight", State: domain.AppStepPending}},
120	})
121
122	svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets}, zerowrap.Default())
123	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
124
125	require.ErrorIs(t, err, domain.ErrAppImageUnresolvable)
126	require.NotNil(t, result)
127	assert.NotEmpty(t, result.Op)
128	assert.Equal(t, "blog", result.App)
129	assert.Equal(t, rev.Revision, result.Revision)
130	runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
131}
132
133func TestDeploy_Mockery_PullsPinnedImageWithRegistryAuth(t *testing.T) {
134	ctx := context.Background()
135	state, runtime, images, secrets := mockDeps(t)
136	svcSpec := webService()
137	svcSpec.Image = "registry.example.com/blog/web:1.4.2"
138	svcSpec.HTTP = nil
139	svcSpec.Readiness = domain.AppReadiness{}
140	rev := domain.AppDesiredRevision{
141		Revision: "rev-1", App: "blog",
142		Spec: domain.AppSpec{Name: "blog", Env: map[string]string{}, Services: []domain.AppService{svcSpec}},
143	}
144
145	state.EXPECT().Recover(mock.Anything).Return(nil)
146	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
147	images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
148	secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
149	pullRef := "127.0.0.1:5000/blog/web@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
150	runtime.EXPECT().PullImageWithOptions(mock.Anything, domain.ImagePullRequest{Reference: pullRef, Username: "gordon", Password: "s3cret", Transport: domain.ImagePullTransportHTTP}).Return(nil).Once()
151	runtime.EXPECT().VerifyImageDigest(mock.Anything, pullRef, "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil).Once()
152	runtime.EXPECT().InspectImageVolumes(mock.Anything, "127.0.0.1:5000/blog/web@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa").Return(nil, nil)
153	state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
154	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
155	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
156	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
157		App:      "blog",
158		Services: map[string]domain.AppEffectiveService{},
159	}, true, nil)
160	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
161	expectNetworkProvision(runtime, "app-blog", 1)
162
163	// The pinned ref was pulled during preflight before image-volume inspection.
164	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
165		return cfg.Image == "127.0.0.1:5000/blog/web@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" &&
166			assert.Equal(t, svcSpec.Command, cfg.Entrypoint) && assert.Empty(t, cfg.Cmd)
167	})).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
168	runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
169	// No TCP-capable interfaces (HTTP nil, readiness empty): no backend
170	// binds published, no GetContainerBackendBinds call.
171	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
172		App:      "blog",
173		Services: map[string]domain.AppEffectiveService{},
174	}, true, nil)
175	state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil)
176	state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
177
178	svc := deployment.NewService(
179		deployment.Deps{
180			State: state, Runtime: runtime, Images: images, Secrets: secrets,
181			Registry: deployment.RegistryConfig{
182				Domain:      "registry.example.com",
183				PullAddress: "127.0.0.1:5000",
184				Username:    "gordon",
185				Password:    "s3cret",
186			},
187		},
188		zerowrap.Default(),
189	).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
190		func(context.Context, string, string) (int, error) { return 200, nil },
191		func(context.Context, string) error { return nil },
192	))
193
194	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
195	require.NoError(t, err)
196	require.NotNil(t, result)
197	assert.Equal(t, "deployed", result.Services["web"].Result)
198	assert.Empty(t, result.Services["web"].BackendBinds)
199}
200
201// TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds proves the rootless-first
202// readiness path: the engine publishes the HTTP container port on
203// 127.0.0.1 ephemeral, probes the recorded loopback bind (never a
204// container IP), and records the binds for the active record + proxy.
205func TestDeploy_Mockery_HTTPSuccessRecordsLoopbackBinds(t *testing.T) {
206	ctx := context.Background()
207	state, runtime, images, secrets := mockDeps(t)
208	rev := mockRevision()
209
210	expectDeployPreflight(state, runtime, images, secrets, rev)
211	expectNetworkProvision(runtime, "app-blog", 1)
212	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
213		App: "blog", Services: map[string]domain.AppEffectiveService{},
214	}, true, nil)
215	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
216
217	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
218	runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
219	runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
220	state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
221		return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
222	})).Return(nil).Once()
223	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
224		App: "blog", Services: map[string]domain.AppEffectiveService{},
225	}, true, nil)
226	state.EXPECT().SaveActive(mock.Anything, mock.MatchedBy(func(a domain.AppActive) bool {
227		return a.Services["web"].BackendBinds[8080] == 18080
228	})).Return(nil).Once()
229	state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
230
231	var probedURL string
232	svc := deployment.NewService(
233		deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
234		zerowrap.Default(),
235	).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
236		func(_ context.Context, url, _ string) (int, error) { probedURL = url; return 200, nil },
237		func(context.Context, string) error { return nil },
238	))
239
240	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
241	require.NoError(t, err)
242	require.NotNil(t, result)
243	assert.Equal(t, "deployed", result.Services["web"].Result)
244	assert.Equal(t, map[int]int{8080: 18080}, result.Services["web"].BackendBinds)
245	assert.Contains(t, probedURL, "127.0.0.1:18080", "readiness dials the loopback bind, never a container IP")
246	runtime.AssertNotCalled(t, "GetContainerNetworkInfo", mock.Anything, mock.Anything)
247}
248
249// TestDeploy_Mockery_HTTPReplacesOldGenerationBeforePublish proves the
250// sequential ordering: the superseded container is stopped and removed
251// before the replacement is created, and ACTIVE names the replacement only
252// after it exists. A public HTTP service without volumes is replaced exactly
253// like every other service.
254func TestDeploy_Mockery_HTTPReplacesOldGenerationBeforePublish(t *testing.T) {
255	ctx := context.Background()
256	state, runtime, images, secrets := mockDeps(t)
257	rev := mockRevision()
258
259	expectDeployPreflight(state, runtime, images, secrets, rev)
260	expectNetworkProvision(runtime, "app-blog", 1)
261	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
262		App: "blog",
263		Services: map[string]domain.AppEffectiveService{
264			"web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"},
265		},
266	}, true, nil)
267	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
268
269	var replacedAt, createdAt, publishedAt int
270	var step int
271	runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(func(context.Context, string, time.Duration) error {
272		step++
273		replacedAt = step
274		return nil
275	}).Once()
276	runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
277	state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
278	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, *domain.ContainerConfig) (*domain.Container, error) {
279		step++
280		createdAt = step
281		return &domain.Container{ID: "c-new", Name: "web"}, nil
282	}).Once()
283	runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
284	runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
285	state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
286		return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
287	})).Return(nil).Once()
288	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
289		App: "blog",
290		Services: map[string]domain.AppEffectiveService{
291			"web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"},
292		},
293	}, true, nil)
294	state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, domain.AppActive) error {
295		step++
296		publishedAt = step
297		return nil
298	}).Once()
299	state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
300	// The superseded generation stops being recovery-inhibited only
301	// once the replacement is published.
302	state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once()
303
304	svc := deployment.NewService(
305		deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
306		zerowrap.Default(),
307	).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
308		func(context.Context, string, string) (int, error) { return 200, nil },
309		func(context.Context, string) error { return nil },
310	))
311
312	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
313	require.NoError(t, err)
314	require.NotNil(t, result)
315	assert.Equal(t, "deployed", result.Services["web"].Result)
316	assert.Less(t, replacedAt, createdAt, "the superseded generation is gone before the replacement is created")
317	assert.Greater(t, publishedAt, createdAt, "ACTIVE is published after the replacement exists")
318	assert.Empty(t, result.CleanupWarnings)
319}
320
321// TestDeploy_Mockery_HTTPReadinessFailureLeavesNoOldGeneration proves a
322// failed replacement never falls back to the generation it already retired:
323// the superseded container stays gone, the unready candidate is removed, and
324// nothing is published in its place.
325func TestDeploy_Mockery_HTTPReadinessFailureLeavesNoOldGeneration(t *testing.T) {
326	ctx := context.Background()
327	state, runtime, images, secrets := mockDeps(t)
328	rev := mockRevision()
329
330	expectDeployPreflight(state, runtime, images, secrets, rev)
331	expectNetworkProvision(runtime, "app-blog", 1)
332	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
333		App: "blog",
334		Services: map[string]domain.AppEffectiveService{
335			"web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"},
336		},
337	}, true, nil)
338	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
339
340	// The superseded generation is retired before the replacement is created,
341	// then the replacement fails readiness fast.
342	runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
343	runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(nil).Once()
344	state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
345	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
346	runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
347	runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 18080, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
348	state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
349		return len(claims) == 1 && claims[0].Port == 18080 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
350	})).Return(nil).Once()
351	runtime.EXPECT().RemoveContainer(mock.Anything, "c-new", true).Return(nil).Once()
352	// The failed candidate's claims are released: a candidate that never
353	// became effective must not keep a loopback reservation.
354	state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-new").Return(nil).Once()
355	runtime.EXPECT().GetContainerLogs(mock.Anything, "c-new", false).Return(io.NopCloser(strings.NewReader("boom\n")), nil).Once()
356
357	svc := deployment.NewService(
358		deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
359		zerowrap.Default(),
360	).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
361		func(context.Context, string, string) (int, error) { return 500, nil },
362		func(context.Context, string) error { return errors.New("refused") },
363	))
364
365	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
366	require.Error(t, err)
367	require.NotNil(t, result)
368	assert.Equal(t, "failed", result.Services["web"].Result)
369	assert.NotContains(t, result.Services["web"].Error, "logs:", "raw logs must never be embedded in the public error")
370	require.Len(t, result.Services["web"].Diagnostics, 1, "failure diagnostics are kept separately")
371	assert.Equal(t, "boom", result.Services["web"].Diagnostics[0])
372	runtime.AssertNotCalled(t, "StopContainer", mock.Anything, "c-new", mock.Anything)
373	runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)
374	state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
375	state.AssertNotCalled(t, "ClearRecoveryInhibition", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
376}
377
378func TestDeploy_Mockery_VolumeReplacementMarksUnsafe(t *testing.T) {
379	ctx := context.Background()
380	state, runtime, images, secrets := mockDeps(t)
381
382	// The GC barrier must be held across resource acquisition through
383	// the durable ownership publication that protects the volume.
384	barrier := outmocks.NewMockGCBarrier(t)
385	gcLease := outmocks.NewMockGCLease(t)
386	gcLeaseHeld := false
387	ownershipSaves := 0
388	barrier.EXPECT().AcquireShared(mock.Anything).Run(func(context.Context) {
389		gcLeaseHeld = true
390	}).Return(gcLease, nil).Once()
391	gcLease.EXPECT().Release().Run(func() {
392		gcLeaseHeld = false
393	}).Once()
394
395	svcSpec := webService()
396	svcSpec.HTTP = nil
397	svcSpec.TCP = []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "9000"}}
398	svcSpec.Readiness = domain.AppReadiness{Type: "none", Timeout: time.Second}
399	svcSpec.Volumes = []domain.AppVolume{{Name: "d", Path: "/data"}}
400	svcSpec.Secrets = map[string]string{}
401	rev := domain.AppDesiredRevision{
402		Revision: "rev-1", App: "blog",
403		Spec: domain.AppSpec{Name: "blog", Env: map[string]string{}, Services: []domain.AppService{svcSpec}},
404	}
405
406	state.EXPECT().Recover(mock.Anything).Return(nil)
407	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
408	images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
409	runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil)
410	state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
411	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-uuid-blog", Volumes: []domain.AppOwnedVolume{{Name: "d", Service: "web", RuntimeName: "gordon-blog--web--vol--d", State: domain.AppResourceAttached}}}, nil).Times(4)
412	runtime.EXPECT().VolumeExists(mock.Anything, "gordon-blog--web--vol--d").Return(true, nil)
413	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
414	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
415		App: "blog",
416		Services: map[string]domain.AppEffectiveService{
417			"web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old"},
418		},
419	}, true, nil)
420	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
421
422	var order []string
423	runtime.EXPECT().StopContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(func(context.Context, string, time.Duration) error {
424		order = append(order, "stop-old")
425		return nil
426	}).Once()
427	runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).RunAndReturn(func(context.Context, string, bool) error {
428		order = append(order, "remove-old")
429		return nil
430	}).Once()
431	// The superseded writer's confirmed disappearance releases its claims.
432	state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-old").Return(nil).Once()
433	expectNetworkProvision(runtime, "app-uuid-blog", 1)
434	// The volume-owning replacement must be inhibited before it can
435	// write, and released only after the new generation is published.
436	state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.MatchedBy(func(inhibition domain.AppRecoveryInhibition) bool {
437		return inhibition.App == "blog" && inhibition.Service == "web" &&
438			inhibition.ContainerID == "c-old" && inhibition.Reason == domain.AppInhibitReplacementPending
439	})).RunAndReturn(func(context.Context, domain.AppRecoveryInhibition) error {
440		order = append(order, "inhibit")
441		return nil
442	}).Once()
443	state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-old").Return(nil).Once()
444	runtime.EXPECT().CreateVolume(mock.Anything, "gordon-blog--web--vol--d", mock.MatchedBy(func(labels map[string]string) bool {
445		return labels[domain.LabelApp] == "blog" &&
446			labels[domain.LabelAppID] == "app-uuid-blog" &&
447			labels[domain.LabelAppService] == "web" &&
448			labels[domain.LabelAppRevision] == "rev-1"
449	})).Run(func(context.Context, string, map[string]string) {
450		require.True(t, gcLeaseHeld, "volume creation must run inside the shared GC lease")
451		// The durable ownership reservation must already be written:
452		// a crash after this point leaves a protected record, never an
453		// unowned volume that prune could later adopt.
454		require.GreaterOrEqual(t, ownershipSaves, 1,
455			"ownership must be reserved before the runtime volume exists")
456	}).Return(nil).Once()
457	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).RunAndReturn(func(context.Context, *domain.ContainerConfig) (*domain.Container, error) {
458		order = append(order, "create-new")
459		return &domain.Container{ID: "c-new", Name: "web"}, nil
460	}).Once()
461	runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
462	runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
463	state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
464		return len(claims) == 1 && claims[0].Port == 19000 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-new"
465	})).Return(nil).Once()
466	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{}}, true, nil)
467	state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
468	restartUnsafeSaved := false
469	state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil).Run(func(_ context.Context, ownership domain.AppOwnership) {
470		require.True(t, gcLeaseHeld, "ownership publication must run inside the shared GC lease")
471		ownershipSaves++
472		if ownership.Services["web"].RestartUnsafe {
473			restartUnsafeSaved = true
474		}
475	})
476
477	svc := deployment.NewService(
478		deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
479		zerowrap.Default(),
480	).WithProbeDeps(deployment.NewProbeDeps(runtime)).WithGCBarrier(barrier)
481
482	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
483	require.NoError(t, err)
484	require.NotNil(t, result)
485	assert.Equal(t, []string{"inhibit", "stop-old", "remove-old", "create-new"}, order,
486		"the durable inhibition is written before the superseded writer can be stopped, and the writer is gone before the replacement can write")
487	assert.True(t, result.Services["web"].RestartUnsafe)
488	assert.True(t, restartUnsafeSaved, "the terminal ownership write must record the restart-unsafe service")
489	assert.GreaterOrEqual(t, ownershipSaves, 2, "the volume reservation and the terminal ownership write must both be durable")
490	assert.False(t, gcLeaseHeld, "the shared GC lease must be released after publication")
491	runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)
492}
493
494// TestDeploy_Mockery_MixedTCPUDPPublishesBothLoopbacks proves a mixed
495// TCP+UDP service publishes both protocols on 127.0.0.1 ephemeral,
496// resolves them in one grouped inspection (same container port number
497// on both protocols yields two distinct binds), and persists both maps
498// with exact-protocol claims.
499func TestDeploy_Mockery_MixedTCPUDPPublishesBothLoopbacks(t *testing.T) {
500	ctx := context.Background()
501	state, runtime, images, secrets := mockDeps(t)
502	svcSpec := webService()
503	svcSpec.HTTP = nil
504	svcSpec.TCP = []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "9000"}}
505	svcSpec.UDP = []domain.AppUDPInterface{{Entrypoint: "udp", Port: 9000, Publish: "9000"}}
506	svcSpec.Readiness = domain.AppReadiness{Type: "none", Timeout: time.Second}
507	svcSpec.Secrets = map[string]string{}
508	rev := domain.AppDesiredRevision{
509		Revision: "rev-1", App: "blog",
510		Spec: domain.AppSpec{Name: "blog", Env: map[string]string{}, Services: []domain.AppService{svcSpec}},
511	}
512
513	state.EXPECT().Recover(mock.Anything).Return(nil)
514	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
515	images.EXPECT().ResolveDigest(mock.Anything, svcSpec.Image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil)
516	runtime.EXPECT().InspectImageVolumes(mock.Anything, svcSpec.Image).Return(nil, nil)
517	state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil)
518	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
519	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
520	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
521		App: "blog", Services: map[string]domain.AppEffectiveService{},
522	}, true, nil)
523	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
524	expectNetworkProvision(runtime, "app-blog", 1)
525
526	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
527		if len(cfg.PortPublishes) != 2 {
528			return false
529		}
530		byProto := map[domain.NetworkProtocol]domain.ContainerPortPublish{}
531		for _, publish := range cfg.PortPublishes {
532			if publish.HostIP != "127.0.0.1" || publish.HostPort != 0 || publish.ContainerPort != 9000 {
533				return false
534			}
535			byProto[publish.Protocol] = publish
536		}
537		return len(byProto) == 2
538	})).Return(&domain.Container{ID: "c-new", Name: "web"}, nil).Once()
539	runtime.EXPECT().StartContainer(mock.Anything, "c-new").Return(nil).Once()
540	runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-new", mock.MatchedBy(func(ports []domain.ContainerBackendPort) bool {
541		if len(ports) != 2 {
542			return false
543		}
544		seen := map[domain.ContainerBackendPort]bool{}
545		for _, port := range ports {
546			seen[port] = true
547		}
548		return seen[domain.ContainerBackendPort{ContainerPort: 9000, Protocol: domain.NetworkProtocolTCP}] &&
549			seen[domain.ContainerBackendPort{ContainerPort: 9000, Protocol: domain.NetworkProtocolUDP}]
550	})).Return([]domain.ContainerBackendBind{
551		{ContainerPort: 9000, HostPort: 19000, Protocol: domain.NetworkProtocolTCP},
552		{ContainerPort: 9000, HostPort: 19001, Protocol: domain.NetworkProtocolUDP},
553	}, nil).Once()
554	state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
555		if len(claims) != 2 {
556			return false
557		}
558		byProto := map[string]domain.AppListenerReservation{}
559		for _, claim := range claims {
560			if claim.Owner != domain.OwnerGordonBackend || claim.ContainerID != "c-new" || claim.IP != "127.0.0.1" {
561				return false
562			}
563			byProto[claim.Proto] = claim
564		}
565		return byProto["tcp"].Port == 19000 && byProto["udp"].Port == 19001
566	})).Return(nil).Once()
567	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{}}, true, nil)
568	state.EXPECT().SaveActive(mock.Anything, mock.MatchedBy(func(a domain.AppActive) bool {
569		svc := a.Services["web"]
570		return svc.BackendBinds[9000] == 19000 && svc.UDPBackendBinds[9000] == 19001
571	})).Return(nil).Once()
572	state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil).Once()
573
574	svc := deployment.NewService(
575		deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
576		zerowrap.Default(),
577	).WithProbeDeps(deployment.NewProbeDeps(runtime))
578
579	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
580	require.NoError(t, err)
581	require.NotNil(t, result)
582	assert.Equal(t, "deployed", result.Services["web"].Result)
583	assert.Equal(t, map[int]int{9000: 19000}, result.Services["web"].BackendBinds)
584	assert.Equal(t, map[int]int{9000: 19001}, result.Services["web"].UDPBackendBinds)
585}
586
587// TestDeploy_Mockery_InjectsAppEnvAndSecrets proves the captured revision's
588// app-wide public env reaches every service container alongside that
589// service's own resolved secrets, sorted and deterministic. Public-only
590// and empty-value cases are covered; resolved secret values never enter
591// saved state (SaveActive/SaveOwnership carry no KEY=value payloads).
592func TestDeploy_Mockery_InjectsAppEnvAndSecrets(t *testing.T) {
593	ctx := context.Background()
594	state, runtime, images, secrets := mockDeps(t)
595	svcSpec := webService()
596	svcSpec.HTTP = nil
597	svcSpec.Readiness = domain.AppReadiness{}
598	rev := domain.AppDesiredRevision{
599		Revision: "rev-1", App: "blog",
600		Spec: domain.AppSpec{
601			Name: "blog",
602			Env:  map[string]string{"APP_ENV": "production", "EMPTY_OK": ""},
603			Services: []domain.AppService{
604				svcSpec,
605				{
606					Name: "worker", Image: "docker.io/example/worker:1.4.2",
607					StopGrace: 10 * time.Second,
608					Readiness: domain.AppReadiness{Timeout: 30 * time.Second},
609					Secrets:   map[string]string{},
610				},
611			},
612		},
613	}
614
615	state.EXPECT().Recover(mock.Anything).Return(nil)
616	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
617	images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
618	images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[1].Image).Return("sha256:"+strings.Repeat("b", 64), nil).Once()
619	secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("s3cr3t", nil).Twice()
620	runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return(nil, nil).Once()
621	runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[1].Image).Return(nil, nil).Once()
622	state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
623	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
624	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
625	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
626		App: "blog", Services: map[string]domain.AppEffectiveService{},
627	}, true, nil)
628	state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
629	expectNetworkProvision(runtime, "app-blog", 2)
630
631	var webEnv, workerEnv []string
632	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
633		if len(cfg.Env) != 3 {
634			return false
635		}
636		webEnv = append([]string(nil), cfg.Env...)
637		return true
638	})).Return(&domain.Container{ID: "c-web", Name: "web"}, nil).Once()
639	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool {
640		workerEnv = append([]string(nil), cfg.Env...)
641		return len(cfg.Env) == 2
642	})).Return(&domain.Container{ID: "c-worker", Name: "worker"}, nil).Once()
643	runtime.EXPECT().StartContainer(mock.Anything, mock.Anything).Return(nil)
644	state.EXPECT().LoadActive(mock.Anything, "blog").Return(domain.AppActive{
645		App: "blog", Services: map[string]domain.AppEffectiveService{},
646	}, true, nil)
647	state.EXPECT().SaveActive(mock.Anything, mock.MatchedBy(func(a domain.AppActive) bool {
648		for _, svc := range a.Services {
649			for _, kv := range []string{svc.Image, svc.Digest, svc.Container} {
650				if kv == "s3cr3t" || kv == "DATABASE_URL=s3cr3t" {
651					return false
652				}
653			}
654		}
655		return true
656	})).Return(nil)
657	state.EXPECT().SaveOwnership(mock.Anything, mock.Anything).Return(nil)
658
659	svc := deployment.NewService(
660		deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
661		zerowrap.Default(),
662	).WithProbeDeps(deployment.NewProbeDeps(runtime))
663
664	result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
665	require.NoError(t, err)
666	require.NotNil(t, result)
667	assert.Equal(t, []string{"APP_ENV=production", "DATABASE_URL=s3cr3t", "EMPTY_OK="}, webEnv)
668	assert.Equal(t, []string{"APP_ENV=production", "EMPTY_OK="}, workerEnv)
669}
670
671// TestStart_RunningContainerRefreshesShiftedBinds proves boot/start bind
672// verification: a running container whose ephemeral loopback bind shifted
673// (native restart while the daemon was away) gets its ACTIVE record
674// updated before the proxy can dial the stale bind. The ACTIVE
675// container is never stopped or removed by re-inspection.
676func TestStart_RunningContainerRefreshesShiftedBinds(t *testing.T) {
677	ctx := context.Background()
678	state, runtime, images, secrets := mockDeps(t)
679	rev := mockRevision()
680
681	state.EXPECT().Recover(mock.Anything).Return(nil)
682	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
683	state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil)
684	active := domain.AppActive{
685		App: "blog",
686		Services: map[string]domain.AppEffectiveService{
687			"web": {
688				EffectiveRevision: "rev-1",
689				Image:             rev.Spec.Services[0].Image,
690				Digest:            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
691				Container:         "c-old",
692				Spec:              rev.Spec.Services[0],
693				BackendBinds:      map[int]int{8080: 32770},
694			},
695		},
696	}
697	state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
698	state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
699	runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(true, nil)
700	// Native restart shifted the ephemeral bind: 32770 -> 32771.
701	runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
702	state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
703		return len(claims) == 1 && claims[0].Port == 32771 && claims[0].Owner == domain.OwnerGordonBackend && claims[0].ContainerID == "c-old"
704	})).Return(nil).Once()
705	state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
706	state.EXPECT().SaveActive(mock.Anything, mock.MatchedBy(func(a domain.AppActive) bool {
707		return a.Services["web"].BackendBinds[8080] == 32771
708	})).Return(nil).Once()
709
710	svc := deployment.NewService(
711		deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
712		zerowrap.Default(),
713	).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
714		func(context.Context, string, string) (int, error) { return 200, nil },
715		func(context.Context, string) error { return nil },
716	))
717	result, err := svc.Start(ctx, "blog", "")
718	require.NoError(t, err)
719	require.NotNil(t, result)
720	require.Contains(t, result.Services, "web")
721	assert.Equal(t, "deployed", result.Services["web"].Result)
722	assert.Equal(t, map[int]int{8080: 32771}, result.Services["web"].BackendBinds)
723	runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
724	runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
725}
726
727// TestStart_BindVerificationFailureFailsClosed proves an unverified bind
728// is never served: when re-inspection fails on a running container, the
729// service step fails (fail-closed) without touching the container, and
730// the stale recorded bind is withdrawn from ACTIVE so the next traffic
731// rebuild cannot re-expose a dead or recycled loopback port.
732func TestStart_BindVerificationFailureFailsClosed(t *testing.T) {
733	ctx := context.Background()
734	state, runtime, images, secrets := mockDeps(t)
735	rev := mockRevision()
736
737	state.EXPECT().Recover(mock.Anything).Return(nil)
738	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
739	state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil)
740	active := domain.AppActive{
741		App: "blog",
742		Services: map[string]domain.AppEffectiveService{
743			"web": {
744				EffectiveRevision: "rev-1",
745				Image:             rev.Spec.Services[0].Image,
746				Digest:            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
747				Container:         "c-old",
748				Spec:              rev.Spec.Services[0],
749				BackendBinds:      map[int]int{8080: 32770},
750			},
751		},
752	}
753	state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
754	state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
755	runtime.EXPECT().IsContainerRunning(mock.Anything, "c-old").Return(true, nil)
756	runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(nil, assert.AnError).Once()
757
758	traffic := &recordingTraffic{}
759	svc := deployment.NewService(
760		deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets, Traffic: traffic},
761		zerowrap.Default(),
762	)
763	result, err := svc.Start(ctx, "blog", "")
764	require.NoError(t, err)
765	require.NotNil(t, result)
766	assert.Equal(t, "failed", result.Services["web"].Result)
767	runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
768	runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
769	// The stale bind is withdrawn through the canonical boundary
770	// (fail-closed projection); deployment writes no ACTIVE binds itself.
771	assert.Equal(t, []string{"blog/web", "blog/web"}, traffic.withdrawn())
772	state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
773}
774
775// TestRestart_MixedServiceRefreshesBothProtocols proves a runtime restart
776// re-inspects TCP and UDP binds together: shifted ephemeral ports on
777// both protocols persist to ACTIVE and surface in the result.
778func TestRestart_MixedServiceRefreshesBothProtocols(t *testing.T) {
779	ctx := context.Background()
780	state, runtime, images, secrets := mockDeps(t)
781	svcSpec := webService()
782	svcSpec.HTTP = nil
783	svcSpec.TCP = []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "9000"}}
784	svcSpec.UDP = []domain.AppUDPInterface{{Entrypoint: "udp", Port: 9000, Publish: "9000"}}
785	svcSpec.Readiness = domain.AppReadiness{Type: "none", Timeout: time.Second}
786	svcSpec.Secrets = map[string]string{}
787	active := domain.AppActive{
788		App: "blog",
789		Services: map[string]domain.AppEffectiveService{
790			"web": {
791				EffectiveRevision: "rev-1",
792				Image:             svcSpec.Image,
793				Digest:            "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
794				Container:         "c-old",
795				Spec:              svcSpec,
796				BackendBinds:      map[int]int{9000: 32770},
797				UDPBackendBinds:   map[int]int{9000: 32780},
798			},
799		},
800	}
801	state.EXPECT().Recover(mock.Anything).Return(nil)
802	state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
803	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
804	state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
805	runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
806	runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return([]domain.ContainerBackendBind{
807		{ContainerPort: 9000, HostPort: 32771, Protocol: domain.NetworkProtocolTCP},
808		{ContainerPort: 9000, HostPort: 32781, Protocol: domain.NetworkProtocolUDP},
809	}, nil).Once()
810	state.EXPECT().RegisterBackendBinds(mock.Anything, mock.MatchedBy(func(claims []domain.AppListenerReservation) bool {
811		if len(claims) != 2 {
812			return false
813		}
814		byProto := map[string]int{}
815		for _, claim := range claims {
816			byProto[claim.Proto] = claim.Port
817		}
818		return byProto["tcp"] == 32771 && byProto["udp"] == 32781
819	})).Return(nil).Once()
820	state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
821	state.EXPECT().SaveActive(mock.Anything, mock.MatchedBy(func(a domain.AppActive) bool {
822		svc := a.Services["web"]
823		return svc.BackendBinds[9000] == 32771 && svc.UDPBackendBinds[9000] == 32781
824	})).Return(nil).Once()
825
826	svc := deployment.NewService(
827		deployment.Deps{State: state, Runtime: runtime, Images: images, Secrets: secrets},
828		zerowrap.Default(),
829	)
830	result, err := svc.Restart(ctx, "blog", "", "")
831	require.NoError(t, err)
832	require.NotNil(t, result)
833	assert.Equal(t, "deployed", result.Services["web"].Result)
834	assert.Equal(t, map[int]int{9000: 32771}, result.Services["web"].BackendBinds)
835	assert.Equal(t, map[int]int{9000: 32781}, result.Services["web"].UDPBackendBinds)
836}

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