v2.7.1 b3a38c8

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	"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}

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