v3.4.0 593c1c3

secret_files_internal_test.go RawHistoryFolder

1package deployment
2
3import (
4	"context"
5	"errors"
6	"testing"
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	"github.com/bnema/gordon/internal/boundaries/out"
14	outmocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
15	"github.com/bnema/gordon/internal/domain"
16)
17
18const (
19	testFileSecretValue = "file-s3cr3t-value"
20	testSecretFileBase  = "/data/secrets-run"
21)
22
23// fileSecretService returns a service with one env and one file secret,
24// wired to mocks returning "env-only" and testFileSecretValue.
25func fileSecretService(t *testing.T, deps Deps) (*Service, pinnedService) {
26	t.Helper()
27	state := outmocks.NewMockAppState(t)
28	state.EXPECT().LoadOwnership(mock.Anything, "blog").Return(domain.AppOwnership{App: "blog", ID: "app-1"}, nil).Maybe()
29	secrets := outmocks.NewMockSecretProvider(t)
30	secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/a").Return("env-only", nil).Maybe()
31	secrets.EXPECT().GetSecret(mock.Anything, "gordon/apps/app-1/web/b").Return(testFileSecretValue, nil).Maybe()
32	deps.State = state
33	deps.Secrets = secrets
34	p := pinnedService{name: "web", appEnv: map[string]string{"APP_ENV": "production"}, spec: domain.AppService{
35		Name:        "web",
36		Secrets:     map[string]string{"A": "a", "B": "b"},
37		SecretFiles: []string{"B"},
38	}}
39	return NewService(deps, zerowrap.Default()), p
40}
41
42func TestServiceEnv_SplitsFileSecrets(t *testing.T) {
43	svc, p := fileSecretService(t, Deps{})
44
45	env, files, err := svc.serviceEnv(context.Background(), "blog", p)
46	require.NoError(t, err)
47	assert.Equal(t, []string{"A=env-only", "APP_ENV=production", "B_FILE=/run/secrets/B"}, env)
48	assert.Equal(t, map[string]string{"B": testFileSecretValue}, files)
49	for _, entry := range env {
50		assert.NotContains(t, entry, testFileSecretValue)
51	}
52}
53
54func TestServiceEnv_FileKeyOverlapFails(t *testing.T) {
55	svc, p := fileSecretService(t, Deps{})
56	p.appEnv = map[string]string{"B_FILE": "x"}
57
58	_, _, err := svc.serviceEnv(context.Background(), "blog", p)
59	require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
60}
61
62func TestCreateWithSecretFiles_MountsReadOnlyFiles(t *testing.T) {
63	store := outmocks.NewMockSecretFileStore(t)
64	runtime := outmocks.NewMockContainerRuntime(t)
65	svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
66	admin := domain.ContainerBind{Name: "config", Source: "/srv/config", Destination: "/etc/app", ReadOnly: true}
67	config := &domain.ContainerConfig{Name: "gordon-blog--web--abc", Binds: []domain.ContainerBind{admin}}
68
69	store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", map[string]string{"B": testFileSecretValue}).
70		Return(map[string]string{"B": testSecretFileBase + "/blog/gordon-blog--web--abc/B"}, nil).Once()
71	runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(c *domain.ContainerConfig) bool {
72		return assert.ObjectsAreEqual([]domain.ContainerBind{admin, {
73			Name:        "secret-file-B",
74			Source:      testSecretFileBase + "/blog/gordon-blog--web--abc/B",
75			Destination: "/run/secrets/B",
76			ReadOnly:    true,
77		}}, c.Binds)
78	})).Return(&domain.Container{ID: "c-1"}, nil).Once()
79
80	created, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": testFileSecretValue})
81	require.NoError(t, err)
82	assert.Equal(t, "c-1", created.ID)
83	assert.Equal(t, []domain.ContainerBind{admin}, config.Binds, "caller config is not mutated")
84}
85
86func TestCreateWithSecretFiles_CreateFailureRemovesFiles(t *testing.T) {
87	store := outmocks.NewMockSecretFileStore(t)
88	runtime := outmocks.NewMockContainerRuntime(t)
89	svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
90	config := &domain.ContainerConfig{Name: "gordon-blog--web--abc"}
91	path := testSecretFileBase + "/blog/gordon-blog--web--abc/B"
92
93	store.EXPECT().Write(mock.Anything, "blog", "gordon-blog--web--abc", mock.Anything).
94		Return(map[string]string{"B": path}, nil).Once()
95	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).
96		Return(nil, errors.New("create failed: mount "+path+" "+testFileSecretValue)).Once()
97	store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--abc").Return(nil).Once()
98
99	_, err := svc.createWithSecretFiles(context.Background(), "blog", "web", config, map[string]string{"B": testFileSecretValue})
100	require.Error(t, err)
101	assert.NotContains(t, err.Error(), testFileSecretValue)
102	assert.NotContains(t, err.Error(), path)
103}
104
105func TestCreateWithSecretFiles_WriteFailureSkipsCreate(t *testing.T) {
106	store := outmocks.NewMockSecretFileStore(t)
107	runtime := outmocks.NewMockContainerRuntime(t)
108	svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
109
110	store.EXPECT().Write(mock.Anything, "blog", "c", mock.Anything).Return(nil, errors.New("disk full")).Once()
111
112	_, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": testFileSecretValue})
113	require.Error(t, err)
114	assert.NotContains(t, err.Error(), testFileSecretValue)
115	runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
116}
117
118func TestCreateWithSecretFiles_CleanupFailureKeepsCreateError(t *testing.T) {
119	store := outmocks.NewMockSecretFileStore(t)
120	runtime := outmocks.NewMockContainerRuntime(t)
121	svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
122
123	store.EXPECT().Write(mock.Anything, "blog", "c", mock.Anything).
124		Return(map[string]string{"B": testSecretFileBase + "/blog/c/B"}, nil).Once()
125	runtime.EXPECT().CreateContainer(mock.Anything, mock.Anything).Return(nil, errors.New("create failed")).Once()
126	store.EXPECT().Remove(mock.Anything, "blog", "c").Return(errors.New("busy")).Once()
127
128	_, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": "v"})
129	require.Error(t, err)
130	// The create error is returned; the cleanup error is only logged.
131	assert.Contains(t, err.Error(), `"web"`)
132	assert.Contains(t, err.Error(), "runtime error redacted")
133	assert.NotContains(t, err.Error(), "busy")
134}
135
136func TestCreateWithSecretFiles_NilStoreRefuses(t *testing.T) {
137	runtime := outmocks.NewMockContainerRuntime(t)
138	svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
139
140	_, err := svc.createWithSecretFiles(context.Background(), "blog", "web", &domain.ContainerConfig{Name: "c"}, map[string]string{"B": "v"})
141	require.Error(t, err)
142	runtime.AssertNotCalled(t, "CreateContainer", mock.Anything, mock.Anything)
143}
144
145func TestCheckSecrets_FileSecretsNeedStore(t *testing.T) {
146	secrets := outmocks.NewMockSecretProvider(t)
147	svc := NewService(Deps{Secrets: secrets}, zerowrap.Default())
148
149	err := svc.checkSecrets(context.Background(), "blog", "app-1", domain.AppService{
150		Name: "web", Secrets: map[string]string{"B": "b"}, SecretFiles: []string{"B"},
151	})
152	require.Error(t, err)
153	assert.Contains(t, err.Error(), "no secret file store")
154}
155
156func TestEnvCurrent_ComparesSecretFiles(t *testing.T) {
157	running := &domain.Container{
158		Name: "gordon-blog--web--abc",
159		Env:  []string{"APP_ENV=production", "A=env-only", "B_FILE=/run/secrets/B", "PATH=/bin"},
160	}
161	cases := []struct {
162		name string
163		read func(*outmocks.MockSecretFileStore)
164		want bool
165	}{
166		{"same content kept", func(m *outmocks.MockSecretFileStore) {
167			m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return(testFileSecretValue, nil).Once()
168		}, true},
169		{"changed content replaced", func(m *outmocks.MockSecretFileStore) {
170			m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return("old", nil).Once()
171		}, false},
172		{"read error replaced", func(m *outmocks.MockSecretFileStore) {
173			m.EXPECT().Read(mock.Anything, "blog", "gordon-blog--web--abc", "B").Return("", errors.New("gone")).Once()
174		}, false},
175	}
176	for _, tc := range cases {
177		t.Run(tc.name, func(t *testing.T) {
178			store := outmocks.NewMockSecretFileStore(t)
179			tc.read(store)
180			svc, p := fileSecretService(t, Deps{SecretFiles: store})
181			assert.Equal(t, tc.want, svc.envCurrent(context.Background(), "blog", p, running))
182		})
183	}
184
185	t.Run("nil store replaced", func(t *testing.T) {
186		svc, p := fileSecretService(t, Deps{})
187		assert.False(t, svc.envCurrent(context.Background(), "blog", p, running))
188	})
189}
190
191func TestRedactDiagnostics_RedactsFileSecrets(t *testing.T) {
192	svc, p := fileSecretService(t, Deps{})
193
194	got := svc.redactDiagnostics(context.Background(), "blog", p, []string{"loaded " + testFileSecretValue, "ok"})
195	assert.Equal(t, []string{"loaded [redacted]", "ok"}, got)
196}
197
198func TestSweepSecretFiles(t *testing.T) {
199	sets := []out.SecretFileSet{
200		{App: "blog", Container: "gordon-blog--web--a"},
201		{App: "blog", Container: "gordon-blog--web--b"},
202		{App: "other", Container: "gordon-other--x--c"},
203	}
204	live := []*domain.Container{{Name: "gordon-blog--web--b"}, {Name: "unrelated"}}
205
206	t.Run("app scoped", func(t *testing.T) {
207		store := outmocks.NewMockSecretFileStore(t)
208		runtime := outmocks.NewMockContainerRuntime(t)
209		store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
210		runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
211		store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
212
213		svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
214		require.NoError(t, svc.sweepSecretFiles(context.Background(), "blog"))
215	})
216
217	t.Run("all apps", func(t *testing.T) {
218		store := outmocks.NewMockSecretFileStore(t)
219		runtime := outmocks.NewMockContainerRuntime(t)
220		store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
221		runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
222		store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
223		store.EXPECT().Remove(mock.Anything, "other", "gordon-other--x--c").Return(nil).Once()
224
225		svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
226		require.NoError(t, svc.SweepSecretFiles(context.Background()))
227	})
228
229	t.Run("no sets skips runtime", func(t *testing.T) {
230		store := outmocks.NewMockSecretFileStore(t)
231		runtime := outmocks.NewMockContainerRuntime(t)
232		store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
233
234		svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
235		require.NoError(t, svc.sweepSecretFiles(context.Background(), "ghost"))
236		runtime.AssertNotCalled(t, "ListContainers", mock.Anything, mock.Anything)
237	})
238
239	t.Run("nil store", func(t *testing.T) {
240		svc := NewService(Deps{}, zerowrap.Default())
241		require.NoError(t, svc.SweepSecretFiles(context.Background()))
242	})
243
244	t.Run("runtime list error removes nothing", func(t *testing.T) {
245		store := outmocks.NewMockSecretFileStore(t)
246		runtime := outmocks.NewMockContainerRuntime(t)
247		store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
248		runtime.EXPECT().ListContainers(mock.Anything, true).Return(nil, errors.New("daemon down")).Once()
249
250		svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
251		require.Error(t, svc.SweepSecretFiles(context.Background()))
252		store.AssertNotCalled(t, "Remove", mock.Anything, mock.Anything, mock.Anything)
253	})
254
255	t.Run("partial remove failure continues", func(t *testing.T) {
256		store := outmocks.NewMockSecretFileStore(t)
257		runtime := outmocks.NewMockContainerRuntime(t)
258		store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
259		runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
260		store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(errors.New("busy")).Once()
261		store.EXPECT().Remove(mock.Anything, "other", "gordon-other--x--c").Return(nil).Once()
262
263		svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
264		require.Error(t, svc.SweepSecretFiles(context.Background()))
265	})
266}
267
268func TestRetireContainer_SweepsSecretFiles(t *testing.T) {
269	t.Run("gone sweeps", func(t *testing.T) {
270		state := outmocks.NewMockAppState(t)
271		runtime := outmocks.NewMockContainerRuntime(t)
272		store := outmocks.NewMockSecretFileStore(t)
273		runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(nil).Once()
274		state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
275		store.EXPECT().List(mock.Anything).Return([]out.SecretFileSet{{App: "blog", Container: "gordon-blog--web--a"}}, nil).Once()
276		runtime.EXPECT().ListContainers(mock.Anything, true).Return(nil, nil).Once()
277		store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(nil).Once()
278
279		svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
280		retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
281		assert.True(t, retired.Gone)
282		assert.Empty(t, retired.Warnings)
283	})
284
285	t.Run("sweep failure is a fixed warning", func(t *testing.T) {
286		state := outmocks.NewMockAppState(t)
287		runtime := outmocks.NewMockContainerRuntime(t)
288		store := outmocks.NewMockSecretFileStore(t)
289		runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(nil).Once()
290		state.EXPECT().ReleaseBackendBinds(mock.Anything, "blog", "c-1").Return(nil).Once()
291		store.EXPECT().List(mock.Anything).Return(nil, errors.New("open "+testSecretFileBase+": denied")).Once()
292
293		svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
294		retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
295		assert.True(t, retired.Gone)
296		require.Len(t, retired.Warnings, 1)
297		assert.Equal(t, "secret files cleanup failed", retired.Warnings[0].Detail)
298	})
299
300	t.Run("not gone does not sweep", func(t *testing.T) {
301		state := outmocks.NewMockAppState(t)
302		runtime := outmocks.NewMockContainerRuntime(t)
303		store := outmocks.NewMockSecretFileStore(t)
304		runtime.EXPECT().RemoveContainer(mock.Anything, "c-1", true).Return(errors.New("busy")).Once()
305
306		svc := NewService(Deps{State: state, Runtime: runtime, SecretFiles: store}, zerowrap.Default())
307		retired := svc.retireContainer(context.Background(), "blog", retireOptions{Service: "web", Force: true}, "c-1")
308		assert.False(t, retired.Gone)
309		store.AssertNotCalled(t, "List", mock.Anything)
310	})
311}

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