v3.1.0 d676137

restart_inplace_test.go RawHistoryFolder

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

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