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