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}