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

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 8

Tags 106

↑↓ moveenter openesc close