v2.31.0-dev20 020cc00

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

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