Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,11 @@ type ProjectConfig struct {
}

type EnvConfig struct {
Provider string `yaml:"provider"`
Source string `yaml:"source,omitempty"` // deprecated, use Provider
PathPrefix string `yaml:"path_prefix"`
Prefix string `yaml:"prefix"`
Provider string `yaml:"provider"`
Source string `yaml:"source,omitempty"` // deprecated, use Provider
PathPrefix string `yaml:"path_prefix"`
Prefix string `yaml:"prefix"`
Mapping map[string]provider.SecretMapping `yaml:"mapping,omitempty"`
}

func (e EnvConfig) GetProvider() string {
Expand All @@ -35,6 +36,7 @@ func (e EnvConfig) ToProviderConfig() provider.EnvConfig {
Provider: e.GetProvider(),
PathPrefix: e.PathPrefix,
Prefix: e.Prefix,
Mapping: e.Mapping,
}
}

Expand Down Expand Up @@ -102,6 +104,11 @@ func (c ProjectConfig) Validate() error {
if _, ok := c.Envs[c.DefaultEnv]; !ok {
return fmt.Errorf("default_env %q not found in envs", c.DefaultEnv)
}
for envName, envCfg := range c.Envs {
if len(envCfg.Mapping) > 0 && envCfg.PathPrefix != "" {
return fmt.Errorf("env %q: mapping and path_prefix are mutually exclusive", envName)
}
}
return nil
}

Expand Down
81 changes: 81 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,87 @@ envs:
}
}

func TestLoadProjectConfigWithMapping(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, ".envmap.yaml")

content := `
project: testapp
default_env: dev
envs:
dev:
provider: vault
mapping:
CDN_TOKEN:
path: shared/cdn
key: CDN_TOKEN
API_KEY:
path: myapp
key: API_SECRET_KEY
`
if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}

cfg, err := LoadProjectConfig(cfgPath)
if err != nil {
t.Fatalf("LoadProjectConfig: %v", err)
}

devCfg := cfg.Envs["dev"]
if len(devCfg.Mapping) != 2 {
t.Fatalf("len(Mapping) = %d, want 2", len(devCfg.Mapping))
}

cdnMapping := devCfg.Mapping["CDN_TOKEN"]
if cdnMapping.Path != "shared/cdn" {
t.Errorf("CDN_TOKEN.Path = %q, want %q", cdnMapping.Path, "shared/cdn")
}
if cdnMapping.Key != "CDN_TOKEN" {
t.Errorf("CDN_TOKEN.Key = %q, want %q", cdnMapping.Key, "CDN_TOKEN")
}

apiMapping := devCfg.Mapping["API_KEY"]
if apiMapping.Path != "myapp" {
t.Errorf("API_KEY.Path = %q, want %q", apiMapping.Path, "myapp")
}
if apiMapping.Key != "API_SECRET_KEY" {
t.Errorf("API_KEY.Key = %q, want %q", apiMapping.Key, "API_SECRET_KEY")
}

// Verify it propagates to provider config
providerCfg := devCfg.ToProviderConfig()
if len(providerCfg.Mapping) != 2 {
t.Fatalf("provider EnvConfig.Mapping = %d, want 2", len(providerCfg.Mapping))
}
}

func TestLoadProjectConfigMappingAndPathPrefixConflict(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, ".envmap.yaml")

content := `
project: testapp
default_env: dev
envs:
dev:
provider: vault
path_prefix: /some/prefix
mapping:
SOME_VAR:
path: some/path
key: SOME_KEY
`
if err := os.WriteFile(cfgPath, []byte(content), 0644); err != nil {
t.Fatal(err)
}

_, err := LoadProjectConfig(cfgPath)
if err == nil {
t.Fatal("expected error when both mapping and path_prefix are set")
}
}

func TestLoadProjectConfigValidation(t *testing.T) {
tests := []struct {
name string
Expand Down
32 changes: 32 additions & 0 deletions env.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,15 @@ func CollectEnvWithMetadata(ctx context.Context, projectCfg ProjectConfig, globa
if err != nil {
return nil, err
}

if len(envCfg.Mapping) > 0 {
mkp, ok := p.(provider.MultiKeyProvider)
if !ok {
return nil, fmt.Errorf("provider %s does not support multi-key secret reading (required by mapping)", envCfg.GetProvider())
}
return provider.CollectMappedSecrets(ctx, mkp, envCfg.Mapping)
}

return provider.ListOrDescribe(ctx, p, provider.ResolvedPrefix(envCfg.ToProviderConfig()))
}

Expand All @@ -77,6 +86,23 @@ func FetchSecret(ctx context.Context, projectCfg ProjectConfig, globalCfg Global
if err != nil {
return "", err
}

if sm, ok := envCfg.Mapping[key]; ok {
mkp, ok := p.(provider.MultiKeyProvider)
if !ok {
return "", fmt.Errorf("provider %s does not support multi-key secret reading (required by mapping)", envCfg.GetProvider())
}
data, err := mkp.ReadSecret(ctx, sm.Path)
if err != nil {
return "", err
}
val, ok := data[sm.Key]
if !ok {
return "", fmt.Errorf("key %q not found in secret at path %q", sm.Key, sm.Path)
}
return val, nil
}

return p.Get(ctx, provider.ApplyPrefix(envCfg.ToProviderConfig(), key))
}

Expand All @@ -85,6 +111,9 @@ func WriteSecret(ctx context.Context, projectCfg ProjectConfig, globalCfg Global
if !ok {
return fmt.Errorf("env %q not found in project config", envName)
}
if len(envCfg.Mapping) > 0 {
return fmt.Errorf("env %q uses mapping mode; secrets are read-only and managed externally", envName)
}
p, err := NewProvider(envName, envCfg, globalCfg)
if err != nil {
return err
Expand All @@ -97,6 +126,9 @@ func DeleteSecret(ctx context.Context, projectCfg ProjectConfig, globalCfg Globa
if !ok {
return fmt.Errorf("env %q not found in project config", envName)
}
if len(envCfg.Mapping) > 0 {
return fmt.Errorf("env %q uses mapping mode; secrets are read-only and managed externally", envName)
}
p, err := NewProvider(envName, envCfg, globalCfg)
if err != nil {
return err
Expand Down
13 changes: 10 additions & 3 deletions provider/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,18 @@ package provider

import "strings"

// SecretMapping maps an env var to a specific key within a multi-key Vault secret.
type SecretMapping struct {
Path string `yaml:"path"`
Key string `yaml:"key"`
}

// EnvConfig represents the environment-specific configuration from the project file.
type EnvConfig struct {
Provider string `yaml:"provider"`
PathPrefix string `yaml:"path_prefix"`
Prefix string `yaml:"prefix"`
Provider string `yaml:"provider"`
PathPrefix string `yaml:"path_prefix"`
Prefix string `yaml:"prefix"`
Mapping map[string]SecretMapping `yaml:"mapping,omitempty"`
}

// ProviderConfig represents the provider configuration from the global config file.
Expand Down
40 changes: 40 additions & 0 deletions provider/mapping.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package provider

import (
"context"
"fmt"
)

// CollectMappedSecrets fetches secrets using the mapping configuration.
// It groups entries by path to minimize API calls, then extracts the specific
// key for each env var from the returned map.
func CollectMappedSecrets(ctx context.Context, p MultiKeyProvider, mapping map[string]SecretMapping) (map[string]SecretRecord, error) {
// Group env vars by Vault path to deduplicate reads.
type entry struct {
envVar string
key string
}
byPath := make(map[string][]entry)
for envVar, sm := range mapping {
byPath[sm.Path] = append(byPath[sm.Path], entry{envVar: envVar, key: sm.Key})
}

out := make(map[string]SecretRecord, len(mapping))

for path, entries := range byPath {
data, err := p.ReadSecret(ctx, path)
if err != nil {
return nil, fmt.Errorf("read secret at %s: %w", path, err)
}

for _, e := range entries {
val, ok := data[e.key]
if !ok {
return nil, fmt.Errorf("key %q not found in secret at path %q (env var %s)", e.key, path, e.envVar)
}
out[e.envVar] = SecretRecord{Value: val}
}
}

return out, nil
}
Loading