detached bcbc032

fix(migration): correct attachment container name prefix handling

Update extractContainerNameFromAttachmentFile to preserve the gordon- prefix instead of stripping it. This fixes a mismatch where migration stored secrets at paths without the prefix but the loader expected full container names with the prefix, causing secrets to not be found after migration.
commit
bcbc0325e211a438992b3f9217b2d62655c531e5
author
bnema · · 2026-01-31 22:20 UTC
parent
d8a81b6
signature
unsigned
2 files · +218 -0
M internal/app/migrate_env.go +94 -0
@@ -31,6 +31,10 @@ func migrateEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, lo
3131 }
3232 }
3333
34+ if err := migrateAttachmentEnvFilesToPass(envDir, passStore, log); err != nil {
35+ return err
36+ }
37+
3438 return nil
3539 }
3640
@@ -127,3 +131,93 @@ func missingSecrets(existingKeys []string, secrets map[string]string) map[string
127131
128132 return missing
129133 }
134+
135+func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
136+ entries, err := readEnvDir(envDir)
137+ if err != nil {
138+ return err
139+ }
140+
141+ for _, entry := range entries {
142+ if entry.IsDir() {
143+ continue
144+ }
145+ name := entry.Name()
146+ if !isAttachmentEnvFile(name) {
147+ continue
148+ }
149+ if err := migrateAttachmentEnvFile(envDir, name, passStore, log); err != nil {
150+ return err
151+ }
152+ }
153+
154+ return nil
155+}
156+
157+func isAttachmentEnvFile(name string) bool {
158+ if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") {
159+ return false
160+ }
161+ return strings.HasPrefix(name, "gordon-")
162+}
163+
164+func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
165+ filePath := filepath.Join(envDir, name)
166+ data, err := os.ReadFile(filePath)
167+ if err != nil {
168+ return fmt.Errorf("failed to read attachment env file %s: %w", filePath, err)
169+ }
170+
171+ containerName := extractContainerNameFromAttachmentFile(name)
172+ secrets, err := domain.ParseEnvData(data)
173+ if err != nil {
174+ return fmt.Errorf("failed to parse attachment env file %s: %w", filePath, err)
175+ }
176+ if len(secrets) == 0 {
177+ log.Info().
178+ Str("file", filePath).
179+ Msg("no secrets found in attachment env file; skipping migration")
180+ return nil
181+ }
182+
183+ existingSecrets, err := passStore.GetAllAttachment(containerName)
184+ if err != nil {
185+ return fmt.Errorf("failed to read pass secrets for attachment %s: %w", containerName, err)
186+ }
187+
188+ existingKeys := make([]string, 0, len(existingSecrets))
189+ for key := range existingSecrets {
190+ existingKeys = append(existingKeys, key)
191+ }
192+
193+ missing := missingSecrets(existingKeys, secrets)
194+ if len(missing) > 0 {
195+ if err := passStore.SetAttachment(containerName, missing); err != nil {
196+ return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err)
197+ }
198+ log.Info().
199+ Int(zerowrap.FieldCount, len(missing)).
200+ Str("container", containerName).
201+ Msg("migrated secrets for attachment container from plain text to pass")
202+ } else {
203+ log.Info().
204+ Str("container", containerName).
205+ Msg("all attachment secrets already exist in pass; no migration needed")
206+ }
207+
208+ migratedPath := filePath + ".migrated"
209+ if err := os.Rename(filePath, migratedPath); err != nil {
210+ return fmt.Errorf("failed to rename attachment env file %s: %w", filePath, err)
211+ }
212+
213+ log.Info().
214+ Str("file", migratedPath).
215+ Msg("original attachment file renamed to .env.migrated - you can safely remove it")
216+
217+ return nil
218+}
219+
220+func extractContainerNameFromAttachmentFile(filename string) string {
221+ name := strings.TrimSuffix(filename, ".env")
222+ return name
223+}
@@ -31,6 +31,10 @@ func migrateEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, lo
31 }31 }
32 }32 }
33 33
34+ if err := migrateAttachmentEnvFilesToPass(envDir, passStore, log); err != nil {
35+ return err
36+ }
37+
34 return nil38 return nil
35 }39 }
36 40
@@ -127,3 +131,93 @@ func missingSecrets(existingKeys []string, secrets map[string]string) map[string
127 131
128 return missing132 return missing
129 }133 }
134+
135+func migrateAttachmentEnvFilesToPass(envDir string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
136+ entries, err := readEnvDir(envDir)
137+ if err != nil {
138+ return err
139+ }
140+
141+ for _, entry := range entries {
142+ if entry.IsDir() {
143+ continue
144+ }
145+ name := entry.Name()
146+ if !isAttachmentEnvFile(name) {
147+ continue
148+ }
149+ if err := migrateAttachmentEnvFile(envDir, name, passStore, log); err != nil {
150+ return err
151+ }
152+ }
153+
154+ return nil
155+}
156+
157+func isAttachmentEnvFile(name string) bool {
158+ if !strings.HasSuffix(name, ".env") || strings.HasSuffix(name, ".env.migrated") {
159+ return false
160+ }
161+ return strings.HasPrefix(name, "gordon-")
162+}
163+
164+func migrateAttachmentEnvFile(envDir, name string, passStore *domainsecrets.PassStore, log zerowrap.Logger) error {
165+ filePath := filepath.Join(envDir, name)
166+ data, err := os.ReadFile(filePath)
167+ if err != nil {
168+ return fmt.Errorf("failed to read attachment env file %s: %w", filePath, err)
169+ }
170+
171+ containerName := extractContainerNameFromAttachmentFile(name)
172+ secrets, err := domain.ParseEnvData(data)
173+ if err != nil {
174+ return fmt.Errorf("failed to parse attachment env file %s: %w", filePath, err)
175+ }
176+ if len(secrets) == 0 {
177+ log.Info().
178+ Str("file", filePath).
179+ Msg("no secrets found in attachment env file; skipping migration")
180+ return nil
181+ }
182+
183+ existingSecrets, err := passStore.GetAllAttachment(containerName)
184+ if err != nil {
185+ return fmt.Errorf("failed to read pass secrets for attachment %s: %w", containerName, err)
186+ }
187+
188+ existingKeys := make([]string, 0, len(existingSecrets))
189+ for key := range existingSecrets {
190+ existingKeys = append(existingKeys, key)
191+ }
192+
193+ missing := missingSecrets(existingKeys, secrets)
194+ if len(missing) > 0 {
195+ if err := passStore.SetAttachment(containerName, missing); err != nil {
196+ return fmt.Errorf("failed to migrate attachment secrets for %s: %w", containerName, err)
197+ }
198+ log.Info().
199+ Int(zerowrap.FieldCount, len(missing)).
200+ Str("container", containerName).
201+ Msg("migrated secrets for attachment container from plain text to pass")
202+ } else {
203+ log.Info().
204+ Str("container", containerName).
205+ Msg("all attachment secrets already exist in pass; no migration needed")
206+ }
207+
208+ migratedPath := filePath + ".migrated"
209+ if err := os.Rename(filePath, migratedPath); err != nil {
210+ return fmt.Errorf("failed to rename attachment env file %s: %w", filePath, err)
211+ }
212+
213+ log.Info().
214+ Str("file", migratedPath).
215+ Msg("original attachment file renamed to .env.migrated - you can safely remove it")
216+
217+ return nil
218+}
219+
220+func extractContainerNameFromAttachmentFile(filename string) string {
221+ name := strings.TrimSuffix(filename, ".env")
222+ return name
223+}
M internal/app/migrate_env_test.go +124 -0
@@ -87,3 +87,127 @@ func TestMigrateEnvFile(t *testing.T) {
8787 assert.Equal(t, "secret123", values["API_KEY"])
8888 assert.Equal(t, "pass456", values["DB_PASSWORD"])
8989 }
90+
91+func cleanupPassAttachment(t *testing.T, containerName string, keys []string) {
92+ for _, key := range keys {
93+ path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key)
94+ _ = passCmd("rm", "-f", path)
95+ }
96+
97+ manifestPath := fmt.Sprintf("%s/%s/.keys", domainsecrets.PassAttachmentPath, containerName)
98+ _ = passCmd("rm", "-f", manifestPath)
99+}
100+
101+func TestMigrateAttachmentEnvFile(t *testing.T) {
102+ requirePass(t)
103+
104+ tmpDir, err := os.MkdirTemp("", "migrate-attachment-env-test-*")
105+ require.NoError(t, err)
106+ defer os.RemoveAll(tmpDir)
107+
108+ logger := zerowrap.Default()
109+ store, err := domainsecrets.NewPassStore(logger)
110+ require.NoError(t, err)
111+
112+ containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
113+ keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
114+ defer cleanupPassAttachment(t, containerName, keys)
115+
116+ envContent := "POSTGRES_USER=gitea\nPOSTGRES_PASSWORD=secret123\n"
117+ envFileName := "gordon-" + containerName + ".env"
118+ envFilePath := filepath.Join(tmpDir, envFileName)
119+ err = os.WriteFile(envFilePath, []byte(envContent), 0600)
120+ require.NoError(t, err)
121+
122+ err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger)
123+ require.NoError(t, err)
124+
125+ migratedPath := envFilePath + ".migrated"
126+ _, err = os.Stat(envFilePath)
127+ assert.True(t, os.IsNotExist(err), "original .env file should be removed")
128+
129+ _, err = os.Stat(migratedPath)
130+ assert.NoError(t, err, ".env.migrated file should exist")
131+
132+ values, err := store.GetAllAttachment(containerName)
133+ require.NoError(t, err)
134+ assert.Equal(t, "gitea", values["POSTGRES_USER"])
135+ assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"])
136+}
137+
138+func TestIsAttachmentEnvFile(t *testing.T) {
139+ tests := []struct {
140+ name string
141+ filename string
142+ want bool
143+ }{
144+ {
145+ name: "attachment env file",
146+ filename: "gordon-gitea-postgres.env",
147+ want: true,
148+ },
149+ {
150+ name: "attachment env file with complex name",
151+ filename: "gordon-git-example-com-gitea-postgres.env",
152+ want: true,
153+ },
154+ {
155+ name: "plain domain env file",
156+ filename: "example.com.env",
157+ want: false,
158+ },
159+ {
160+ name: "migrated attachment file",
161+ filename: "gordon-gitea-postgres.env.migrated",
162+ want: false,
163+ },
164+ {
165+ name: "non-env file",
166+ filename: "config.yaml",
167+ want: false,
168+ },
169+ {
170+ name: "gordon prefix but no env suffix",
171+ filename: "gordon-something",
172+ want: false,
173+ },
174+ }
175+
176+ for _, tt := range tests {
177+ t.Run(tt.name, func(t *testing.T) {
178+ got := isAttachmentEnvFile(tt.filename)
179+ assert.Equal(t, tt.want, got)
180+ })
181+ }
182+}
183+
184+func TestExtractContainerNameFromAttachmentFile(t *testing.T) {
185+ tests := []struct {
186+ name string
187+ filename string
188+ want string
189+ }{
190+ {
191+ name: "simple attachment file",
192+ filename: "gordon-gitea-postgres.env",
193+ want: "gordon-gitea-postgres",
194+ },
195+ {
196+ name: "complex attachment file",
197+ filename: "gordon-git-example-com-gitea-redis.env",
198+ want: "gordon-git-example-com-gitea-redis",
199+ },
200+ {
201+ name: "single word container",
202+ filename: "gordon-redis.env",
203+ want: "gordon-redis",
204+ },
205+ }
206+
207+ for _, tt := range tests {
208+ t.Run(tt.name, func(t *testing.T) {
209+ got := extractContainerNameFromAttachmentFile(tt.filename)
210+ assert.Equal(t, tt.want, got)
211+ })
212+ }
213+}
@@ -87,3 +87,127 @@ func TestMigrateEnvFile(t *testing.T) {
87 assert.Equal(t, "secret123", values["API_KEY"])87 assert.Equal(t, "secret123", values["API_KEY"])
88 assert.Equal(t, "pass456", values["DB_PASSWORD"])88 assert.Equal(t, "pass456", values["DB_PASSWORD"])
89 }89 }
90+
91+func cleanupPassAttachment(t *testing.T, containerName string, keys []string) {
92+ for _, key := range keys {
93+ path := fmt.Sprintf("%s/%s/%s", domainsecrets.PassAttachmentPath, containerName, key)
94+ _ = passCmd("rm", "-f", path)
95+ }
96+
97+ manifestPath := fmt.Sprintf("%s/%s/.keys", domainsecrets.PassAttachmentPath, containerName)
98+ _ = passCmd("rm", "-f", manifestPath)
99+}
100+
101+func TestMigrateAttachmentEnvFile(t *testing.T) {
102+ requirePass(t)
103+
104+ tmpDir, err := os.MkdirTemp("", "migrate-attachment-env-test-*")
105+ require.NoError(t, err)
106+ defer os.RemoveAll(tmpDir)
107+
108+ logger := zerowrap.Default()
109+ store, err := domainsecrets.NewPassStore(logger)
110+ require.NoError(t, err)
111+
112+ containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
113+ keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
114+ defer cleanupPassAttachment(t, containerName, keys)
115+
116+ envContent := "POSTGRES_USER=gitea\nPOSTGRES_PASSWORD=secret123\n"
117+ envFileName := "gordon-" + containerName + ".env"
118+ envFilePath := filepath.Join(tmpDir, envFileName)
119+ err = os.WriteFile(envFilePath, []byte(envContent), 0600)
120+ require.NoError(t, err)
121+
122+ err = migrateAttachmentEnvFile(tmpDir, envFileName, store, logger)
123+ require.NoError(t, err)
124+
125+ migratedPath := envFilePath + ".migrated"
126+ _, err = os.Stat(envFilePath)
127+ assert.True(t, os.IsNotExist(err), "original .env file should be removed")
128+
129+ _, err = os.Stat(migratedPath)
130+ assert.NoError(t, err, ".env.migrated file should exist")
131+
132+ values, err := store.GetAllAttachment(containerName)
133+ require.NoError(t, err)
134+ assert.Equal(t, "gitea", values["POSTGRES_USER"])
135+ assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"])
136+}
137+
138+func TestIsAttachmentEnvFile(t *testing.T) {
139+ tests := []struct {
140+ name string
141+ filename string
142+ want bool
143+ }{
144+ {
145+ name: "attachment env file",
146+ filename: "gordon-gitea-postgres.env",
147+ want: true,
148+ },
149+ {
150+ name: "attachment env file with complex name",
151+ filename: "gordon-git-example-com-gitea-postgres.env",
152+ want: true,
153+ },
154+ {
155+ name: "plain domain env file",
156+ filename: "example.com.env",
157+ want: false,
158+ },
159+ {
160+ name: "migrated attachment file",
161+ filename: "gordon-gitea-postgres.env.migrated",
162+ want: false,
163+ },
164+ {
165+ name: "non-env file",
166+ filename: "config.yaml",
167+ want: false,
168+ },
169+ {
170+ name: "gordon prefix but no env suffix",
171+ filename: "gordon-something",
172+ want: false,
173+ },
174+ }
175+
176+ for _, tt := range tests {
177+ t.Run(tt.name, func(t *testing.T) {
178+ got := isAttachmentEnvFile(tt.filename)
179+ assert.Equal(t, tt.want, got)
180+ })
181+ }
182+}
183+
184+func TestExtractContainerNameFromAttachmentFile(t *testing.T) {
185+ tests := []struct {
186+ name string
187+ filename string
188+ want string
189+ }{
190+ {
191+ name: "simple attachment file",
192+ filename: "gordon-gitea-postgres.env",
193+ want: "gordon-gitea-postgres",
194+ },
195+ {
196+ name: "complex attachment file",
197+ filename: "gordon-git-example-com-gitea-redis.env",
198+ want: "gordon-git-example-com-gitea-redis",
199+ },
200+ {
201+ name: "single word container",
202+ filename: "gordon-redis.env",
203+ want: "gordon-redis",
204+ },
205+ }
206+
207+ for _, tt := range tests {
208+ t.Run(tt.name, func(t *testing.T) {
209+ got := extractContainerNameFromAttachmentFile(tt.filename)
210+ assert.Equal(t, tt.want, got)
211+ })
212+ }
213+}

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