1package deployment_test
2
3import (
4 "context"
5 "testing"
6 "time"
7
8 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"
10 "github.com/stretchr/testify/mock"
11 "github.com/stretchr/testify/require"
12
13 "github.com/bnema/gordon/internal/domain"
14 "github.com/bnema/gordon/internal/usecase/deployment"
15)
16
17// graceSpec is a headless service carrying one explicit stop grace.
18func graceSpec(grace time.Duration) domain.AppService {
19 spec := headlessSpec()
20 spec.StopGrace = grace
21 return spec
22}
23
24func activeWithGrace(container string, grace time.Duration) domain.AppActive {
25 return domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
26 "web": {Container: container, EffectiveRevision: "rev-1", Spec: graceSpec(grace)},
27 }}
28}
29
30// TestLifecycleVerbsPassTheEffectiveStopGrace proves every runtime stop
31// uses the effective service grace instead of a fixed adapter timeout.
32func TestLifecycleVerbsPassTheEffectiveStopGrace(t *testing.T) {
33 const grace = 25 * time.Second
34
35 t.Run("stop", func(t *testing.T) {
36 ctx := context.Background()
37 state, runtime, _, _ := mockDeps(t)
38 active := activeWithGrace("c-1", grace)
39 state.EXPECT().Recover(mock.Anything).Return(nil)
40 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
41 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil).Once()
42 runtime.EXPECT().StopContainer(mock.Anything, "c-1", grace).Return(nil).Once()
43 runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", false).Return(nil).Once()
44 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
45 state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-1").Return(nil).Once()
46 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
47
48 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime}, zerowrap.Default())
49 _, err := svc.Stop(ctx, "blog", "")
50 require.NoError(t, err)
51 })
52
53 t.Run("restart", func(t *testing.T) {
54 ctx := context.Background()
55 state, runtime, _, _ := mockDeps(t)
56 active := activeWithGrace("c-1", grace)
57 state.EXPECT().Recover(mock.Anything).Return(nil)
58 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
59 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
60 runtime.EXPECT().RestartContainer(mock.Anything, "c-1", grace).Return(nil).Once()
61 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
62
63 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime}, zerowrap.Default())
64 _, err := svc.Restart(ctx, "blog", "web", "")
65 require.NoError(t, err)
66 })
67
68 t.Run("remove", func(t *testing.T) {
69 ctx := context.Background()
70 state, runtime, _, _ := mockDeps(t)
71 active := activeWithGrace("c-1", grace)
72 state.EXPECT().Recover(mock.Anything).Return(nil)
73 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
74 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil).Once()
75 state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.Anything).Return(nil).Once()
76 runtime.EXPECT().StopContainer(mock.Anything, "c-1", grace).Return(nil).Once()
77 runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", false).Return(nil).Once()
78 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
79 state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-1").Return(nil).Once()
80 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog"}, nil).Once()
81 state.EXPECT().RetireApp(mock.Anything, "blog").Return(nil).Once()
82 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
83
84 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime}, zerowrap.Default())
85 _, err := svc.Remove(ctx, "blog", "")
86 require.NoError(t, err)
87 })
88}
89
90// TestStopWithoutDeclaredGraceUsesTheAppDefault proves an effective spec
91// with no stop_grace still reaches the runtime as the app default instead
92// of an immediate kill.
93func TestStopWithoutDeclaredGraceUsesTheAppDefault(t *testing.T) {
94 ctx := context.Background()
95 state, runtime, _, _ := mockDeps(t)
96 active := activeWithGrace("c-1", 0)
97 state.EXPECT().Recover(mock.Anything).Return(nil)
98 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
99 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil).Once()
100 runtime.EXPECT().StopContainer(mock.Anything, "c-1", domain.AppDefaultStopGrace).Return(nil).Once()
101 runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", false).Return(nil).Once()
102 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
103 state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "web", "c-1").Return(nil).Once()
104 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
105
106 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime}, zerowrap.Default())
107 _, err := svc.Stop(ctx, "blog", "")
108 require.NoError(t, err)
109}
110
111// TestRemove_FailedRetirementKeepsClaimsAndActiveState proves a container
112// that is not confirmed gone keeps its backend claims, keeps its ACTIVE
113// reference, and fails the operation terminally.
114func TestRemove_FailedRetirementKeepsClaimsAndActiveState(t *testing.T) {
115 ctx := context.Background()
116 state, runtime, _, _ := mockDeps(t)
117 active := activeWithGrace("c-1", time.Second)
118 state.EXPECT().Recover(mock.Anything).Return(nil)
119 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
120 state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil).Once()
121 state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.Anything).Return(nil).Once()
122 runtime.EXPECT().StopContainer(mock.Anything, "c-1", time.Second).Return(nil).Once()
123 runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", false).Return(assert.AnError).Once()
124 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
125
126 svc := deployment.NewService(deployment.Deps{State: state, Runtime: runtime}, zerowrap.Default())
127 _, err := svc.Remove(ctx, "blog", "")
128 require.Error(t, err)
129 assert.Contains(t, err.Error(), "not confirmed gone")
130 state.AssertNotCalled(t, "ReleaseBackendBinds", mock.Anything, mock.Anything, mock.Anything)
131 state.AssertNotCalled(t, "ClearRecoveryInhibition", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
132 state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
133 state.AssertNotCalled(t, "RetireApp", mock.Anything, mock.Anything)
134}
135
136// TestDeploy_RetirementFailureBlocksReplacement proves the superseded
137// generation must be confirmed gone before a replacement starts: a failed
138// retirement aborts the service without creating a candidate, and the
139// failure is journaled instead of being downgraded to a warning.
140func TestDeploy_RetirementFailureBlocksReplacement(t *testing.T) {
141 ctx := context.Background()
142 state, runtime, images, secrets := mockDeps(t)
143 rev := mockRevision()
144
145 oldActive := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
146 "web": {EffectiveRevision: "rev-0", Image: "img:0", Container: "c-old", Spec: graceSpec(time.Second)},
147 }}
148
149 state.EXPECT().Recover(mock.Anything).Return(nil)
150 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
151 images.EXPECT().ResolveDigest(mock.Anything, rev.Spec.Services[0].Image).Return(
152 "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil).Once()
153 secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-blog/web/database-url").Return("x", nil)
154 runtime.EXPECT().InspectImageVolumes(mock.Anything, rev.Spec.Services[0].Image).Return(nil, nil).Once()
155 state.EXPECT().LoadCheckpoint(mock.Anything).Return(domain.AppStoreCheckpoint{}, nil).Once()
156 state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-blog"}, nil)
157 state.EXPECT().LoadActive(mock.Anything, "blog").Return(oldActive, true, nil)
158 state.EXPECT().LoadDesired(mock.Anything, "blog").Return(rev, true, nil)
159 // The superseded container is retired with its own effective grace, and
160 // its removal fails: the replacement must not start.
161 runtime.EXPECT().StopContainer(mock.Anything, "c-old", time.Second).Return(nil).Once()
162 runtime.EXPECT().RemoveContainer(mock.Anything, "c-old", false).Return(assert.AnError).Once()
163
164 var saved []domain.AppOperation
165 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).RunAndReturn(
166 func(_ context.Context, op domain.AppOperation) error {
167 saved = append(saved, op)
168 return nil
169 })
170
171 svc := deployment.NewService(deployment.Deps{
172 State: state, Runtime: runtime, Images: images, Secrets: secrets,
173 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
174 func(context.Context, string, string) (int, error) { return 200, nil },
175 func(context.Context, string) error { return nil },
176 ))
177
178 result, err := svc.Deploy(ctx, deployment.DeployInput{App: "blog"})
179 require.Error(t, err)
180 require.NotNil(t, result)
181 assert.Equal(t, "failed", result.Services["web"].Result)
182 assert.Contains(t, result.Services["web"].Error, "retire superseded container",
183 "the unconfirmed retirement is the reported failure")
184
185 // No workload is created, started, or published while the superseded
186 // generation is not confirmed gone, and no claim or inhibition of that
187 // generation is released.
188 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
189 runtime.AssertNotCalled(t, "StartContainer", mock.Anything, mock.Anything)
190 runtime.AssertNotCalled(t, "ListNetworks", mock.Anything)
191 runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)
192 state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
193 state.AssertNotCalled(t, "ReleaseBackendBinds", mock.Anything, mock.Anything, mock.Anything)
194 state.AssertNotCalled(t, "ClearRecoveryInhibition", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
195
196 require.NotEmpty(t, saved)
197 last := saved[len(saved)-1]
198 require.Len(t, last.Steps, 2)
199 assert.Equal(t, "preflight", last.Steps[0].ID)
200 assert.Equal(t, domain.AppStepSucceeded, last.Steps[0].State)
201 assert.Equal(t, "service.web.replace", last.Steps[1].ID)
202 assert.Equal(t, domain.AppStepFailed, last.Steps[1].State)
203 assert.Equal(t, domain.AppOutcomeFailed, last.Outcome)
204}