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 Str("content", line).
103 Msg("invalid env file line format, skipping")
104 continue
105 }
106
107 key := strings.TrimSpace(parts[0])
108 value := strings.TrimSpace(parts[1])
109
110 // Remove quotes if present
111 if len(value) >= 2 {
112 if (strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"")) ||
113 (strings.HasPrefix(value, "'") && strings.HasSuffix(value, "'")) {
114 value = value[1 : len(value)-1]
115 }
116 }
117
118 // Resolve secrets if value contains secret syntax
119 resolvedValue, err := l.resolveSecrets(ctx, value)
120 if err != nil {
121 return nil, log.WrapErrWithFields(err, "failed to resolve secret", map[string]any{
122 "key": key,
123 "line": lineNum,
124 })
125 }
126
127 envVars = append(envVars, fmt.Sprintf("%s=%s", key, resolvedValue))
128 }
129
130 if err := scanner.Err(); err != nil {
131 return nil, log.WrapErr(err, "error reading env file")
132 }
133
134 log.Info().Int(zerowrap.FieldCount, len(envVars)).Msg("loaded environment variables for route")
135
136 return envVars, nil
137}
138
139// CreateEnvFile creates an empty environment file for a new domain.
140func (l *FileLoader) CreateEnvFile(ctx context.Context, domain string) error {
141 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
142 zerowrap.FieldLayer: "adapter",
143 zerowrap.FieldAdapter: "envloader",
144 zerowrap.FieldAction: "CreateEnvFile",
145 "domain": domain,
146 })
147 log := zerowrap.FromCtx(ctx)
148
149 envFile, err := l.getEnvFilePath(domain)
150 if err != nil {
151 return log.WrapErr(err, "invalid env file domain")
152 }
153
154 // Check if file already exists
155 if _, err := os.Stat(envFile); err == nil {
156 log.Debug().Str("env_file", envFile).Msg("env file already exists, skipping creation")
157 return nil
158 } else if !os.IsNotExist(err) {
159 return log.WrapErr(err, "error checking env file")
160 }
161
162 // Create empty env file with helpful comments
163 content := fmt.Sprintf(`# Environment variables for route: %s
164# Add KEY=VALUE pairs, one per line
165# Secrets can be referenced with ${provider:path} syntax
166
167`, domain)
168
169 if err := os.WriteFile(envFile, []byte(content), 0600); err != nil {
170 return log.WrapErr(err, "failed to create env file")
171 }
172
173 log.Info().Str("env_file", envFile).Msg("created empty env file for route")
174
175 return nil
176}
177
178// EnvFileExists checks if an environment file exists for a domain.
179func (l *FileLoader) EnvFileExists(domain string) (bool, error) {
180 envFile, err := l.getEnvFilePath(domain)
181 if err != nil {
182 return false, err
183 }
184
185 _, err = os.Stat(envFile)
186 if os.IsNotExist(err) {
187 return false, nil
188 }
189 if err != nil {
190 return false, err
191 }
192
193 return true, nil
194}
195
196// resolveSecrets resolves any secret references in the value.
197// Secret syntax: ${provider:path}
198func (l *FileLoader) resolveSecrets(ctx context.Context, value string) (string, error) {
199 // Check if value contains secret syntax: ${provider:path}
200 if !strings.Contains(value, "${") {
201 return value, nil
202 }
203
204 result := value
205 for {
206 start := strings.Index(result, "${")
207 if start == -1 {
208 break
209 }
210
211 end := strings.Index(result[start:], "}")
212 if end == -1 {
213 return "", fmt.Errorf("unclosed secret syntax in value: %s", value)
214 }
215 end += start
216
217 secretRef := result[start+2 : end]
218 parts := strings.SplitN(secretRef, ":", 2)
219 if len(parts) != 2 {
220 return "", fmt.Errorf("invalid secret syntax: expected 'provider:path', got '%s'", secretRef)
221 }
222
223 providerName := parts[0]
224 secretPath := parts[1]
225
226 provider, exists := l.secretProviders[providerName]
227 if !exists {
228 return "", fmt.Errorf("unknown secret provider: %s", providerName)
229 }
230
231 secretValue, err := provider.GetSecret(ctx, secretPath)
232 if err != nil {
233 return "", fmt.Errorf("failed to get secret from provider %s: %w", providerName, err)
234 }
235
236 // Replace the secret reference with the actual value
237 result = result[:start] + secretValue + result[end+1:]
238 }
239
240 return result, nil
241}
242
243func (l *FileLoader) getEnvFilePath(domainName string) (string, error) {
244 storageKey, err := domain.NewEnvStorageKey(domainName)
245 if err != nil {
246 return "", err
247 }
248
249 return filepath.Join(l.envDir, storageKey.FileName()), nil
250}