main bac21c6

app_traffic_publication_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close