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 state.EXPECT().ClearRecoveryInhibition(mock.Anything, "blog", "legacy", "c-legacy").Return(nil).Once()
66 var saved domain.AppActive
67 state.EXPECT().SaveActive(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, a domain.AppActive) error {
68 saved = a
69 return nil
70 }).Once()
71
72 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default())
73 steps, removed, warnings, err := svc.reconcileRemovedServices(ctx, "blog", active, []pinnedService{
74 {name: "web", spec: domain.AppService{Name: "web"}},
75 })
76
77 require.NoError(t, err)
78 assert.Equal(t, []string{"legacy"}, removed)
79 assert.Empty(t, warnings)
80 require.Len(t, steps, 1)
81 assert.Equal(t, domain.AppStepSucceeded, steps[0].State)
82 assert.Equal(t, []string{"blog/legacy"}, traffic.withdrawn)
83 _, ok := saved.Services["legacy"]
84 assert.False(t, ok, "removed service must be absent from ACTIVE")
85 _, ok = saved.Services["web"]
86 assert.True(t, ok, "healthy sibling must be preserved")
87 runtime.AssertNotCalled(t, "RemoveVolume", mock.Anything, mock.Anything, mock.Anything)
88}
89
90// TestReconcileRemovedServices_WithdrawFailureAbortsBeforeStop proves a
91// failed withdrawal stops the deploy before the container is touched, so
92// ACTIVE is never cleared while the service may still be reachable.
93func TestReconcileRemovedServices_WithdrawFailureAbortsBeforeStop(t *testing.T) {
94 state := outmocks.NewMockAppState(t)
95 runtime := outmocks.NewMockContainerRuntime(t)
96 traffic := &stubTraffic{fail: true}
97 active := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
98 "legacy": {Container: "c-legacy", EffectiveRevision: "rev-1", Spec: domain.AppService{Name: "legacy"}},
99 }}
100
101 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: traffic}, zerowrap.Default())
102 steps, _, _, err := svc.reconcileRemovedServices(context.Background(), "blog", active, nil)
103
104 require.Error(t, err)
105 require.Len(t, steps, 1)
106 assert.Equal(t, domain.AppStepFailed, steps[0].State)
107 runtime.AssertNotCalled(t, "StopContainer", mock.Anything, mock.Anything, mock.Anything)
108 state.AssertNotCalled(t, "SaveActive", mock.Anything, mock.Anything)
109}
110
111// TestReconcileRemovedServices_NoopWhenAllActiveServicesRemain proves a
112// deploy that keeps every active service performs no removal effects.
113func TestReconcileRemovedServices_NoopWhenAllActiveServicesRemain(t *testing.T) {
114 state := outmocks.NewMockAppState(t)
115 runtime := outmocks.NewMockContainerRuntime(t)
116 active := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
117 "web": {Container: "c-web"},
118 }}
119
120 svc := NewService(Deps{State: state, Runtime: runtime, Traffic: &stubTraffic{}}, zerowrap.Default())
121 steps, removed, warnings, err := svc.reconcileRemovedServices(context.Background(), "blog", active, []pinnedService{{name: "web"}})
122
123 require.NoError(t, err)
124 assert.Empty(t, steps)
125 assert.Empty(t, removed)
126 assert.Empty(t, warnings)
127}