detached 378df3f

feat(loader): add attachment container support to pass environment loader

Update PassLoader to detect and load environment variables for attachment containers using the gordon- prefix heuristic. Add comprehensive test coverage for both domain and attachment container loading scenarios.
commit
378df3f6233292d2a1282c5cc9364ac95d112e6b
author
bnema · · 2026-01-31 22:21 UTC
parent
80d7794
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +135 -3
M internal/adapters/out/envloader/pass_loader.go +27 -3
@@ -5,6 +5,7 @@ import (
55 "context"
66 "fmt"
77 "sort"
8+ "strings"
89
910 "github.com/bnema/zerowrap"
1011
@@ -45,9 +46,19 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
4546 })
4647 log := zerowrap.FromCtx(ctx)
4748
48- secretsMap, err := l.store.GetAll(domain)
49- if err != nil {
50- return nil, log.WrapErr(err, "failed to load env from pass")
49+ var secretsMap map[string]string
50+ var err error
51+
52+ if isAttachmentContainer(domain) {
53+ secretsMap, err = l.store.GetAllAttachment(domain)
54+ if err != nil {
55+ return nil, log.WrapErr(err, "failed to load attachment env from pass")
56+ }
57+ } else {
58+ secretsMap, err = l.store.GetAll(domain)
59+ if err != nil {
60+ return nil, log.WrapErr(err, "failed to load env from pass")
61+ }
5162 }
5263
5364 keys := make([]string, 0, len(secretsMap))
@@ -66,6 +77,19 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
6677 return envVars, nil
6778 }
6879
80+// isAttachmentContainer determines whether a given name refers to an attachment container
81+// or a domain. It uses a simple heuristic: attachment container names always start with
82+// the "gordon-" prefix (e.g., "gordon-git-example-com-gitea-postgres").
83+//
84+// LIMITATION: This heuristic assumes domains never start with "gordon-". A domain like
85+// "gordon-app.example.com" would be incorrectly classified as an attachment container.
86+// This is an acceptable trade-off for simplicity, but if such domains become common,
87+// this should be replaced with a more robust detection mechanism (e.g., checking both
88+// GetAll() and GetAllAttachment() and seeing which returns results).
89+func isAttachmentContainer(domain string) bool {
90+ return strings.HasPrefix(domain, "gordon-")
91+}
92+
6993 // CreateEnvFile is a no-op for pass-backed loader.
7094 func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error {
7195 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -5,6 +5,7 @@ import (
5 "context"5 "context"
6 "fmt"6 "fmt"
7 "sort"7 "sort"
8+ "strings"
8 9
9 "github.com/bnema/zerowrap"10 "github.com/bnema/zerowrap"
10 11
@@ -45,9 +46,19 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
45 })46 })
46 log := zerowrap.FromCtx(ctx)47 log := zerowrap.FromCtx(ctx)
47 48
48- secretsMap, err := l.store.GetAll(domain)49+ var secretsMap map[string]string
49- if err != nil {50+ var err error
50- return nil, log.WrapErr(err, "failed to load env from pass")51+
52+ if isAttachmentContainer(domain) {
53+ secretsMap, err = l.store.GetAllAttachment(domain)
54+ if err != nil {
55+ return nil, log.WrapErr(err, "failed to load attachment env from pass")
56+ }
57+ } else {
58+ secretsMap, err = l.store.GetAll(domain)
59+ if err != nil {
60+ return nil, log.WrapErr(err, "failed to load env from pass")
61+ }
51 }62 }
52 63
53 keys := make([]string, 0, len(secretsMap))64 keys := make([]string, 0, len(secretsMap))
@@ -66,6 +77,19 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro
66 return envVars, nil77 return envVars, nil
67 }78 }
68 79
80+// isAttachmentContainer determines whether a given name refers to an attachment container
81+// or a domain. It uses a simple heuristic: attachment container names always start with
82+// the "gordon-" prefix (e.g., "gordon-git-example-com-gitea-postgres").
83+//
84+// LIMITATION: This heuristic assumes domains never start with "gordon-". A domain like
85+// "gordon-app.example.com" would be incorrectly classified as an attachment container.
86+// This is an acceptable trade-off for simplicity, but if such domains become common,
87+// this should be replaced with a more robust detection mechanism (e.g., checking both
88+// GetAll() and GetAllAttachment() and seeing which returns results).
89+func isAttachmentContainer(domain string) bool {
90+ return strings.HasPrefix(domain, "gordon-")
91+}
92+
69 // CreateEnvFile is a no-op for pass-backed loader.93 // CreateEnvFile is a no-op for pass-backed loader.
70 func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error {94 func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error {
71 ctx = zerowrap.CtxWithFields(ctx, map[string]any{95 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
A internal/adapters/out/envloader/pass_loader_test.go +108 -0
@@ -0,0 +1,108 @@
1+package envloader
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
14+ "github.com/bnema/zerowrap"
15+)
16+
17+func passCmd(args ...string) error {
18+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
19+ defer cancel()
20+ return exec.CommandContext(ctx, "pass", args...).Run()
21+}
22+
23+func requirePass(t *testing.T) {
24+ if err := passCmd("version"); err != nil {
25+ t.Skip("pass not available")
26+ }
27+ if err := passCmd("ls"); err != nil {
28+ t.Skip("pass store not initialized")
29+ }
30+}
31+
32+func cleanupPassEnv(t *testing.T, path string) {
33+ _ = passCmd("rm", "-rf", path)
34+}
35+
36+func TestPassLoader_LoadEnv_Domain(t *testing.T) {
37+ requirePass(t)
38+
39+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
40+ store, err := domainsecrets.NewPassStore(log)
41+ require.NoError(t, err)
42+
43+ loader, err := NewPassLoader(store, log)
44+ require.NoError(t, err)
45+
46+ domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
47+ defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassDomainSecretsPath, domainName))
48+
49+ secretsMap := map[string]string{
50+ "API_KEY": "test123",
51+ "DB_HOST": "localhost",
52+ }
53+ err = store.Set(domainName, secretsMap)
54+ require.NoError(t, err)
55+
56+ ctx := context.Background()
57+ envVars, err := loader.LoadEnv(ctx, domainName)
58+ require.NoError(t, err)
59+ assert.Len(t, envVars, 2)
60+ assert.Contains(t, envVars, "API_KEY=test123")
61+ assert.Contains(t, envVars, "DB_HOST=localhost")
62+}
63+
64+func TestPassLoader_LoadEnv_Attachment(t *testing.T) {
65+ requirePass(t)
66+
67+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
68+ store, err := domainsecrets.NewPassStore(log)
69+ require.NoError(t, err)
70+
71+ loader, err := NewPassLoader(store, log)
72+ require.NoError(t, err)
73+
74+ containerName := fmt.Sprintf("gordon-gitea-postgres-%d", time.Now().UnixNano())
75+ defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassAttachmentPath, containerName))
76+
77+ secretsMap := map[string]string{
78+ "POSTGRES_USER": "gitea",
79+ "POSTGRES_PASSWORD": "secret123",
80+ }
81+ err = store.SetAttachment(containerName, secretsMap)
82+ require.NoError(t, err)
83+
84+ ctx := context.Background()
85+ envVars, err := loader.LoadEnv(ctx, containerName)
86+ require.NoError(t, err)
87+ assert.Len(t, envVars, 2)
88+ assert.Contains(t, envVars, "POSTGRES_USER=gitea")
89+ assert.Contains(t, envVars, "POSTGRES_PASSWORD=secret123")
90+}
91+
92+func TestPassLoader_LoadEnv_EmptyDomain(t *testing.T) {
93+ requirePass(t)
94+
95+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
96+ store, err := domainsecrets.NewPassStore(log)
97+ require.NoError(t, err)
98+
99+ loader, err := NewPassLoader(store, log)
100+ require.NoError(t, err)
101+
102+ domainName := fmt.Sprintf("empty.%d.example.com", time.Now().UnixNano())
103+
104+ ctx := context.Background()
105+ envVars, err := loader.LoadEnv(ctx, domainName)
106+ require.NoError(t, err)
107+ assert.Len(t, envVars, 0)
108+}
@@ -0,0 +1,108 @@
1+package envloader
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os/exec"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/gordon/internal/adapters/out/domainsecrets"
14+ "github.com/bnema/zerowrap"
15+)
16+
17+func passCmd(args ...string) error {
18+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
19+ defer cancel()
20+ return exec.CommandContext(ctx, "pass", args...).Run()
21+}
22+
23+func requirePass(t *testing.T) {
24+ if err := passCmd("version"); err != nil {
25+ t.Skip("pass not available")
26+ }
27+ if err := passCmd("ls"); err != nil {
28+ t.Skip("pass store not initialized")
29+ }
30+}
31+
32+func cleanupPassEnv(t *testing.T, path string) {
33+ _ = passCmd("rm", "-rf", path)
34+}
35+
36+func TestPassLoader_LoadEnv_Domain(t *testing.T) {
37+ requirePass(t)
38+
39+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
40+ store, err := domainsecrets.NewPassStore(log)
41+ require.NoError(t, err)
42+
43+ loader, err := NewPassLoader(store, log)
44+ require.NoError(t, err)
45+
46+ domainName := fmt.Sprintf("test.%d.example.com", time.Now().UnixNano())
47+ defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassDomainSecretsPath, domainName))
48+
49+ secretsMap := map[string]string{
50+ "API_KEY": "test123",
51+ "DB_HOST": "localhost",
52+ }
53+ err = store.Set(domainName, secretsMap)
54+ require.NoError(t, err)
55+
56+ ctx := context.Background()
57+ envVars, err := loader.LoadEnv(ctx, domainName)
58+ require.NoError(t, err)
59+ assert.Len(t, envVars, 2)
60+ assert.Contains(t, envVars, "API_KEY=test123")
61+ assert.Contains(t, envVars, "DB_HOST=localhost")
62+}
63+
64+func TestPassLoader_LoadEnv_Attachment(t *testing.T) {
65+ requirePass(t)
66+
67+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
68+ store, err := domainsecrets.NewPassStore(log)
69+ require.NoError(t, err)
70+
71+ loader, err := NewPassLoader(store, log)
72+ require.NoError(t, err)
73+
74+ containerName := fmt.Sprintf("gordon-gitea-postgres-%d", time.Now().UnixNano())
75+ defer cleanupPassEnv(t, fmt.Sprintf("%s/%s", domainsecrets.PassAttachmentPath, containerName))
76+
77+ secretsMap := map[string]string{
78+ "POSTGRES_USER": "gitea",
79+ "POSTGRES_PASSWORD": "secret123",
80+ }
81+ err = store.SetAttachment(containerName, secretsMap)
82+ require.NoError(t, err)
83+
84+ ctx := context.Background()
85+ envVars, err := loader.LoadEnv(ctx, containerName)
86+ require.NoError(t, err)
87+ assert.Len(t, envVars, 2)
88+ assert.Contains(t, envVars, "POSTGRES_USER=gitea")
89+ assert.Contains(t, envVars, "POSTGRES_PASSWORD=secret123")
90+}
91+
92+func TestPassLoader_LoadEnv_EmptyDomain(t *testing.T) {
93+ requirePass(t)
94+
95+ log := zerowrap.New(zerowrap.Config{Level: "fatal"})
96+ store, err := domainsecrets.NewPassStore(log)
97+ require.NoError(t, err)
98+
99+ loader, err := NewPassLoader(store, log)
100+ require.NoError(t, err)
101+
102+ domainName := fmt.Sprintf("empty.%d.example.com", time.Now().UnixNano())
103+
104+ ctx := context.Background()
105+ envVars, err := loader.LoadEnv(ctx, domainName)
106+ require.NoError(t, err)
107+ assert.Len(t, envVars, 0)
108+}

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