1package app
2
3import (
4 "context"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"
10
11 outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
12 "github.com/bnema/gordon/internal/domain"
13 "github.com/bnema/gordon/internal/usecase/apptraffic"
14)
15
16// servedActive builds an ACTIVE record for one served host with a
17// resolved loopback backend.
18func servedActive(container string, port int) domain.AppActive {
19 return domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
20 "web": {Container: container, Spec: httpAppSpec(), BackendBinds: map[int]int{8080: port}},
21 }}
22}
23
24// TestAppTrafficPublisher_PublishesIndexOnlyAfterGraphApply proves the
25// prepare/commit contract: the graph is built and applied against a
26// detached candidate while the live index still serves the previous
27// generation, and only a successful apply publishes the candidate.
28func TestAppTrafficPublisher_PublishesIndexOnlyAfterGraphApply(t *testing.T) {
29 ctx := context.Background()
30 state := outmocks.NewMockAppState(t)
31 svc, index := newPublisherServices(state)
32
33 first := servedActive("c-1", 32770)
34 second := servedActive("c-2", 32771)
35
36 // First generation: one successful publication.
37 state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil).Once()
38 state.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
39 state.EXPECT().LoadActive(mock.Anything, "blog").Return(first, true, nil).Once()
40
41 publisher := newAppTrafficPublisher(svc, Config{})
42 require.NoError(t, publisher.RebuildTraffic(ctx))
43 backend, ok := index.LookupHost("blog.example.com")
44 require.True(t, ok)
45 assert.Equal(t, 32770, backend.Port)
46
47 // Second generation: the applier observes the candidate and the live
48 // index side by side.
49 state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil).Once()
50 state.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
51 state.EXPECT().LoadActive(mock.Anything, "blog").Return(second, true, nil).Once()
52
53 applies := 0
54 publisher.applyGraph = func(_ context.Context, _ Config, hosts appHostRoutes) error {
55 applies++
56 require.NotNil(t, hosts, "the graph is built against the candidate view")
57 require.Len(t, hosts.AppHosts(), 1)
58 // The candidate carries the new backend while the live index
59 // still carries the old one: nothing is published yet.
60 live, ok := index.LookupHost("blog.example.com")
61 require.True(t, ok)
62 assert.Equal(t, 32770, live.Port, "the live index must not change before the graph apply succeeds")
63 candidateHosts := hosts.(*apptraffic.HostIndex)
64 candidateBackend, ok := candidateHosts.LookupHost("blog.example.com")
65 require.True(t, ok)
66 assert.Equal(t, 32771, candidateBackend.Port)
67 return nil
68 }
69
70 require.NoError(t, publisher.RebuildTraffic(ctx))
71 assert.Equal(t, 1, applies)
72 backend, ok = index.LookupHost("blog.example.com")
73 require.True(t, ok)
74 assert.Equal(t, 32771, backend.Port, "the accepted candidate is published exactly once")
75}
76
77// TestAppTrafficPublisher_GraphFailureKeepsPreviousGeneration proves a
78// rejected graph leaves the live index exactly where it was.
79func TestAppTrafficPublisher_GraphFailureKeepsPreviousGeneration(t *testing.T) {
80 ctx := context.Background()
81 state := outmocks.NewMockAppState(t)
82 svc, index := newPublisherServices(state)
83
84 first := servedActive("c-1", 32770)
85 second := servedActive("c-2", 32771)
86
87 state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil).Once()
88 state.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
89 state.EXPECT().LoadActive(mock.Anything, "blog").Return(first, true, nil).Once()
90
91 publisher := newAppTrafficPublisher(svc, Config{})
92 require.NoError(t, publisher.RebuildTraffic(ctx))
93
94 state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil).Once()
95 state.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
96 state.EXPECT().LoadActive(mock.Anything, "blog").Return(second, true, nil).Once()
97
98 publisher.applyGraph = func(context.Context, Config, appHostRoutes) error {
99 return assert.AnError
100 }
101
102 require.ErrorIs(t, publisher.RebuildTraffic(ctx), assert.AnError)
103 backend, ok := index.LookupHost("blog.example.com")
104 require.True(t, ok)
105 assert.Equal(t, 32770, backend.Port, "a rejected graph leaves the previous generation published")
106}
107
108// TestAppTrafficPublisher_WithdrawalStaysFailClosedWhenGraphApplyFails
109// proves a rejected graph cannot leave a withdrawn backend published: the
110// durable binds are already gone, so the read model must drop them even
111// though the error is reported for a retry.
112func TestAppTrafficPublisher_WithdrawalStaysFailClosedWhenGraphApplyFails(t *testing.T) {
113 ctx := context.Background()
114 state := outmocks.NewMockAppState(t)
115 svc, index := newPublisherServices(state)
116
117 served := servedActive("c-1", 32770)
118 withdrawn := domain.AppActive{App: "blog", Services: map[string]domain.AppEffectiveService{
119 "web": {Container: "c-1", Spec: httpAppSpec()},
120 }}
121
122 state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil).Once()
123 state.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
124 state.EXPECT().LoadActive(mock.Anything, "blog").Return(served, true, nil).Once()
125
126 publisher := newAppTrafficPublisher(svc, Config{})
127 require.NoError(t, publisher.RebuildTraffic(ctx))
128 _, ok := index.LookupHost("blog.example.com")
129 require.True(t, ok)
130
131 // The withdrawal clears the binds, then the graph apply is rejected.
132 state.EXPECT().LoadActive(mock.Anything, "blog").Return(served, true, nil).Once()
133 state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
134 state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil).Once()
135 state.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
136 state.EXPECT().LoadActive(mock.Anything, "blog").Return(withdrawn, true, nil).Once()
137 state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil).Once()
138 state.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
139 state.EXPECT().LoadActive(mock.Anything, "blog").Return(withdrawn, true, nil).Once()
140
141 publisher.applyGraph = func(context.Context, Config, appHostRoutes) error {
142 return assert.AnError
143 }
144
145 require.Error(t, publisher.WithdrawService(ctx, "blog", "web"))
146 backend, ok := index.LookupHost("blog.example.com")
147 if ok {
148 assert.False(t, backend.Resolved(), "a withdrawn service must not keep a resolved backend")
149 }
150}
151
152// TestAppTrafficPublisher_ProjectionFailureAppliesNoGraph proves a
153// failing projection never reaches the dataplane: the graph apply is not
154// called at all and the live index is untouched.
155func TestAppTrafficPublisher_ProjectionFailureAppliesNoGraph(t *testing.T) {
156 ctx := context.Background()
157 state := outmocks.NewMockAppState(t)
158 svc, index := newPublisherServices(state)
159
160 first := servedActive("c-1", 32770)
161 state.EXPECT().ListApps(mock.Anything).Return([]string{"blog"}, nil).Once()
162 state.EXPECT().LoadIntent(mock.Anything, "blog").Return(domain.AppStopIntent{App: "blog"}, nil).Once()
163 state.EXPECT().LoadActive(mock.Anything, "blog").Return(first, true, nil).Once()
164
165 publisher := newAppTrafficPublisher(svc, Config{})
166 require.NoError(t, publisher.RebuildTraffic(ctx))
167
168 state.EXPECT().ListApps(mock.Anything).Return(nil, assert.AnError).Once()
169 applied := false
170 publisher.applyGraph = func(context.Context, Config, appHostRoutes) error {
171 applied = true
172 return nil
173 }
174
175 require.Error(t, publisher.RebuildTraffic(ctx))
176 assert.False(t, applied, "a failed projection must not reach the dataplane")
177 backend, ok := index.LookupHost("blog.example.com")
178 require.True(t, ok)
179 assert.Equal(t, 32770, backend.Port)
180}