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}