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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "sort" | |
| 8 | + | "strings" | |
| 8 | 9 | ||
| 9 | 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 | 47 | log := zerowrap.FromCtx(ctx) | |
| 47 | 48 | ||
| 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 | + | } | |
| 51 | 62 | } | |
| 52 | 63 | ||
| 53 | 64 | keys := make([]string, 0, len(secretsMap)) | |
| @@ -66,6 +77,19 @@ func (l *PassLoader) LoadEnv(ctx context.Context, domain string) ([]string, erro | |||
| 66 | 77 | 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 | 93 | // CreateEnvFile is a no-op for pass-backed loader. | |
| 70 | 94 | func (l *PassLoader) CreateEnvFile(ctx context.Context, domain string) error { | |
| 71 | 95 | 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, nil | 77 | 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 | + | } | |||