v3.1.0 d676137

service_test.go RawHistoryFolder

1package deployment_test
2
3import (
4	"context"
5	"testing"
6	"time"
7
8	"github.com/bnema/zerowrap"
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/mock"
11	"github.com/stretchr/testify/require"
12
13	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
14	"github.com/bnema/gordon/internal/domain"
15	"github.com/bnema/gordon/internal/usecase/deployment"
16)
17
18func testRevision(app string, services ...domain.AppService) domain.AppDesiredRevision {
19	for i := range services {
20		if services[i].StopGrace == 0 {
21			services[i].StopGrace = 10 * time.Second
22		}
23		if services[i].Readiness.Timeout == 0 {
24			services[i].Readiness.Timeout = 30 * time.Second
25		}
26	}
27	return domain.AppDesiredRevision{
28		Revision: "rev-1", App: app,
29		Spec: domain.AppSpec{Name: app, Env: map[string]string{}, Services: services},
30	}
31}
32
33func webService() domain.AppService {
34	return domain.AppService{
35		Name: "web", Image: "docker.io/example/web:1.4.2",
36		Readiness: domain.AppReadiness{Type: "http", Path: "/healthz"},
37		HTTP:      []domain.AppHTTPInterface{{Host: "blog.example.com", Port: 8080, TLS: "auto"}},
38		Secrets:   map[string]string{"DATABASE_URL": "database-url"},
39	}
40}
41
42func preflightService(
43	t *testing.T,
44	state *outmocks.MockAppState,
45	runtime *outmocks.MockContainerRuntime,
46	images *outmocks.MockImageResolver,
47	secrets *outmocks.MockSecretProvider,
48) *deployment.Service {
49	t.Helper()
50	// Every mutation claims a journal only when no other operation of the
51	// app is unfinished. These tests start clean.
52	state.EXPECT().LoadLatestOperation(mock.Anything, mock.Anything).
53		Return(domain.AppOperation{}, false, nil).Maybe()
54	return deployment.NewService(deployment.Deps{
55		State: state, Runtime: runtime, Images: images, Secrets: secrets,
56		ImagePolicy: domain.ImageSourcePolicy{AllowedRegistries: []string{"registry.example.com"}},
57	}, zerowrap.Default()).WithProbeDeps(deployment.NewTestProbeDeps(runtime,
58		func(context.Context, string, string) (int, error) { return 200, nil },
59		func(context.Context, string) error { return nil },
60	))
61}
62
63func TestComputeOutcome_TerminalResults(t *testing.T) {
64	deployed := deployment.ServiceResult{Result: "deployed"}
65	failed := deployment.ServiceResult{Result: "failed", Error: "boom"}
66	removed := deployment.ServiceResult{Result: domain.AppServiceRemoved}
67	assert.Equal(t, "success", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": removed}))
68	assert.Equal(t, "partial", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": removed, "b": failed}))
69	assert.Equal(t, "success", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": deployed}))
70	assert.Equal(t, "failed", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": failed}))
71	assert.Equal(t, "partial", deployment.ComputeOutcome(map[string]deployment.ServiceResult{"a": deployed, "b": failed}))
72	assert.Equal(t, "failed", deployment.ComputeOutcome(map[string]deployment.ServiceResult{}))
73}
74
75// TestReconcileBoot_ContinuesAfterAppFailure proves R1: a failing app
76// never blocks verification of the following apps, and failures are
77// aggregated instead of aborting at the first one.
78func TestReconcileBoot_ContinuesAfterAppFailure(t *testing.T) {
79	ctx := context.Background()
80	state := outmocks.NewMockAppState(t)
81	runtime := outmocks.NewMockContainerRuntime(t)
82	images := outmocks.NewMockImageResolver(t)
83	secrets := outmocks.NewMockSecretProvider(t)
84	svcSvc := preflightService(t, state, runtime, images, secrets)
85
86	badEff := domain.AppEffectiveService{
87		Container:    "ctr-bad",
88		Spec:         webService(),
89		BackendBinds: map[int]int{8080: 32768},
90	}
91	badActive := domain.AppActive{App: "bad", Services: map[string]domain.AppEffectiveService{"web": badEff}}
92	goodEff := domain.AppEffectiveService{
93		Container: "ctr-good",
94		Spec:      webService(),
95	}
96	goodActive := domain.AppActive{App: "good", Services: map[string]domain.AppEffectiveService{"web": goodEff}}
97
98	state.EXPECT().Recover(mock.Anything).Return(nil)
99	state.EXPECT().ListApps(mock.Anything).Return([]string{"bad", "good"}, nil).Once()
100	// bad app: Start fails bind verification, binds withdrawn.
101	state.EXPECT().LoadIntent(mock.Anything, "bad").Return(domain.AppStopIntent{App: "bad"}, nil).Once()
102	state.EXPECT().LoadActive(mock.Anything, "bad").Return(badActive, true, nil)
103	state.EXPECT().LoadLatestOperation(mock.Anything, "bad").Return(domain.AppOperation{}, false, nil).Maybe()
104	state.EXPECT().SaveOperation(mock.Anything, mock.Anything).Return(nil)
105	state.EXPECT().SaveIntent(mock.Anything, mock.Anything).Return(nil)
106	state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "bad").Return(nil, nil).Once()
107	runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-bad").Return(true, nil).Once()
108	runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-bad", mock.Anything).Return(nil, assert.AnError).Once()
109	// No traffic boundary is wired here, so the fail-closed bind
110	// withdrawal is a no-op and deployment writes no ACTIVE binds itself.
111	// good app: still verified after the bad one failed.
112	state.EXPECT().LoadIntent(mock.Anything, "good").Return(domain.AppStopIntent{App: "good"}, nil).Once()
113	// Start load + bind-refresh reload.
114	state.EXPECT().LoadActive(mock.Anything, "good").Return(goodActive, true, nil)
115	state.EXPECT().LoadLatestOperation(mock.Anything, "good").Return(domain.AppOperation{}, false, nil).Maybe()
116	state.EXPECT().LoadRecoveryInhibitions(mock.Anything, "good").Return(nil, nil).Once()
117	runtime.EXPECT().IsContainerRunning(mock.Anything, "ctr-good").Return(true, nil).Once()
118	runtime.EXPECT().GetContainerBackendBinds(mock.Anything, "ctr-good", mock.Anything).Return([]domain.ContainerBackendBind{{ContainerPort: 8080, HostPort: 32777, Protocol: domain.NetworkProtocolTCP}}, nil).Once()
119	state.EXPECT().RegisterBackendBinds(mock.Anything, mock.Anything).Return(nil).Once()
120	// Bind refresh persists the new bind (differs from recorded nil).
121	state.EXPECT().SaveActive(mock.Anything, mock.Anything).Return(nil).Once()
122
123	err := svcSvc.ReconcileBoot(ctx)
124	require.Error(t, err)
125	assert.Contains(t, err.Error(), `"bad"`)
126}

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