1// Package envloader implements the environment variable loader adapter.
2package envloader
3
4import (
5 "bufio"
6 "context"
7 "fmt"
8 "os"
9 "path/filepath"
10 "strings"
11
12 "github.com/bnema/zerowrap"
13
14 "github.com/bnema/gordon/internal/boundaries/out"
15)
16
17// FileLoader implements the EnvLoader interface using filesystem-based env files.
18type FileLoader struct {
19 envDir string
20 secretProviders map[string]out.SecretProvider
21 log zerowrap.Logger
22}
23
24// NewFileLoader creates a new file-based environment loader.
25func NewFileLoader(envDir string, log zerowrap.Logger) (*FileLoader, error) {
26 // Ensure env directory exists
27 if err := os.MkdirAll(envDir, 0700); err != nil {
28 return nil, fmt.Errorf("failed to create env directory %s: %w", envDir, err)
29 }
30
31 log.Debug().
32 Str(zerowrap.FieldLayer, "adapter").
33 Str(zerowrap.FieldAdapter, "envloader").
34 Str("env_dir", envDir).
35 Msg("env loader initialized")
36
37 return &FileLoader{
38 envDir: envDir,
39 secretProviders: make(map[string]out.SecretProvider),
40 log: log,
41 }, nil
42}
43
44// RegisterSecretProvider registers a secret provider for resolving secrets.
45func (l *FileLoader) RegisterSecretProvider(provider out.SecretProvider) {
46 l.secretProviders[provider.Name()] = provider
47 l.log.Debug().
48 Str(zerowrap.FieldLayer, "adapter").
49 Str(zerowrap.FieldAdapter, "envloader").
50 Str("provider", provider.Name()).
51 Msg("secret provider registered")
52}
53
54// LoadEnv loads environment variables for a given domain.
55func (l *FileLoader) LoadEnv(ctx context.Context, domain string) ([]string, error) {
56 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
57 zerowrap.FieldLayer: "adapter",
58 zerowrap.FieldAdapter: "envloader",
59 zerowrap.FieldAction: "LoadEnv",
60 "domain": domain,
61 })
62 log := zerowrap.FromCtx(ctx)
63
64 envVars := []string{}
65 envFile := l.getEnvFilePath(domain)
66
67 // Check if env file exists
68 if _, err := os.Stat(envFile); os.IsNotExist(err) {
69 log.Debug().Str("env_file", envFile).Msg("no env file found for route")
70 return envVars, nil
71 }
72
73 log.Debug().Str("env_file", envFile).Msg("loading env file for route")
74
75 file, err := os.Open(envFile)
76 if err != nil {
77 return nil, log.WrapErr(err, "failed to open env file")
78 }
79 defer file.Close()
80
81 scanner := bufio.NewScanner(file)
82 lineNum := 0
83
84 for scanner.Scan() {
85 lineNum++
86 line := strings.TrimSpace(scanner.Text())
87
88 // Skip empty lines and comments
89 if line == "" || strings.HasPrefix(line, "#") {
90 continue
91 }
92
93 // Parse KEY=VALUE format
94 parts := strings.SplitN(line, "=", 2)
95 if len(parts) != 2 {
96 log.Warn().
97 Int("line", lineNum).
98 Str("content", line).
99 Msg("invalid env file line format, skipping")
100 continue
101 }
102
103 key := strings.TrimSpace(parts[0])
104 value := strings.TrimSpace(parts[1])
105
106 // Remove quotes if present
107 if len(value) >= 2 {
108 if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
109 (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
110 value = value[1 : len(value)-1]
111 }
112 }
113
114 // Resolve secrets if value contains secret syntax
115 resolvedValue, err := l.resolveSecrets(ctx, value)
116 if err != nil {
117 return nil, log.WrapErrWithFields(err, "failed to resolve secret", map[string]any{
118 "key": key,
119 "line": lineNum,
120 })
121 }
122
123 envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue))
124 }
125
126 if err := scanner.Err(); err != nil {
127 return nil, log.WrapErr(err, "error reading env file")
128 }
129
130 log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route")
131
132 return envVars, nil
133}
134
135// CreateEnvFile creates an empty environment file for a new domain.
136func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
137 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
138 zerowrap.FieldLayer: "adapter",
139 zerowrap.FieldAdapter: "envloader",
140 zerowrap.FieldAction: "CreateEnvFile",
141 "domain": domain,
142 })
143 log := zerowrap.FromCtx(ctx)
144
145 envFile := l.getEnvFilePath(domain)
146
147 // Check if file already exists
148 if _, err := os.Stat(envFile); err == nil {
149 log.Debug().Str("env_file", envFile).Msg("env file already exists, skipping creation")
150 return nil
151 } else if !os.IsNotExist(err) {
152 return log.WrapErr(err, "error checking env file")
153 }
154
155 // Create empty env file with helpful comments
156 content := fmt.Sprintf(`# Environment variables for route: %s
157# Add KEY=VALUE pairs, one per line
158# Secrets can be referenced with ${provider:path} syntax
159
160`, domain)
161
162 if err := os.WriteFile(envFile, []byte(content), 0600); err != nil {
163 return log.WrapErr(err, "failed to create env file")
164 }
165
166 log.Info().Str("env_file", envFile).Msg("created empty env file for route")
167
168 return nil
169}
170
171// EnvFileExists checks if an environment file exists for a domain.
172func (l *FileLoader) EnvFileExists(domain string) bool {
173 envFile := l.getEnvFilePath(domain)
174 _, err := os.Stat(envFile)
175 return err == nil
176}
177
178// resolveSecrets resolves any secret references in the value.
179// Secret syntax: ${provider:path}
180func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string, error) {
181 // Check if value contains secret syntax: ${provider:path}
182 if !strings.Contains(value, "${") {
183 return value, nil
184 }
185
186 result := value
187 for {
188 start := strings.Index(result, "${")
189 if start == -1 {
190 break
191 }
192
193 end := strings.Index(result[start:], "}")
194 if end == -1 {
195 return "", fmt.Errorf("unclosed secret syntax in value: %s", value)
196 }
197 end += start
198
199 secretRef := result[start+2 : end]
200 parts := strings.SplitN(secretRef, ":", 2)
201 if len(parts) != 2 {
202 return "", fmt.Errorf("invalid secret syntax: expected 'provider:path', got '%s'", secretRef)
203 }
204
205 providerName := parts[0]
206 secretPath := parts[1]
207
208 provider, exists := l.secretProviders[providerName]
209 if !exists {
210 return "", fmt.Errorf("unknown secret provider: %s", providerName)
211 }
212
213 secretValue, err := provider.GetSecret(ctx, secretPath)
214 if err != nil {
215 return "", fmt.Errorf("failed to get secret from provider %s: %w", providerName, err)
216 }
217
218 // Replace the secret reference with the actual value
219 result = result[:start] + secretValue + result[end+1:]
220 }
221
222 return result, nil
223}
224
225func (l *FileLoader) getEnvFilePath(domain string) string {
226 // Create domain-safe filename (replace dots and other chars with underscores)
227 safeDomain := strings.ReplaceAll(domain, ".", "_")
228 safeDomain = strings.ReplaceAll(safeDomain, ":", "_")
229 safeDomain = strings.ReplaceAll(safeDomain, "/", "_")
230 return filepath.Join(l.envDir, safeDomain+".env")
231}