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}