v2.31.0-dev21 b59533b

migrate_env.go RawHistoryFolder

1package app
2
3import (
4	"errors"
5	"fmt"
6	"os"
7	"path/filepath"
8	"strings"
9
10	"github.com/bnema/zerowrap"
11
12	"github.com/bnema/gordon/internal/adapters/out/domainsecrets"
13	"github.com/bnema/gordon/internal/domain"
14)
15
16func migrateEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
17	entries, err := readEnvDir(envDir)
18	if err != nil {
19		return err
20	}
21
22	for _, entry := range entries {
23		if entry.IsDir() {
24			continue
25		}
26		name := entry.Name()
27		if !isPlainEnvFile(name) {
28			continue
29		}
30		if err := migrateEnvFile(envDir, name, passStore, log); err != nil {
31			return err
32		}
33	}
34
35	if err := migrateAttachmentEnvFilesToPass(envDir, passStore, log); err != nil {
36		return err
37	}
38
39	return nil
40}
41
42func readEnvDir(envDir string) ([]os.DirEntry, error) {
43	entries, err := os.ReadDir(envDir)
44	if err != nil {
45		if os.IsNotExist(err) {
46			return nil, nil
47		}
48		return nil, fmt.Errorf("failed to read env directory: %w", err)
49	}
50	return entries, nil
51}
52
53func isPlainEnvFile(name string) bool {
54	if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") {
55		return false
56	}
57	// Skip attachment env files: gordon-<sanitized-domain>-<service>.env
58	if strings.HasPrefix(name, "gordon-") {
59		return false
60	}
61	return true
62}
63
64func migrateEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
65	filePath := filepath.Join(envDir, name)
66	data, err := os.ReadFile(filePath)
67	if err != nil {
68		return fmt.Errorf("failed to read env file %s: %w", filePath, err)
69	}
70
71	domainName := strings.TrimSuffix(name, ".env")
72	secrets, err := domain.ParseEnvData(data)
73	if err != nil {
74		return fmt.Errorf("failed to parse env file %s: %w", filePath, err)
75	}
76	if len(secrets) == 0 {
77		log.Info().
78			Str("file", filePath).
79			Msg("no secrets found in env file; skipping migration")
80		return nil
81	}
82
83	writtenKeys, err := passStore.SetIfEmpty(domainName, secrets)
84	if err != nil {
85		if errors.Is(err, domain.ErrSecretsAlreadyExist) {
86			return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err)
87		}
88		return fmt.Errorf("failed to migrate secrets for %s: %w", domainName, err)
89	}
90
91	if err := os.Remove(filePath); err != nil {
92		// Check if the stored secrets match what we just wrote
93		storedSecrets, getErr := passStore.GetAll(domainName)
94		if getErr == nil && secretsMatch(storedSecrets, secrets) {
95			// Migration was successful, removal failed but secrets are stored
96			log.Warn().
97				Str("file", filePath).
98				Str("domain", domainName).
99				Msg("secrets migrated successfully but failed to remove original file; treating as success")
100			return nil
101		}
102		// Attempt rollback: remove only the pass entries this migration wrote.
103		for _, key := range writtenKeys {
104			if delErr := passStore.Delete(domainName, key); delErr != nil {
105				log.Error().
106					Err(delErr).
107					Str("domain", domainName).
108					Str("key", key).
109					Msg("failed to rollback pass entry after file removal failure")
110			}
111		}
112		return fmt.Errorf("failed to remove migrated env file %s: %w", filePath, err)
113	}
114
115	log.Info().
116		Int(zerowrap.FieldCount, len(secrets)).
117		Str("domain", domainName).
118		Msg("migrated secrets for domain from plain text to pass and removed original env file")
119
120	return nil
121}
122
123func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
124	entries, err := readEnvDir(envDir)
125	if err != nil {
126		return err
127	}
128
129	for _, entry := range entries {
130		if entry.IsDir() {
131			continue
132		}
133		name := entry.Name()
134		if !isAttachmentEnvFile(name) {
135			continue
136		}
137		if err := migrateAttachmentEnvFile(envDir, name, passStore, log); err != nil {
138			return err
139		}
140	}
141
142	return nil
143}
144
145func isAttachmentEnvFile(name string) bool {
146	if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") {
147		return false
148	}
149	return strings.HasPrefix(name, "gordon-")
150}
151
152func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
153	filePath := filepath.Join(envDir, name)
154	data, err := os.ReadFile(filePath)
155	if err != nil {
156		return fmt.Errorf("failed to read attachment env file %s: %w", filePath, err)
157	}
158
159	containerName := extractContainerNameFromAttachmentFile(name)
160	secrets, err := domain.ParseEnvData(data)
161	if err != nil {
162		return fmt.Errorf("failed to parse attachment env file %s: %w", filePath, err)
163	}
164	if len(secrets) == 0 {
165		log.Info().
166			Str("file", filePath).
167			Msg("no secrets found in attachment env file; skipping migration")
168		return nil
169	}
170
171	writtenKeys, err := passStore.SetAttachmentIfEmpty(containerName, secrets)
172	if err != nil {
173		if errors.Is(err, domain.ErrSecretsAlreadyExist) {
174			return fmt.Errorf("%w; refusing to delete plaintext env file automatically", err)
175		}
176		return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err)
177	}
178
179	if err := os.Remove(filePath); err != nil {
180		// Check if the stored secrets match what we just wrote
181		storedSecrets, getErr := passStore.GetAllAttachment(containerName)
182		if getErr == nil && secretsMatch(storedSecrets, secrets) {
183			// Migration was successful, removal failed but secrets are stored
184			log.Warn().
185				Str("file", filePath).
186				Str("container", containerName).
187				Msg("attachment secrets migrated successfully but failed to remove original file; treating as success")
188			return nil
189		}
190		// Attempt rollback: remove only the pass entries this migration wrote.
191		for _, key := range writtenKeys {
192			if delErr := passStore.DeleteAttachment(containerName, key); delErr != nil {
193				log.Error().
194					Err(delErr).
195					Str("container", containerName).
196					Str("key", key).
197					Msg("failed to rollback pass entry after file removal failure")
198			}
199		}
200		return fmt.Errorf("failed to remove migrated attachment env file %s: %w", filePath, err)
201	}
202
203	log.Info().
204		Int(zerowrap.FieldCount, len(secrets)).
205		Str("container", containerName).
206		Str("file", filePath).
207		Msg("migrated attachment secrets to pass and removed original env file")
208
209	return nil
210}
211
212func extractContainerNameFromAttachmentFile(filename string) string {
213	name := strings.TrimSuffix(filename, ".env")
214	return name
215}
216
217func secretsMatch(a, b map[string]string) bool {
218	if len(a) != len(b) {
219		return false
220	}
221	for k, v := range a {
222		bv, ok := b[k]
223		if !ok || bv != v {
224			return false
225		}
226	}
227	return true
228}

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