detached 1df31dd

fix(deployment): never replace another container's secret files

commit
1df31ddeeb382f5dd5cb7de3e23f8c1a3bab4db1
author
brice · · 2026-10-08 21:10 UTC
parent
4e3a25d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +79 -19
M internal/adapters/out/secretfiles/store.go +6 -6
@@ -69,7 +69,10 @@ func (s *Store) Dir() string { return s.dir }
6969 // Close releases the base directory handle.
7070 func (s *Store) Close() error { return s.root.Close() }
7171
72-// Write replaces the container's directory with one 0444 file per key.
72+// Write creates the container's directory with one 0444 file per key. The
73+// directory must not exist yet: container names are unique per create, so
74+// an existing directory belongs to another container and is never touched.
75+// Crash leftovers are removed by the sweeps, not here.
7376 func (s *Store) Write(_ context.Context, app, container string, values map[string]string) (map[string]string, error) {
7477 if err := validateSet(app, container); err != nil {
7578 return nil, err
@@ -86,8 +89,8 @@ func (s *Store) Write(_ context.Context, app, container string, values map[strin
8689 return nil, fmt.Errorf("secret files: chmod app directory: %w", err)
8790 }
8891 rel := filepath.Join(app, container)
89- if err := s.root.RemoveAll(rel); err != nil {
90- return nil, fmt.Errorf("secret files: clear container directory: %w", err)
92+ if err := s.root.Mkdir(rel, dirMode); err != nil {
93+ return nil, fmt.Errorf("secret files: create container directory: %w", err)
9194 }
9295 paths, err := s.writeFiles(rel, values)
9396 if err != nil {
@@ -98,9 +101,6 @@ func (s *Store) Write(_ context.Context, app, container string, values map[strin
98101 }
99102
100103 func (s *Store) writeFiles(rel string, values map[string]string) (map[string]string, error) {
101- if err := s.root.Mkdir(rel, dirMode); err != nil {
102- return nil, fmt.Errorf("secret files: create container directory: %w", err)
103- }
104104 if err := s.root.Chmod(rel, dirMode); err != nil {
105105 return nil, fmt.Errorf("secret files: chmod container directory: %w", err)
106106 }
@@ -69,7 +69,10 @@ func (s *Store) Dir() string { return s.dir }
69 // Close releases the base directory handle.69 // Close releases the base directory handle.
70 func (s *Store) Close() error { return s.root.Close() }70 func (s *Store) Close() error { return s.root.Close() }
71 71
72-// Write replaces the container's directory with one 0444 file per key.72+// Write creates the container's directory with one 0444 file per key. The
73+// directory must not exist yet: container names are unique per create, so
74+// an existing directory belongs to another container and is never touched.
75+// Crash leftovers are removed by the sweeps, not here.
73 func (s *Store) Write(_ context.Context, app, container string, values map[string]string) (map[string]string, error) {76 func (s *Store) Write(_ context.Context, app, container string, values map[string]string) (map[string]string, error) {
74 if err := validateSet(app, container); err != nil {77 if err := validateSet(app, container); err != nil {
75 return nil, err78 return nil, err
@@ -86,8 +89,8 @@ func (s *Store) Write(_ context.Context, app, container string, values map[strin
86 return nil, fmt.Errorf("secret files: chmod app directory: %w", err)89 return nil, fmt.Errorf("secret files: chmod app directory: %w", err)
87 }90 }
88 rel := filepath.Join(app, container)91 rel := filepath.Join(app, container)
89- if err := s.root.RemoveAll(rel); err != nil {92+ if err := s.root.Mkdir(rel, dirMode); err != nil {
90- return nil, fmt.Errorf("secret files: clear container directory: %w", err)93+ return nil, fmt.Errorf("secret files: create container directory: %w", err)
91 }94 }
92 paths, err := s.writeFiles(rel, values)95 paths, err := s.writeFiles(rel, values)
93 if err != nil {96 if err != nil {
@@ -98,9 +101,6 @@ func (s *Store) Write(_ context.Context, app, container string, values map[strin
98 }101 }
99 102
100 func (s *Store) writeFiles(rel string, values map[string]string) (map[string]string, error) {103 func (s *Store) writeFiles(rel string, values map[string]string) (map[string]string, error) {
101- if err := s.root.Mkdir(rel, dirMode); err != nil {
102- return nil, fmt.Errorf("secret files: create container directory: %w", err)
103- }
104 if err := s.root.Chmod(rel, dirMode); err != nil {104 if err := s.root.Chmod(rel, dirMode); err != nil {
105 return nil, fmt.Errorf("secret files: chmod container directory: %w", err)105 return nil, fmt.Errorf("secret files: chmod container directory: %w", err)
106 }106 }
M internal/adapters/out/secretfiles/store_test.go +7 -7
@@ -49,19 +49,19 @@ func TestStore_WriteModesAndRead(t *testing.T) {
4949 assert.Equal(t, "s3cr3t", got)
5050 }
5151
52-func TestStore_WriteReplacesStaleKeys(t *testing.T) {
52+func TestStore_WriteNeverReplacesExistingContainer(t *testing.T) {
5353 store, _ := newStore(t)
5454 ctx := context.Background()
55- _, err := store.Write(ctx, "blog", "c1", map[string]string{"OLD": "x"})
56- require.NoError(t, err)
57- _, err = store.Write(ctx, "blog", "c1", map[string]string{"NEW": "y"})
55+ _, err := store.Write(ctx, "blog", "c1", map[string]string{"K": "live"})
5856 require.NoError(t, err)
5957
60- _, err = store.Read(ctx, "blog", "c1", "OLD")
58+ _, err = store.Write(ctx, "blog", "c1", map[string]string{"K": "other"})
6159 require.Error(t, err)
62- got, err := store.Read(ctx, "blog", "c1", "NEW")
60+ assert.NotContains(t, err.Error(), store.Dir())
61+
62+ got, err := store.Read(ctx, "blog", "c1", "K")
6363 require.NoError(t, err)
64- assert.Equal(t, "y", got)
64+ assert.Equal(t, "live", got, "a live container's files stay intact")
6565 }
6666
6767 func TestStore_RemoveAndList(t *testing.T) {
@@ -49,19 +49,19 @@ func TestStore_WriteModesAndRead(t *testing.T) {
49 assert.Equal(t, "s3cr3t", got)49 assert.Equal(t, "s3cr3t", got)
50 }50 }
51 51
52-func TestStore_WriteReplacesStaleKeys(t *testing.T) {52+func TestStore_WriteNeverReplacesExistingContainer(t *testing.T) {
53 store, _ := newStore(t)53 store, _ := newStore(t)
54 ctx := context.Background()54 ctx := context.Background()
55- _, err := store.Write(ctx, "blog", "c1", map[string]string{"OLD": "x"})55+ _, err := store.Write(ctx, "blog", "c1", map[string]string{"K": "live"})
56- require.NoError(t, err)
57- _, err = store.Write(ctx, "blog", "c1", map[string]string{"NEW": "y"})
58 require.NoError(t, err)56 require.NoError(t, err)
59 57
60- _, err = store.Read(ctx, "blog", "c1", "OLD")58+ _, err = store.Write(ctx, "blog", "c1", map[string]string{"K": "other"})
61 require.Error(t, err)59 require.Error(t, err)
62- got, err := store.Read(ctx, "blog", "c1", "NEW")60+ assert.NotContains(t, err.Error(), store.Dir())
61+
62+ got, err := store.Read(ctx, "blog", "c1", "K")
63 require.NoError(t, err)63 require.NoError(t, err)
64- assert.Equal(t, "y", got)64+ assert.Equal(t, "live", got, "a live container's files stay intact")
65 }65 }
66 66
67 func TestStore_RemoveAndList(t *testing.T) {67 func TestStore_RemoveAndList(t *testing.T) {
M internal/boundaries/out/secret_files.go +3 -2
@@ -8,8 +8,9 @@ type SecretFileSet struct{ App, Container string }
88 // SecretFileStore persists per-container secret files on the host. Values
99 // cross this boundary in memory only; nothing is logged.
1010 type SecretFileStore interface {
11- // Write replaces the container's directory with one 0444 file per key
12- // and returns key -> absolute host path.
11+ // Write creates the container's directory with one 0444 file per key
12+ // and returns key -> absolute host path. It fails when the directory
13+ // already exists, so another container's files are never replaced.
1314 Write(ctx context.Context, app, container string, values map[string]string) (map[string]string, error)
1415 // Read returns one file's content.
1516 Read(ctx context.Context, app, container, key string) (string, error)
@@ -8,8 +8,9 @@ type SecretFileSet struct{ App, Container string }
8 // SecretFileStore persists per-container secret files on the host. Values8 // SecretFileStore persists per-container secret files on the host. Values
9 // cross this boundary in memory only; nothing is logged.9 // cross this boundary in memory only; nothing is logged.
10 type SecretFileStore interface {10 type SecretFileStore interface {
11- // Write replaces the container's directory with one 0444 file per key11+ // Write creates the container's directory with one 0444 file per key
12- // and returns key -> absolute host path.12+ // and returns key -> absolute host path. It fails when the directory
13+ // already exists, so another container's files are never replaced.
13 Write(ctx context.Context, app, container string, values map[string]string) (map[string]string, error)14 Write(ctx context.Context, app, container string, values map[string]string) (map[string]string, error)
14 // Read returns one file's content.15 // Read returns one file's content.
15 Read(ctx context.Context, app, container, key string) (string, error)16 Read(ctx context.Context, app, container, key string) (string, error)
M internal/domain/app_secret_files.go +5 -3
@@ -22,7 +22,12 @@ func (s AppService) SecretAsFile(key string) bool {
2222 }
2323
2424 // validateSecretFiles checks the file-delivered secret keys of one service.
25+// <KEY>_FILE collisions with public env are checked by
26+// checkEnvSecretKeyDisjoint, which sees both app and service env.
2527 func (s *AppService) validateSecretFiles() error {
28+ if !slices.IsSorted(s.SecretFiles) {
29+ return fmt.Errorf("%w: service %q secret file keys must be sorted", ErrInvalidAppSpec, s.Name)
30+ }
2631 seen := make(map[string]struct{}, len(s.SecretFiles))
2732 for _, key := range s.SecretFiles {
2833 if _, ok := s.Secrets[key]; !ok {
@@ -36,9 +41,6 @@ func (s *AppService) validateSecretFiles() error {
3641 if _, ok := s.Secrets[fileKey]; ok {
3742 return fmt.Errorf("%w: service %q secret key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey)
3843 }
39- if _, ok := s.Env[fileKey]; ok {
40- return fmt.Errorf("%w: service %q env key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey)
41- }
4244 }
4345 if len(s.SecretFiles) == 0 {
4446 return nil
@@ -22,7 +22,12 @@ func (s AppService) SecretAsFile(key string) bool {
22 }22 }
23 23
24 // validateSecretFiles checks the file-delivered secret keys of one service.24 // validateSecretFiles checks the file-delivered secret keys of one service.
25+// <KEY>_FILE collisions with public env are checked by
26+// checkEnvSecretKeyDisjoint, which sees both app and service env.
25 func (s *AppService) validateSecretFiles() error {27 func (s *AppService) validateSecretFiles() error {
28+ if !slices.IsSorted(s.SecretFiles) {
29+ return fmt.Errorf("%w: service %q secret file keys must be sorted", ErrInvalidAppSpec, s.Name)
30+ }
26 seen := make(map[string]struct{}, len(s.SecretFiles))31 seen := make(map[string]struct{}, len(s.SecretFiles))
27 for _, key := range s.SecretFiles {32 for _, key := range s.SecretFiles {
28 if _, ok := s.Secrets[key]; !ok {33 if _, ok := s.Secrets[key]; !ok {
@@ -36,9 +41,6 @@ func (s *AppService) validateSecretFiles() error {
36 if _, ok := s.Secrets[fileKey]; ok {41 if _, ok := s.Secrets[fileKey]; ok {
37 return fmt.Errorf("%w: service %q secret key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey)42 return fmt.Errorf("%w: service %q secret key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey)
38 }43 }
39- if _, ok := s.Env[fileKey]; ok {
40- return fmt.Errorf("%w: service %q env key %q collides with a secret file key", ErrInvalidAppSpec, s.Name, fileKey)
41- }
42 }44 }
43 if len(s.SecretFiles) == 0 {45 if len(s.SecretFiles) == 0 {
44 return nil46 return nil
M internal/domain/app_secret_files_test.go +4 -0
@@ -30,6 +30,10 @@ func TestAppSpec_ValidateSecretFilesErrors(t *testing.T) {
3030 wantErr string
3131 }{
3232 {"not a secret", func(s *domain.AppSpec) { s.Services[0].SecretFiles = []string{"OTHER"} }, "not a declared secret"},
33+ {"unsorted", func(s *domain.AppSpec) {
34+ s.Services[0].Secrets["API"] = "api"
35+ s.Services[0].SecretFiles = []string{"DATABASE_URL", "API"}
36+ }, "must be sorted"},
3337 {"duplicate", func(s *domain.AppSpec) {
3438 s.Services[0].SecretFiles = []string{"DATABASE_URL", "DATABASE_URL"}
3539 }, "duplicated"},
@@ -30,6 +30,10 @@ func TestAppSpec_ValidateSecretFilesErrors(t *testing.T) {
30 wantErr string30 wantErr string
31 }{31 }{
32 {"not a secret", func(s *domain.AppSpec) { s.Services[0].SecretFiles = []string{"OTHER"} }, "not a declared secret"},32 {"not a secret", func(s *domain.AppSpec) { s.Services[0].SecretFiles = []string{"OTHER"} }, "not a declared secret"},
33+ {"unsorted", func(s *domain.AppSpec) {
34+ s.Services[0].Secrets["API"] = "api"
35+ s.Services[0].SecretFiles = []string{"DATABASE_URL", "API"}
36+ }, "must be sorted"},
33 {"duplicate", func(s *domain.AppSpec) {37 {"duplicate", func(s *domain.AppSpec) {
34 s.Services[0].SecretFiles = []string{"DATABASE_URL", "DATABASE_URL"}38 s.Services[0].SecretFiles = []string{"DATABASE_URL", "DATABASE_URL"}
35 }, "duplicated"},39 }, "duplicated"},
M internal/usecase/deployment/secret_files.go +3 -1
@@ -4,6 +4,8 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+
8+ "github.com/bnema/gordon/internal/boundaries/out"
79 )
810
911 // sweepAppSecretFiles removes the secret file directories of app whose
@@ -33,7 +35,7 @@ func (s *Service) sweepSecretFiles(ctx context.Context, filter string) error {
3335 return fmt.Errorf("deployment: list secret files: %w", err)
3436 }
3537 if filter != "" {
36- kept := sets[:0]
38+ kept := make([]out.SecretFileSet, 0, len(sets))
3739 for _, set := range sets {
3840 if set.App == filter {
3941 kept = append(kept, set)
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+
8+ "github.com/bnema/gordon/internal/boundaries/out"
7 )9 )
8 10
9 // sweepAppSecretFiles removes the secret file directories of app whose11 // sweepAppSecretFiles removes the secret file directories of app whose
@@ -33,7 +35,7 @@ func (s *Service) sweepSecretFiles(ctx context.Context, filter string) error {
33 return fmt.Errorf("deployment: list secret files: %w", err)35 return fmt.Errorf("deployment: list secret files: %w", err)
34 }36 }
35 if filter != "" {37 if filter != "" {
36- kept := sets[:0]38+ kept := make([]out.SecretFileSet, 0, len(sets))
37 for _, set := range sets {39 for _, set := range sets {
38 if set.App == filter {40 if set.App == filter {
39 kept = append(kept, set)41 kept = append(kept, set)
M internal/usecase/deployment/secret_files_internal_test.go +51 -0
@@ -102,6 +102,34 @@ func TestCreateWithSecretFiles_CreateFailureRemovesFiles(t *testing.T) {
102102 assert.NotContains(t, err.Error(), path)
103103 }
104104
105+func 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+
118+func 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+ assert.Contains(t, err.Error(), "runtime error redacted")
131+}
132+
105133 func TestCreateWithSecretFiles_NilStoreRefuses(t *testing.T) {
106134 runtime := outmocks.NewMockContainerRuntime(t)
107135 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
@@ -209,6 +237,29 @@ func TestSweepSecretFiles(t *testing.T) {
209237 svc := NewService(Deps{}, zerowrap.Default())
210238 require.NoError(t, svc.SweepSecretFiles(context.Background()))
211239 })
240+
241+ t.Run("runtime list error removes nothing", func(t *testing.T) {
242+ store := outmocks.NewMockSecretFileStore(t)
243+ runtime := outmocks.NewMockContainerRuntime(t)
244+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
245+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(nil, errors.New("daemon down")).Once()
246+
247+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
248+ require.Error(t, svc.SweepSecretFiles(context.Background()))
249+ store.AssertNotCalled(t, "Remove", mock.Anything, mock.Anything, mock.Anything)
250+ })
251+
252+ t.Run("partial remove failure continues", func(t *testing.T) {
253+ store := outmocks.NewMockSecretFileStore(t)
254+ runtime := outmocks.NewMockContainerRuntime(t)
255+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
256+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
257+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(errors.New("busy")).Once()
258+ store.EXPECT().Remove(mock.Anything, "other", "gordon-other--x--c").Return(nil).Once()
259+
260+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
261+ require.Error(t, svc.SweepSecretFiles(context.Background()))
262+ })
212263 }
213264
214265 func TestRetireContainer_SweepsSecretFiles(t *testing.T) {
@@ -102,6 +102,34 @@ func TestCreateWithSecretFiles_CreateFailureRemovesFiles(t *testing.T) {
102 assert.NotContains(t, err.Error(), path)102 assert.NotContains(t, err.Error(), path)
103 }103 }
104 104
105+func 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+
118+func 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+ assert.Contains(t, err.Error(), "runtime error redacted")
131+}
132+
105 func TestCreateWithSecretFiles_NilStoreRefuses(t *testing.T) {133 func TestCreateWithSecretFiles_NilStoreRefuses(t *testing.T) {
106 runtime := outmocks.NewMockContainerRuntime(t)134 runtime := outmocks.NewMockContainerRuntime(t)
107 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())135 svc := NewService(Deps{Runtime: runtime}, zerowrap.Default())
@@ -209,6 +237,29 @@ func TestSweepSecretFiles(t *testing.T) {
209 svc := NewService(Deps{}, zerowrap.Default())237 svc := NewService(Deps{}, zerowrap.Default())
210 require.NoError(t, svc.SweepSecretFiles(context.Background()))238 require.NoError(t, svc.SweepSecretFiles(context.Background()))
211 })239 })
240+
241+ t.Run("runtime list error removes nothing", func(t *testing.T) {
242+ store := outmocks.NewMockSecretFileStore(t)
243+ runtime := outmocks.NewMockContainerRuntime(t)
244+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
245+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(nil, errors.New("daemon down")).Once()
246+
247+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
248+ require.Error(t, svc.SweepSecretFiles(context.Background()))
249+ store.AssertNotCalled(t, "Remove", mock.Anything, mock.Anything, mock.Anything)
250+ })
251+
252+ t.Run("partial remove failure continues", func(t *testing.T) {
253+ store := outmocks.NewMockSecretFileStore(t)
254+ runtime := outmocks.NewMockContainerRuntime(t)
255+ store.EXPECT().List(mock.Anything).Return(sets, nil).Once()
256+ runtime.EXPECT().ListContainers(mock.Anything, true).Return(live, nil).Once()
257+ store.EXPECT().Remove(mock.Anything, "blog", "gordon-blog--web--a").Return(errors.New("busy")).Once()
258+ store.EXPECT().Remove(mock.Anything, "other", "gordon-other--x--c").Return(nil).Once()
259+
260+ svc := NewService(Deps{Runtime: runtime, SecretFiles: store}, zerowrap.Default())
261+ require.Error(t, svc.SweepSecretFiles(context.Background()))
262+ })
212 }263 }
213 264
214 func TestRetireContainer_SweepsSecretFiles(t *testing.T) {265 func TestRetireContainer_SweepsSecretFiles(t *testing.T) {

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