1package deployment_test
2
3import (
4 "context"
5 "errors"
6 "sync"
7 "testing"
8 "time"
9
10 "github.com/bnema/zerowrap"
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/mock"
13 "github.com/stretchr/testify/require"
14
15 "github.com/bnema/gordon/internal/domain"
16 "github.com/bnema/gordon/internal/usecase/deployment"
17)
18
19// restartTrafficRecorder records the traffic boundary calls of a restart.
20type restartTrafficRecorder struct {
21 mu sync.Mutex
22 events *[]string
23 failRebuild bool
24}
25
26func (r *restartTrafficRecorder) record(event string) {
27 r.mu.Lock()
28 defer r.mu.Unlock()
29 if r.events != nil {
30 *r.events = append(*r.events, event)
31 }
32}
33func (r *restartTrafficRecorder) RebuildTraffic(context.Context) error {
34 r.record("traffic")
35 if r.failRebuild {
36 return errors.New("traffic rebuild failed")
37 }
38 return nil
39}
40func (r *restartTrafficRecorder) WithdrawService(context.Context, string, string) error {
41 r.record("withdraw")
42 return nil
43}
44func (r *restartTrafficRecorder) WithdrawServiceState(context.Context, string, string) error {
45 r.record("withdraw-state")
46 return nil
47}
48
49const restartTestDigest = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
50
51func restartActive(t *testing.T, spec domain.AppService) domain.AppActive {
52 t.Helper()
53 return domain.AppActive{App: "blog", ConvergedRevision: "rev-1", Converged: true, Services: map[string]domain.AppEffectiveService{"web": {EffectiveRevision: "rev-1", ActivatedBy: "op-before", ActivatedAt: time.Now().Add(-time.Hour).UTC(), Image: spec.Image, Digest: restartTestDigest, Container: "c-old", Spec: spec, BackendBinds: map[int]int{8080: 32770}}}}
54}
55
56// TestRestart_InPlaceWithdrawsRestartsVerifiesAndRepublishes proves the
57// restart contract: the service is withdrawn from traffic, the SAME pinned
58// container is restarted, readiness is verified against its refreshed
59// loopback bind, and traffic is republished. No second container is created
60// and no container is stopped or removed.
61func TestRestart_InPlaceWithdrawsRestartsVerifiesAndRepublishes(t *testing.T) {
62 ctx := context.Background()
63 state, runtime, images, secrets := mockDeps(t)
64 spec := webService()
65 spec.Secrets = map[string]string{}
66 spec.StopGrace = 10 * time.Millisecond
67 // No declared readiness probe: the implicit HTTP check still applies to a
68 // stateless public HTTP service on restart, exactly as it does on deploy.
69 spec.Readiness = domain.AppReadiness{Timeout: time.Second}
70 active := restartActive(t, spec)
71
72 var order []string
73 state.EXPECT().Recover(mock.Anything).Return(nil)
74 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
75 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
76 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).RunAndReturn(
77 func(context.Context, string, time.Duration) error {
78 recordEvent(&order, "restart")
79 return nil
80 }).Once()
81 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
82 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
83 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
84 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
85 var saved domain.AppActive
86 state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(
87 func(_ context.Context, refreshed domain.AppActive) error {
88 saved = refreshed
89 recordEvent(&order, "persist-binds")
90 return nil
91 }).Once()
92 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
93
94 var probedURL string
95 svc := deployment.NewService(deployment.Deps{
96 State: state, Runtime: runtime, Images: images, Secrets: secrets,
97 Traffic: &restartTrafficRecorder{events: &order},
98 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
99 func(_ context.Context, url, _ string) (int, error) {
100 probedURL = url
101 recordEvent(&order, "ready")
102 return 200, nil
103 },
104 func(context.Context, string) error { return nil },
105 ))
106
107 result, err := svc.Restart(ctx, "blog", "web", "")
108 require.NoError(t, err)
109 require.NotNil(t, result)
110 assert.Equal(t, "deployed", result.Services["web"].Result)
111 assert.Equal(t, "c-old", result.Services["web"].After, "the same pinned container is restarted in place")
112 assert.Contains(t, probedURL, "127.0.0.1:32771", "readiness dials the refreshed loopback bind")
113 assert.Equal(t, []string{"withdraw", "restart", "persist-binds", "ready", "traffic"}, order)
114 assert.Equal(t, map[int]int{8080: 32771}, saved.Services["web"].BackendBinds)
115 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
116 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
117 runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
118}
119
120// TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn proves a
121// restart is never served unverified: the failure leaves the same container
122// in place but withdraws its recorded binds, and the graph is republished
123// fail-closed. Nothing is created, stopped, or removed.
124func TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn(t *testing.T) {
125 ctx := context.Background()
126 state, runtime, images, secrets := mockDeps(t)
127 spec := webService()
128 spec.Secrets = map[string]string{}
129 spec.StopGrace = 10 * time.Millisecond
130 spec.Readiness = domain.AppReadiness{Timeout: 50 * time.Millisecond}
131 active := restartActive(t, spec)
132
133 var order []string
134 state.EXPECT().Recover(mock.Anything).Return(nil)
135 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
136 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
137 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
138 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
139 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
140 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
141 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
142 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
143 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
144
145 svc := deployment.NewService(deployment.Deps{
146 State: state, Runtime: runtime, Images: images, Secrets: secrets,
147 Traffic: &restartTrafficRecorder{events: &order},
148 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
149 func(context.Context, string, string) (int, error) { return 503, nil },
150 func(context.Context, string) error { return assert.AnError },
151 ))
152
153 result, err := svc.Restart(ctx, "blog", "web", "")
154 require.Error(t, err)
155 require.NotNil(t, result)
156 assert.Equal(t, "failed", result.Services["web"].Result)
157 assert.Contains(t, result.Services["web"].Error, "readiness")
158 assert.Contains(t, order, "withdraw-state", "the unverified generation is withdrawn from state")
159 assert.Contains(t, order, "traffic", "the graph is republished fail-closed")
160 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
161 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
162 runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
163}