v2.31.0 e02f654

file.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close