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().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
82 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
83 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
84 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
85 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
86 var saved domain.AppActive
87 state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(
88 func(_ context.Context, refreshed domain.AppActive) error {
89 saved = refreshed
90 recordEvent(&order, "persist-binds")
91 return nil
92 }).Once()
93 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
94
95 var probedURL string
96 svc := deployment.NewService(deployment.Deps{
97 State: state, Runtime: runtime, Images: images, Secrets: secrets,
98 Traffic: &restartTrafficRecorder{events: &order},
99 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
100 func(_ context.Context, url, _ string) (int, error) {
101 probedURL = url
102 recordEvent(&order, "ready")
103 return 200, nil
104 },
105 func(context.Context, string) error { return nil },
106 ))
107
108 result, err := svc.Restart(ctx, "blog", "web", "")
109 require.NoError(t, err)
110 require.NotNil(t, result)
111 assert.Equal(t, "deployed", result.Services["web"].Result)
112 assert.Equal(t, "c-old", result.Services["web"].After, "the same pinned container is restarted in place")
113 assert.Contains(t, probedURL, "127.0.0.1:32771", "readiness dials the refreshed loopback bind")
114 assert.Equal(t, []string{"withdraw", "restart", "persist-binds", "ready", "traffic"}, order)
115 assert.Equal(t, map[int]int{8080: 32771}, saved.Services["web"].BackendBinds)
116 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
117 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
118 runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
119}
120
121// TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn proves a
122// restart is never served unverified: the failure leaves the same container
123// in place but withdraws its recorded binds, and the graph is republished
124// fail-closed. Nothing is created, stopped, or removed.
125func TestRestart_ReadinessFailureLeavesTheRestartedContainerWithdrawn(t *testing.T) {
126 ctx := context.Background()
127 state, runtime, images, secrets := mockDeps(t)
128 spec := webService()
129 spec.Secrets = map[string]string{}
130 spec.StopGrace = 10 * time.Millisecond
131 spec.Readiness = domain.AppReadiness{Timeout: 50 * time.Millisecond}
132 active := restartActive(t, spec)
133
134 var order []string
135 state.EXPECT().Recover(mock.Anything).Return(nil)
136 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
137 state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "blog").Return(nil, nil).Once()
138 runtime.EXPECT().RestartContainer(mock.Anything, "c-old", mock.Anything).Return(nil).Once()
139 runtime.EXPECT().InspectContainer(mock.Anything, "c-old").Return(&domain.Container{ID: "c-old", Status: "running", StartedAt: executionStartedAt}, nil).Maybe()
140 runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "c-old", mock.Anything).Return(
141 []domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32771, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
142 state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
143 state.EXPECT().LoadActive(mock.Anything, "blog").Return(active, true, nil)
144 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
145 state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
146
147 svc := deployment.NewService(deployment.Deps{
148 State: state, Runtime: runtime, Images: images, Secrets: secrets,
149 Traffic: &restartTrafficRecorder{events: &order},
150 }, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
151 func(context.Context, string, string) (int, error) { return 503, nil },
152 func(context.Context, string) error { return assert.AnError },
153 ))
154
155 result, err := svc.Restart(ctx, "blog", "web", "")
156 require.Error(t, err)
157 require.NotNil(t, result)
158 assert.Equal(t, "failed", result.Services["web"].Result)
159 assert.Contains(t, result.Services["web"].Error, "readiness")
160 assert.Contains(t, order, "withdraw-state", "the unverified generation is withdrawn from state")
161 assert.Contains(t, order, "traffic", "the graph is republished fail-closed")
162 runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
163 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
164 runtime.AssertNotCalled(t, "RemoveContainer", mock.Anything, mock.Anything, mock.Anything)
165}