detached 800ace0

fix(cli): fallback to local signals when remote admin is unavailable

commit
800ace03777cc836a1a522089aa457ee1401fe08
author
brice · · 2026-02-06 17:58 UTC
committer
brice · · 2026-02-06 19:34 UTC
parent
cdca39f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +242 -2
M internal/adapters/in/cli/deploy.go +8 -0
@@ -35,6 +35,14 @@ Examples:
3535 // Remote deployment via admin API
3636 result, err := client.Deploy(ctx, deployDomain)
3737 if err != nil {
38+ if shouldFallbackToLocal(err) {
39+ domain, localErr := app.SendDeploySignal(deployDomain)
40+ if localErr == nil {
41+ fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err)))
42+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))
43+ return nil
44+ }
45+ }
3846 return fmt.Errorf("failed to deploy: %w", err)
3947 }
4048 containerID := result.ContainerID
@@ -35,6 +35,14 @@ Examples:
35 // Remote deployment via admin API35 // Remote deployment via admin API
36 result, err := client.Deploy(ctx, deployDomain)36 result, err := client.Deploy(ctx, deployDomain)
37 if err != nil {37 if err != nil {
38+ if shouldFallbackToLocal(err) {
39+ domain, localErr := app.SendDeploySignal(deployDomain)
40+ if localErr == nil {
41+ fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote deploy failed (%v), used local signal fallback", err)))
42+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Deploy signal sent for domain: %s", domain)))
43+ return nil
44+ }
45+ }
38 return fmt.Errorf("failed to deploy: %w", err)46 return fmt.Errorf("failed to deploy: %w", err)
39 }47 }
40 containerID := result.ContainerID48 containerID := result.ContainerID
A internal/adapters/in/cli/fallback.go +36 -0
@@ -0,0 +1,36 @@
1+package cli
2+
3+import "strings"
4+
5+func shouldFallbackToLocal(err error) bool {
6+ if err == nil {
7+ return false
8+ }
9+ msg := strings.ToLower(err.Error())
10+
11+ // Retry local signal mode when remote API is unavailable or misrouted.
12+ if strings.Contains(msg, "connection refused") ||
13+ strings.Contains(msg, "no such host") ||
14+ strings.Contains(msg, "i/o timeout") ||
15+ strings.Contains(msg, "timeout") {
16+ return true
17+ }
18+
19+ if strings.Contains(msg, "500") ||
20+ strings.Contains(msg, "501") ||
21+ strings.Contains(msg, "502") ||
22+ strings.Contains(msg, "503") ||
23+ strings.Contains(msg, "504") ||
24+ strings.Contains(msg, "505") {
25+ return true
26+ }
27+
28+ if strings.Contains(msg, "internal server error") ||
29+ strings.Contains(msg, "service unavailable") ||
30+ strings.Contains(msg, "bad gateway") ||
31+ strings.Contains(msg, "gateway timeout") {
32+ return true
33+ }
34+
35+ return false
36+}
@@ -0,0 +1,36 @@
1+package cli
2+
3+import "strings"
4+
5+func shouldFallbackToLocal(err error) bool {
6+ if err == nil {
7+ return false
8+ }
9+ msg := strings.ToLower(err.Error())
10+
11+ // Retry local signal mode when remote API is unavailable or misrouted.
12+ if strings.Contains(msg, "connection refused") ||
13+ strings.Contains(msg, "no such host") ||
14+ strings.Contains(msg, "i/o timeout") ||
15+ strings.Contains(msg, "timeout") {
16+ return true
17+ }
18+
19+ if strings.Contains(msg, "500") ||
20+ strings.Contains(msg, "501") ||
21+ strings.Contains(msg, "502") ||
22+ strings.Contains(msg, "503") ||
23+ strings.Contains(msg, "504") ||
24+ strings.Contains(msg, "505") {
25+ return true
26+ }
27+
28+ if strings.Contains(msg, "internal server error") ||
29+ strings.Contains(msg, "service unavailable") ||
30+ strings.Contains(msg, "bad gateway") ||
31+ strings.Contains(msg, "gateway timeout") {
32+ return true
33+ }
34+
35+ return false
36+}
A internal/adapters/in/cli/fallback_test.go +30 -0
@@ -0,0 +1,30 @@
1+package cli
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestShouldFallbackToLocal(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ err error
14+ want bool
15+ }{
16+ {name: "nil", err: nil, want: false},
17+ {name: "connection refused", err: errors.New(`Post "https://reg.example.com/admin/restart": dial tcp 127.0.0.1:443: connect: connection refused`), want: true},
18+ {name: "status 503", err: errors.New("503 Service Unavailable: Registry Unavailable"), want: true},
19+ {name: "status 500", err: errors.New("500 Internal Server Error: failed to deploy container"), want: true},
20+ {name: "timeout", err: errors.New("request timeout"), want: true},
21+ {name: "auth error", err: errors.New("401 Unauthorized: invalid token"), want: false},
22+ {name: "bad request", err: errors.New("400 Bad Request: invalid domain"), want: false},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ assert.Equal(t, tt.want, shouldFallbackToLocal(tt.err))
28+ })
29+ }
30+}
@@ -0,0 +1,30 @@
1+package cli
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestShouldFallbackToLocal(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ err error
14+ want bool
15+ }{
16+ {name: "nil", err: nil, want: false},
17+ {name: "connection refused", err: errors.New(`Post "https://reg.example.com/admin/restart": dial tcp 127.0.0.1:443: connect: connection refused`), want: true},
18+ {name: "status 503", err: errors.New("503 Service Unavailable: Registry Unavailable"), want: true},
19+ {name: "status 500", err: errors.New("500 Internal Server Error: failed to deploy container"), want: true},
20+ {name: "timeout", err: errors.New("request timeout"), want: true},
21+ {name: "auth error", err: errors.New("401 Unauthorized: invalid token"), want: false},
22+ {name: "bad request", err: errors.New("400 Bad Request: invalid domain"), want: false},
23+ }
24+
25+ for _, tt := range tests {
26+ t.Run(tt.name, func(t *testing.T) {
27+ assert.Equal(t, tt.want, shouldFallbackToLocal(tt.err))
28+ })
29+ }
30+}
M internal/adapters/in/cli/local.go +32 -2
@@ -5,6 +5,7 @@ import (
55 "context"
66 "fmt"
77 "path/filepath"
8+ "strings"
89
910 "github.com/bnema/zerowrap"
1011 "github.com/spf13/viper"
@@ -12,6 +13,8 @@ import (
1213 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
1314 "github.com/bnema/gordon/internal/app"
1415 "github.com/bnema/gordon/internal/boundaries/in"
16+ "github.com/bnema/gordon/internal/boundaries/out"
17+ "github.com/bnema/gordon/internal/domain"
1518 "github.com/bnema/gordon/internal/usecase/config"
1619 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
1720 )
@@ -82,8 +85,8 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
8285 envDir = filepath.Join(dataDir, "env")
8386 }
8487
85- // Create domain secret store and service
86- domainSecretStore, err := domainsecrets.NewFileStore(envDir, log)
88+ // Create domain secret store using configured backend (pass or file-based)
89+ domainSecretStore, err := createLocalDomainSecretStore(v, envDir, log)
8790 if err != nil {
8891 return nil, fmt.Errorf("failed to create domain secret store: %w", err)
8992 }
@@ -95,3 +98,30 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
9598 dataDir: dataDir,
9699 }, nil
97100 }
101+
102+func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) {
103+ backend := resolveLocalSecretsBackend(v)
104+ if backend == domain.SecretsBackendPass {
105+ return domainsecrets.NewPassStore(log)
106+ }
107+ return domainsecrets.NewFileStore(envDir, log)
108+}
109+
110+func resolveLocalSecretsBackend(v *viper.Viper) domain.SecretsBackend {
111+ backend := strings.TrimSpace(v.GetString("auth.secrets_backend"))
112+ if backend == "" {
113+ // Legacy key support for older configs.
114+ backend = strings.TrimSpace(v.GetString("secrets.backend"))
115+ }
116+
117+ switch backend {
118+ case "pass":
119+ return domain.SecretsBackendPass
120+ case "sops":
121+ return domain.SecretsBackendSops
122+ case "unsafe", "":
123+ return domain.SecretsBackendUnsafe
124+ default:
125+ return domain.SecretsBackendUnsafe
126+ }
127+}
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7 "path/filepath"7 "path/filepath"
8+ "strings"
8 9
9 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
10 "github.com/spf13/viper"11 "github.com/spf13/viper"
@@ -12,6 +13,8 @@ import (
12 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"13 "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
13 "github.com/bnema/gordon/internal/app"14 "github.com/bnema/gordon/internal/app"
14 "github.com/bnema/gordon/internal/boundaries/in"15 "github.com/bnema/gordon/internal/boundaries/in"
16+ "github.com/bnema/gordon/internal/boundaries/out"
17+ "github.com/bnema/gordon/internal/domain"
15 "github.com/bnema/gordon/internal/usecase/config"18 "github.com/bnema/gordon/internal/usecase/config"
16 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"19 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
17 )20 )
@@ -82,8 +85,8 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
82 envDir = filepath.Join(dataDir, "env")85 envDir = filepath.Join(dataDir, "env")
83 }86 }
84 87
85- // Create domain secret store and service88+ // Create domain secret store using configured backend (pass or file-based)
86- domainSecretStore, err := domainsecrets.NewFileStore(envDir, log)89+ domainSecretStore, err := createLocalDomainSecretStore(v, envDir, log)
87 if err != nil {90 if err != nil {
88 return nil, fmt.Errorf("failed to create domain secret store: %w", err)91 return nil, fmt.Errorf("failed to create domain secret store: %w", err)
89 }92 }
@@ -95,3 +98,30 @@ func GetLocalServices(configPath string) (*LocalServices, error) {
95 dataDir: dataDir,98 dataDir: dataDir,
96 }, nil99 }, nil
97 }100 }
101+
102+func createLocalDomainSecretStore(v *viper.Viper, envDir string, log zerowrap.Logger) (out.DomainSecretStore, error) {
103+ backend := resolveLocalSecretsBackend(v)
104+ if backend == domain.SecretsBackendPass {
105+ return domainsecrets.NewPassStore(log)
106+ }
107+ return domainsecrets.NewFileStore(envDir, log)
108+}
109+
110+func resolveLocalSecretsBackend(v *viper.Viper) domain.SecretsBackend {
111+ backend := strings.TrimSpace(v.GetString("auth.secrets_backend"))
112+ if backend == "" {
113+ // Legacy key support for older configs.
114+ backend = strings.TrimSpace(v.GetString("secrets.backend"))
115+ }
116+
117+ switch backend {
118+ case "pass":
119+ return domain.SecretsBackendPass
120+ case "sops":
121+ return domain.SecretsBackendSops
122+ case "unsafe", "":
123+ return domain.SecretsBackendUnsafe
124+ default:
125+ return domain.SecretsBackendUnsafe
126+ }
127+}
A internal/adapters/in/cli/local_test.go +49 -0
@@ -0,0 +1,49 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zerowrap"
7+ "github.com/spf13/viper"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func TestResolveLocalSecretsBackend(t *testing.T) {
15+ t.Run("auth backend takes precedence", func(t *testing.T) {
16+ v := viper.New()
17+ v.Set("auth.secrets_backend", "pass")
18+ v.Set("secrets.backend", "unsafe")
19+
20+ got := resolveLocalSecretsBackend(v)
21+ require.Equal(t, domain.SecretsBackendPass, got)
22+ })
23+
24+ t.Run("falls back to legacy secrets backend key", func(t *testing.T) {
25+ v := viper.New()
26+ v.Set("secrets.backend", "pass")
27+
28+ got := resolveLocalSecretsBackend(v)
29+ require.Equal(t, domain.SecretsBackendPass, got)
30+ })
31+
32+ t.Run("defaults to unsafe for unknown backend", func(t *testing.T) {
33+ v := viper.New()
34+ v.Set("auth.secrets_backend", "wat")
35+
36+ got := resolveLocalSecretsBackend(v)
37+ require.Equal(t, domain.SecretsBackendUnsafe, got)
38+ })
39+}
40+
41+func TestCreateLocalDomainSecretStore_UsesFileStoreForUnsafe(t *testing.T) {
42+ v := viper.New()
43+ v.Set("auth.secrets_backend", "unsafe")
44+
45+ log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})
46+ store, err := createLocalDomainSecretStore(v, t.TempDir(), log)
47+ require.NoError(t, err)
48+ require.IsType(t, &domainsecrets.FileStore{}, store)
49+}
@@ -0,0 +1,49 @@
1+package cli
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zerowrap"
7+ "github.com/spf13/viper"
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+func TestResolveLocalSecretsBackend(t *testing.T) {
15+ t.Run("auth backend takes precedence", func(t *testing.T) {
16+ v := viper.New()
17+ v.Set("auth.secrets_backend", "pass")
18+ v.Set("secrets.backend", "unsafe")
19+
20+ got := resolveLocalSecretsBackend(v)
21+ require.Equal(t, domain.SecretsBackendPass, got)
22+ })
23+
24+ t.Run("falls back to legacy secrets backend key", func(t *testing.T) {
25+ v := viper.New()
26+ v.Set("secrets.backend", "pass")
27+
28+ got := resolveLocalSecretsBackend(v)
29+ require.Equal(t, domain.SecretsBackendPass, got)
30+ })
31+
32+ t.Run("defaults to unsafe for unknown backend", func(t *testing.T) {
33+ v := viper.New()
34+ v.Set("auth.secrets_backend", "wat")
35+
36+ got := resolveLocalSecretsBackend(v)
37+ require.Equal(t, domain.SecretsBackendUnsafe, got)
38+ })
39+}
40+
41+func TestCreateLocalDomainSecretStore_UsesFileStoreForUnsafe(t *testing.T) {
42+ v := viper.New()
43+ v.Set("auth.secrets_backend", "unsafe")
44+
45+ log := zerowrap.New(zerowrap.Config{Level: "error", Format: "console"})
46+ store, err := createLocalDomainSecretStore(v, t.TempDir(), log)
47+ require.NoError(t, err)
48+ require.IsType(t, &domainsecrets.FileStore{}, store)
49+}
M internal/adapters/in/cli/restart.go +8 -0
@@ -43,6 +43,14 @@ Examples:
4343
4444 result, err := client.Restart(ctx, restartDomain, withAttachments)
4545 if err != nil {
46+ if !withAttachments && shouldFallbackToLocal(err) {
47+ domain, localErr := app.SendDeploySignal(restartDomain)
48+ if localErr == nil {
49+ fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err)))
50+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain)))
51+ return nil
52+ }
53+ }
4654 return fmt.Errorf("failed to restart: %w", err)
4755 }
4856 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain)))
@@ -43,6 +43,14 @@ Examples:
43 43
44 result, err := client.Restart(ctx, restartDomain, withAttachments)44 result, err := client.Restart(ctx, restartDomain, withAttachments)
45 if err != nil {45 if err != nil {
46+ if !withAttachments && shouldFallbackToLocal(err) {
47+ domain, localErr := app.SendDeploySignal(restartDomain)
48+ if localErr == nil {
49+ fmt.Println(styles.RenderWarning(fmt.Sprintf("Remote restart failed (%v), used local deploy-signal fallback", err)))
50+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restart signal sent for %s (local deploy path)", domain)))
51+ return nil
52+ }
53+ }
46 return fmt.Errorf("failed to restart: %w", err)54 return fmt.Errorf("failed to restart: %w", err)
47 }55 }
48 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain)))56 fmt.Println(styles.RenderSuccess(fmt.Sprintf("Restarted %s", result.Domain)))
M internal/adapters/in/cli/root.go +7 -0
@@ -190,6 +190,13 @@ func runReload() error {
190190 // runReloadRemote triggers a reload on a remote Gordon instance.
191191 func runReloadRemote(ctx context.Context, client *remote.Client) error {
192192 if err := client.Reload(ctx); err != nil {
193+ if shouldFallbackToLocal(err) {
194+ if localErr := runReload(); localErr == nil {
195+ fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err)
196+ fmt.Println("Configuration reloaded successfully")
197+ return nil
198+ }
199+ }
193200 return fmt.Errorf("failed to reload: %w", err)
194201 }
195202 fmt.Println("Configuration reloaded successfully")
@@ -190,6 +190,13 @@ func runReload() error {
190 // runReloadRemote triggers a reload on a remote Gordon instance.190 // runReloadRemote triggers a reload on a remote Gordon instance.
191 func runReloadRemote(ctx context.Context, client *remote.Client) error {191 func runReloadRemote(ctx context.Context, client *remote.Client) error {
192 if err := client.Reload(ctx); err != nil {192 if err := client.Reload(ctx); err != nil {
193+ if shouldFallbackToLocal(err) {
194+ if localErr := runReload(); localErr == nil {
195+ fmt.Printf("Remote reload failed (%v), used local signal fallback\n", err)
196+ fmt.Println("Configuration reloaded successfully")
197+ return nil
198+ }
199+ }
193 return fmt.Errorf("failed to reload: %w", err)200 return fmt.Errorf("failed to reload: %w", err)
194 }201 }
195 fmt.Println("Configuration reloaded successfully")202 fmt.Println("Configuration reloaded successfully")
M internal/adapters/out/domainsecrets/pass_store_test.go +72 -0
@@ -177,6 +177,78 @@ func TestPassStore_DeleteAttachment(t *testing.T) {
177177 assert.False(t, exists)
178178 }
179179
180+func TestPassStore_Delete_Idempotent(t *testing.T) {
181+ requirePass(t)
182+
183+ store, err := NewPassStore(testLogger())
184+ require.NoError(t, err)
185+
186+ domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano())
187+ keys := []string{"API_KEY"}
188+ defer cleanupPassDomain(t, domainName, keys)
189+
190+ err = store.Set(domainName, map[string]string{"API_KEY": "alpha"})
191+ require.NoError(t, err)
192+
193+ err = store.Delete(domainName, "API_KEY")
194+ require.NoError(t, err)
195+
196+ // Second delete of an already-removed key must be a no-op.
197+ err = store.Delete(domainName, "API_KEY")
198+ require.NoError(t, err)
199+
200+ values, err := store.GetAll(domainName)
201+ require.NoError(t, err)
202+ assert.NotContains(t, values, "API_KEY")
203+}
204+
205+func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) {
206+ requirePass(t)
207+
208+ store, err := NewPassStore(testLogger())
209+ require.NoError(t, err)
210+
211+ containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano())
212+ keys := []string{"POSTGRES_PASSWORD"}
213+ defer CleanupPassAttachment(t, containerName, keys)
214+
215+ err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"})
216+ require.NoError(t, err)
217+
218+ err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
219+ require.NoError(t, err)
220+
221+ // Second delete of an already-removed key must be a no-op.
222+ err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
223+ require.NoError(t, err)
224+
225+ values, err := store.GetAllAttachment(containerName)
226+ require.NoError(t, err)
227+ assert.NotContains(t, values, "POSTGRES_PASSWORD")
228+}
229+
230+func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) {
231+ requirePass(t)
232+
233+ store, err := NewPassStore(testLogger())
234+ require.NoError(t, err)
235+
236+ containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano())
237+ keys := []string{"REDIS_PASSWORD"}
238+ defer CleanupPassAttachment(t, containerName, keys)
239+
240+ err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"})
241+ require.NoError(t, err)
242+
243+ // Re-setting same key with a different value should deterministically overwrite.
244+ err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"})
245+ require.NoError(t, err)
246+
247+ values, err := store.GetAllAttachment(containerName)
248+ require.NoError(t, err)
249+ assert.Equal(t, "second", values["REDIS_PASSWORD"])
250+}
251+
180252 func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) {
181253 requirePass(t)
182254
@@ -177,6 +177,78 @@ func TestPassStore_DeleteAttachment(t *testing.T) {
177 assert.False(t, exists)177 assert.False(t, exists)
178 }178 }
179 179
180+func TestPassStore_Delete_Idempotent(t *testing.T) {
181+ requirePass(t)
182+
183+ store, err := NewPassStore(testLogger())
184+ require.NoError(t, err)
185+
186+ domainName := fmt.Sprintf("idempotent.%d.example.com", time.Now().UnixNano())
187+ keys := []string{"API_KEY"}
188+ defer cleanupPassDomain(t, domainName, keys)
189+
190+ err = store.Set(domainName, map[string]string{"API_KEY": "alpha"})
191+ require.NoError(t, err)
192+
193+ err = store.Delete(domainName, "API_KEY")
194+ require.NoError(t, err)
195+
196+ // Second delete of an already-removed key must be a no-op.
197+ err = store.Delete(domainName, "API_KEY")
198+ require.NoError(t, err)
199+
200+ values, err := store.GetAll(domainName)
201+ require.NoError(t, err)
202+ assert.NotContains(t, values, "API_KEY")
203+}
204+
205+func TestPassStore_DeleteAttachment_Idempotent(t *testing.T) {
206+ requirePass(t)
207+
208+ store, err := NewPassStore(testLogger())
209+ require.NoError(t, err)
210+
211+ containerName := fmt.Sprintf("idempotent-attachment-%d", time.Now().UnixNano())
212+ keys := []string{"POSTGRES_PASSWORD"}
213+ defer CleanupPassAttachment(t, containerName, keys)
214+
215+ err = store.SetAttachment(containerName, map[string]string{"POSTGRES_PASSWORD": "secret123"})
216+ require.NoError(t, err)
217+
218+ err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
219+ require.NoError(t, err)
220+
221+ // Second delete of an already-removed key must be a no-op.
222+ err = store.DeleteAttachment(containerName, "POSTGRES_PASSWORD")
223+ require.NoError(t, err)
224+
225+ values, err := store.GetAllAttachment(containerName)
226+ require.NoError(t, err)
227+ assert.NotContains(t, values, "POSTGRES_PASSWORD")
228+}
229+
230+func TestPassStore_SetAttachment_OverwriteValue(t *testing.T) {
231+ requirePass(t)
232+
233+ store, err := NewPassStore(testLogger())
234+ require.NoError(t, err)
235+
236+ containerName := fmt.Sprintf("attachment-overwrite-%d", time.Now().UnixNano())
237+ keys := []string{"REDIS_PASSWORD"}
238+ defer CleanupPassAttachment(t, containerName, keys)
239+
240+ err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "first"})
241+ require.NoError(t, err)
242+
243+ // Re-setting same key with a different value should deterministically overwrite.
244+ err = store.SetAttachment(containerName, map[string]string{"REDIS_PASSWORD": "second"})
245+ require.NoError(t, err)
246+
247+ values, err := store.GetAllAttachment(containerName)
248+ require.NoError(t, err)
249+ assert.Equal(t, "second", values["REDIS_PASSWORD"])
250+}
251+
180 func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) {252 func TestPassStore_ListKeys_RecoversOrphanedEntries(t *testing.T) {
181 requirePass(t)253 requirePass(t)
182 254

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