1package deployment
2
3import (
4 "context"
5 "testing"
6
7 "github.com/bnema/zerowrap"
8 "github.com/stretchr/testify/assert"
9 "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"
11
12 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
13 "github.com/bnema/gordon/internal/domain"
14)
15
16type stubTraffic struct {
17 withdrawn []string
18 fail bool
19}
20
21func (s *stubTraffic) WithdrawService(_ context.Context, app, service string) error {
22 if s.fail {
23 return assert.AnError
24 }
25 s.withdrawn = append(s.withdrawn, app+"/"+service)
26 return nil
27}
28
29// WithdrawServiceState records the state-only withdrawal the canonical
30// boundary performs; in-package tests treat it like WithdrawService
31// without a graph application.
32func (s *stubTraffic) WithdrawServiceState(_ context.Context, app, service string) error {
33 if s.fail {
34 return assert.AnError
35 }
36 s.withdrawn = append(s.withdrawn, app+"/"+service)
37 return nil
38}
39
40func (s *stubTraffic) RebuildTraffic(context.Context) error { return nil }
41
42// TestReconcileRemovedServices_WithdrawsStopsAndClears proves a service the
43// new revision drops is withdrawn first, its exact container stopped and
44// removed, its claims released, and its ACTIVE entry deleted while a
45// healthy sibling is preserved.
46func TestReconcileRemovedServices_WithdrawsStopsAndClears(t *testing.T) {
47 ctx := context.Background()
48 state := outmocks.NewMockAppState(t)
49 runtime := outmocks.NewMockContainerRuntime(t)
50 traffic := &stubTraffic{}
51 active := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
52 "web": {Container: "c-web", EffectiveRevision: "rev-1", Spec: domain.AppService{Name: "web"}},
53 "legacy": {
54 Container: "c-legacy", EffectiveRevision: "rev-1",
55 Spec: domain.AppService{Name: "legacy", TCP: []domain.AppTCPInterface{{Entrypoint: "tcp", Port: 9000, Publish: "0.0.0.0:9000"}}},
56 },
57 }}
58
59 state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.MatchedBy(func(i domain.AppRecoveryInhibition) bool {
60 return i.App == "blog" && i.Service == "legacy" && i.ContainerID == "c-legacy" && i.Reason == "removed"
61 })).Return(nil).Once()
62 runtime.EXPECT().StopContainer(mock.Anything, "c-legacy", mock.Anything).Return(nil).Once()
63 runtime.EXPECT().RemoveContainer(mock.Anything, "c-legacy", false).Return(nil).Once()
64 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-legacy").Return(nil).Once()
65 var saved domain.AppActive
66 var order []string
67 state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, a domain.AppActive) error {
68 saved = a
69 order = append(order, "save-active")
70 return nil
71 }).Once()
72 state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "legacy", "c-legacy").RunAndReturn(func(context.Context, string, string, string) error {
73 order = append(order, "clear-inhibition")
74 return nil
75 }).Once()
76
77 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default())
78 steps, removed, warnings, err := svc.reconcileRemovedServices(ctx, "blog", active, []pinnedService{
79 {name: "web", spec: domain.AppService{Name: "web"}},
80 })
81
82 require.NoError(t, err)
83 assert.Equal(t, []string{"legacy"}, removed)
84 assert.Empty(t, warnings)
85 require.Len(t, steps, 1)
86 assert.Equal(t, domain.AppStepSucceeded, steps[0].State)
87 assert.Equal(t, []string{"blog/legacy"}, traffic.withdrawn)
88 _, ok := saved.Services["legacy"]
89 assert.False(t, ok, "removed service must be absent from ACTIVE")
90 _, ok = saved.Services["web"]
91 assert.True(t, ok, "healthy sibling must be preserved")
92 runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)
93 assert.Equal(t, []string{"save-active", "clear-inhibition"}, order, "removal must be persisted before its inhibition is cleared")
94}
95
96// TestReconcileRemovedServices_SaveFailureKeepsInhibition proves a removal
97// that cannot be persisted keeps its recovery inhibition, so recovery can
98// never recreate a service ACTIVE still lists.
99func TestReconcileRemovedServices_SaveFailureKeepsInhibition(t *testing.T) {
100 state := outmocks.NewMockAppState(t)
101 runtime := outmocks.NewMockContainerRuntime(t)
102 active := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
103 "legacy": {Container: "c-legacy", EffectiveRevision: "rev-1", Spec: domain.AppService{Name: "legacy"}},
104 }}
105
106 state.EXPECT().SaveRecoveryInhibition(mock.Anything, mock.Anything).Return(nil).Once()
107 runtime.EXPECT().StopContainer(mock.Anything, "c-legacy", mock.Anything).Return(nil).Once()
108 runtime.EXPECT().RemoveContainer(mock.Anything, "c-legacy", false).Return(nil).Once()
109 state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-legacy").Return(nil).Once()
110 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(assert.AnError).Once()
111
112 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: &stubTraffic{}}, zerowrap.Default())
113 _, _, _, err := svc.reconcileRemovedServices(context.Background(), "blog", active, nil)
114
115 require.ErrorIs(t, err, assert.AnError)
116 state.AssertNotCalled(t, "ClearRecoveryInhibition", mock.Anything, mock.Anything, mock.Anything, mock.Anything)
117}
118
119// TestReconcileRemovedServices_WithdrawFailureAbortsBeforeStop proves a
120// failed withdrawal stops the deploy before the container is touched, so
121// ACTIVE is never cleared while the service may still be reachable.
122func TestReconcileRemovedServices_WithdrawFailureAbortsBeforeStop(t *testing.T) {
123 state := outmocks.NewMockAppState(t)
124 runtime := outmocks.NewMockContainerRuntime(t)
125 traffic := &stubTraffic{fail: true}
126 active := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
127 "legacy": {Container: "c-legacy", EffectiveRevision: "rev-1", Spec: domain.AppService{Name: "legacy"}},
128 }}
129
130 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default())
131 steps, _, _, err := svc.reconcileRemovedServices(context.Background(), "blog", active, nil)
132
133 require.Error(t, err)
134 require.Len(t, steps, 1)
135 assert.Equal(t, domain.AppStepFailed, steps[0].State)
136 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
137 state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
138}
139
140// TestReconcileRemovedServices_NoopWhenAllActiveServicesRemain proves a
141// deploy that keeps every active service performs no removal effects.
142func TestReconcileRemovedServices_NoopWhenAllActiveServicesRemain(t *testing.T) {
143 state := outmocks.NewMockAppState(t)
144 runtime := outmocks.NewMockContainerRuntime(t)
145 active := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
146 "web": {Container: "c-web"},
147 }}
148
149 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: &stubTraffic{}}, zerowrap.Default())
150 steps, removed, warnings, err := svc.reconcileRemovedServices(context.Background(), "blog", active, []pinnedService{{name: "web"}})
151
152 require.NoError(t, err)
153 assert.Empty(t, steps)
154 assert.Empty(t, removed)
155 assert.Empty(t, warnings)
156}