detached 8541735

refactor(secrets): extract shared sanitization logic to domain package

- Add SanitizeDomainForContainer and SanitizeDomainForContainerLegacy to domain package
- Update container service and secret stores to use shared sanitization functions
- Eliminate duplicate sanitization code across adapters
- Improve collision resistance with distinct separator replacements
commit
854173518eddad59f0c9f1e19a8580e7e665a596
author
bnema · · 2026-01-31 22:20 UTC
parent
0a85e4e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +474 -53
M internal/adapters/out/domainsecrets/pass_store.go +195 -3
@@ -229,7 +229,113 @@ func (s *PassStore) Delete(domainName, key string) error {
229229 return nil
230230 }
231231
232+// SetAttachment sets or updates multiple secrets for an attachment container.
233+func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error {
234+ if _, err := s.attachmentPath(containerName); err != nil {
235+ return err
236+ }
237+
238+ existingKeys, err := s.listAttachmentKeys(containerName)
239+ if err != nil {
240+ return err
241+ }
242+
243+ existingSet := make(map[string]struct{}, len(existingKeys))
244+ for _, key := range existingKeys {
245+ existingSet[key] = struct{}{}
246+ }
247+
248+ insertedNewPaths := make([]string, 0, len(secretsMap))
249+ for key, value := range secretsMap {
250+ if err := domain.ValidateEnvKey(key); err != nil {
251+ return err
252+ }
253+
254+ keyPath, err := s.attachmentKeyPath(containerName, key)
255+ if err != nil {
256+ return err
257+ }
258+
259+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
260+ err = s.passInsert(ctx, keyPath, value)
261+ cancel()
262+ if err != nil {
263+ s.cleanupInsertedPaths(insertedNewPaths)
264+ return fmt.Errorf("failed to store attachment secret %s: %w", key, err)
265+ }
266+
267+ if _, exists := existingSet[key]; !exists {
268+ insertedNewPaths = append(insertedNewPaths, keyPath)
269+ }
270+ }
271+
272+ keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
273+ for _, key := range existingKeys {
274+ keySet[key] = struct{}{}
275+ }
276+ for key := range secretsMap {
277+ keySet[key] = struct{}{}
278+ }
279+
280+ keys := make([]string, 0, len(keySet))
281+ for key := range keySet {
282+ keys = append(keys, key)
283+ }
284+ sort.Strings(keys)
285+
286+ manifestPath, err := s.attachmentManifestPath(containerName)
287+ if err != nil {
288+ return err
289+ }
290+
291+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
292+ if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
293+ cancel()
294+ s.cleanupInsertedPaths(insertedNewPaths)
295+ return fmt.Errorf("failed to update attachment manifest: %w", err)
296+ }
297+ cancel()
298+
299+ return nil
300+}
301+
302+// GetAllAttachment returns all secrets for an attachment container as a key-value map.
303+func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
304+ keys, err := s.listAttachmentKeys(containerName)
305+ if err != nil {
306+ return nil, err
307+ }
308+
309+ secretsMap := make(map[string]string)
310+ for _, key := range keys {
311+ keyPath, err := s.attachmentKeyPath(containerName, key)
312+ if err != nil {
313+ return nil, err
314+ }
315+
316+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
317+ value, exists, err := s.passShow(ctx, keyPath)
318+ cancel()
319+ if err != nil {
320+ return nil, err
321+ }
322+ if !exists {
323+ s.log.Warn().
324+ Str(zerowrap.FieldLayer, "adapter").
325+ Str(zerowrap.FieldAdapter, "domainsecrets").
326+ Str("container", containerName).
327+ Str("key", key).
328+ Msg("attachment secret listed in manifest but missing in pass")
329+ continue
330+ }
331+ secretsMap[key] = value
332+ }
333+
334+ return secretsMap, nil
335+}
336+
232337 // ListAttachmentKeys finds attachment secrets for a domain from pass.
338+// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
233339 func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
234340 if _, err := s.domainPath(domainName); err != nil {
235341 return nil, err
@@ -243,14 +349,33 @@ func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre
243349 return nil, err
244350 }
245351
246- sanitizedDomain := sanitizeDomainForContainer(domainName)
247- prefix := "gordon-" + sanitizedDomain + "-"
352+ // Try both new and legacy sanitization for backwards compatibility
353+ sanitizedDomain := domain.SanitizeDomainForContainer(domainName)
354+ sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
355+ prefixes := []string{
356+ "gordon-" + sanitizedDomain + "-", // New format (collision-resistant)
357+ "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible)
358+ }
248359
360+ seen := make(map[string]bool)
249361 var results []out.AttachmentSecrets
250362 for _, containerName := range containers {
251- if !strings.HasPrefix(containerName, prefix) {
363+ matches := false
364+ for _, prefix := range prefixes {
365+ if strings.HasPrefix(containerName, prefix) {
366+ matches = true
367+ break
368+ }
369+ }
370+ if !matches {
371+ continue
372+ }
373+
374+ // Deduplicate results
375+ if seen[containerName] {
252376 continue
253377 }
378+ seen[containerName] = true
254379
255380 manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)
256381 if err := secrets.ValidatePath(manifestPath); err != nil {
@@ -326,6 +451,73 @@ func (s *PassStore) manifestPath(domainName string) (string, error) {
326451 return path, nil
327452 }
328453
454+func (s *PassStore) attachmentPath(containerName string) (string, error) {
455+ if err := domain.ValidateContainerName(containerName); err != nil {
456+ return "", err
457+ }
458+ path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName)
459+ if err := secrets.ValidatePath(path); err != nil {
460+ return "", err
461+ }
462+ return path, nil
463+}
464+
465+func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) {
466+ attachmentPath, err := s.attachmentPath(containerName)
467+ if err != nil {
468+ return "", err
469+ }
470+ if err := domain.ValidateEnvKey(key); err != nil {
471+ return "", err
472+ }
473+ path := fmt.Sprintf("%s/%s", attachmentPath, key)
474+ if err := secrets.ValidatePath(path); err != nil {
475+ return "", err
476+ }
477+ return path, nil
478+}
479+
480+func (s *PassStore) attachmentManifestPath(containerName string) (string, error) {
481+ attachmentPath, err := s.attachmentPath(containerName)
482+ if err != nil {
483+ return "", err
484+ }
485+ path := fmt.Sprintf("%s/.keys", attachmentPath)
486+ if err := secrets.ValidatePath(path); err != nil {
487+ return "", err
488+ }
489+ return path, nil
490+}
491+
492+func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
493+ manifestPath, err := s.attachmentManifestPath(containerName)
494+ if err != nil {
495+ return nil, err
496+ }
497+
498+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
499+ defer cancel()
500+
501+ content, exists, err := s.passShow(ctx, manifestPath)
502+ if err != nil {
503+ return nil, err
504+ }
505+ if !exists {
506+ return []string{}, nil
507+ }
508+
509+ keys := []string{}
510+ for _, line := range strings.Split(content, "\n") {
511+ key := strings.TrimSpace(line)
512+ if key == "" {
513+ continue
514+ }
515+ keys = append(keys, key)
516+ }
517+
518+ return keys, nil
519+}
520+
329521 func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
330522 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
331523 if err != nil {
@@ -229,7 +229,113 @@ func (s *PassStore) Delete(domainName, key string) error {
229 return nil229 return nil
230 }230 }
231 231
232+// SetAttachment sets or updates multiple secrets for an attachment container.
233+func (s *PassStore) SetAttachment(containerName string, secretsMap map[string]string) error {
234+ if _, err := s.attachmentPath(containerName); err != nil {
235+ return err
236+ }
237+
238+ existingKeys, err := s.listAttachmentKeys(containerName)
239+ if err != nil {
240+ return err
241+ }
242+
243+ existingSet := make(map[string]struct{}, len(existingKeys))
244+ for _, key := range existingKeys {
245+ existingSet[key] = struct{}{}
246+ }
247+
248+ insertedNewPaths := make([]string, 0, len(secretsMap))
249+ for key, value := range secretsMap {
250+ if err := domain.ValidateEnvKey(key); err != nil {
251+ return err
252+ }
253+
254+ keyPath, err := s.attachmentKeyPath(containerName, key)
255+ if err != nil {
256+ return err
257+ }
258+
259+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
260+ err = s.passInsert(ctx, keyPath, value)
261+ cancel()
262+ if err != nil {
263+ s.cleanupInsertedPaths(insertedNewPaths)
264+ return fmt.Errorf("failed to store attachment secret %s: %w", key, err)
265+ }
266+
267+ if _, exists := existingSet[key]; !exists {
268+ insertedNewPaths = append(insertedNewPaths, keyPath)
269+ }
270+ }
271+
272+ keySet := make(map[string]struct{}, len(existingKeys)+len(secretsMap))
273+ for _, key := range existingKeys {
274+ keySet[key] = struct{}{}
275+ }
276+ for key := range secretsMap {
277+ keySet[key] = struct{}{}
278+ }
279+
280+ keys := make([]string, 0, len(keySet))
281+ for key := range keySet {
282+ keys = append(keys, key)
283+ }
284+ sort.Strings(keys)
285+
286+ manifestPath, err := s.attachmentManifestPath(containerName)
287+ if err != nil {
288+ return err
289+ }
290+
291+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
292+ if err := s.passInsert(ctx, manifestPath, strings.Join(keys, "\n")); err != nil {
293+ cancel()
294+ s.cleanupInsertedPaths(insertedNewPaths)
295+ return fmt.Errorf("failed to update attachment manifest: %w", err)
296+ }
297+ cancel()
298+
299+ return nil
300+}
301+
302+// GetAllAttachment returns all secrets for an attachment container as a key-value map.
303+func (s *PassStore) GetAllAttachment(containerName string) (map[string]string, error) {
304+ keys, err := s.listAttachmentKeys(containerName)
305+ if err != nil {
306+ return nil, err
307+ }
308+
309+ secretsMap := make(map[string]string)
310+ for _, key := range keys {
311+ keyPath, err := s.attachmentKeyPath(containerName, key)
312+ if err != nil {
313+ return nil, err
314+ }
315+
316+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
317+ value, exists, err := s.passShow(ctx, keyPath)
318+ cancel()
319+ if err != nil {
320+ return nil, err
321+ }
322+ if !exists {
323+ s.log.Warn().
324+ Str(zerowrap.FieldLayer, "adapter").
325+ Str(zerowrap.FieldAdapter, "domainsecrets").
326+ Str("container", containerName).
327+ Str("key", key).
328+ Msg("attachment secret listed in manifest but missing in pass")
329+ continue
330+ }
331+ secretsMap[key] = value
332+ }
333+
334+ return secretsMap, nil
335+}
336+
232 // ListAttachmentKeys finds attachment secrets for a domain from pass.337 // ListAttachmentKeys finds attachment secrets for a domain from pass.
338+// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
233 func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {339 func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
234 if _, err := s.domainPath(domainName); err != nil {340 if _, err := s.domainPath(domainName); err != nil {
235 return nil, err341 return nil, err
@@ -243,14 +349,33 @@ func (s *PassStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre
243 return nil, err349 return nil, err
244 }350 }
245 351
246- sanitizedDomain := sanitizeDomainForContainer(domainName)352+ // Try both new and legacy sanitization for backwards compatibility
247- prefix := "gordon-" + sanitizedDomain + "-"353+ sanitizedDomain := domain.SanitizeDomainForContainer(domainName)
354+ sanitizedDomainLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
355+ prefixes := []string{
356+ "gordon-" + sanitizedDomain + "-", // New format (collision-resistant)
357+ "gordon-" + sanitizedDomainLegacy + "-", // Old format (buggy but backwards compatible)
358+ }
248 359
360+ seen := make(map[string]bool)
249 var results []out.AttachmentSecrets361 var results []out.AttachmentSecrets
250 for _, containerName := range containers {362 for _, containerName := range containers {
251- if !strings.HasPrefix(containerName, prefix) {363+ matches := false
364+ for _, prefix := range prefixes {
365+ if strings.HasPrefix(containerName, prefix) {
366+ matches = true
367+ break
368+ }
369+ }
370+ if !matches {
371+ continue
372+ }
373+
374+ // Deduplicate results
375+ if seen[containerName] {
252 continue376 continue
253 }377 }
378+ seen[containerName] = true
254 379
255 manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)380 manifestPath := fmt.Sprintf("%s/%s/.keys", PassAttachmentPath, containerName)
256 if err := secrets.ValidatePath(manifestPath); err != nil {381 if err := secrets.ValidatePath(manifestPath); err != nil {
@@ -326,6 +451,73 @@ func (s *PassStore) manifestPath(domainName string) (string, error) {
326 return path, nil451 return path, nil
327 }452 }
328 453
454+func (s *PassStore) attachmentPath(containerName string) (string, error) {
455+ if err := domain.ValidateContainerName(containerName); err != nil {
456+ return "", err
457+ }
458+ path := fmt.Sprintf("%s/%s", PassAttachmentPath, containerName)
459+ if err := secrets.ValidatePath(path); err != nil {
460+ return "", err
461+ }
462+ return path, nil
463+}
464+
465+func (s *PassStore) attachmentKeyPath(containerName, key string) (string, error) {
466+ attachmentPath, err := s.attachmentPath(containerName)
467+ if err != nil {
468+ return "", err
469+ }
470+ if err := domain.ValidateEnvKey(key); err != nil {
471+ return "", err
472+ }
473+ path := fmt.Sprintf("%s/%s", attachmentPath, key)
474+ if err := secrets.ValidatePath(path); err != nil {
475+ return "", err
476+ }
477+ return path, nil
478+}
479+
480+func (s *PassStore) attachmentManifestPath(containerName string) (string, error) {
481+ attachmentPath, err := s.attachmentPath(containerName)
482+ if err != nil {
483+ return "", err
484+ }
485+ path := fmt.Sprintf("%s/.keys", attachmentPath)
486+ if err := secrets.ValidatePath(path); err != nil {
487+ return "", err
488+ }
489+ return path, nil
490+}
491+
492+func (s *PassStore) listAttachmentKeys(containerName string) ([]string, error) {
493+ manifestPath, err := s.attachmentManifestPath(containerName)
494+ if err != nil {
495+ return nil, err
496+ }
497+
498+ ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
499+ defer cancel()
500+
501+ content, exists, err := s.passShow(ctx, manifestPath)
502+ if err != nil {
503+ return nil, err
504+ }
505+ if !exists {
506+ return []string{}, nil
507+ }
508+
509+ keys := []string{}
510+ for _, line := range strings.Split(content, "\n") {
511+ key := strings.TrimSpace(line)
512+ if key == "" {
513+ continue
514+ }
515+ keys = append(keys, key)
516+ }
517+
518+ return keys, nil
519+}
520+
329 func (s *PassStore) sanitizeDomain(domainName string) (string, error) {521 func (s *PassStore) sanitizeDomain(domainName string) (string, error) {
330 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)522 safeDomain, err := domain.SanitizeDomainForEnvFile(domainName)
331 if err != nil {523 if err != nil {
M internal/adapters/out/domainsecrets/store.go +214 -42
@@ -6,6 +6,7 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "sort"
910 "strings"
1011
1112 "github.com/bnema/zerowrap"
@@ -148,6 +149,56 @@ func (s *FileStore) Delete(domainName, key string) error {
148149 return s.writeSecretsAtomic(domainName, existing)
149150 }
150151
152+// SetAttachment sets or updates multiple secrets for an attachment container.
153+func (s *FileStore) SetAttachment(containerName string, secrets map[string]string) error {
154+ // Validate container name first
155+ if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil {
156+ return err
157+ }
158+
159+ // Ensure env directory exists
160+ if err := os.MkdirAll(s.envDir, 0700); err != nil {
161+ return fmt.Errorf("failed to create env directory: %w", err)
162+ }
163+
164+ // Read existing secrets
165+ existing, err := s.GetAllAttachment(containerName)
166+ if err != nil {
167+ return err
168+ }
169+
170+ // Merge new secrets with existing
171+ for key, value := range secrets {
172+ existing[key] = value
173+ }
174+
175+ // Write back atomically
176+ return s.writeAttachmentSecretsAtomic(containerName, existing)
177+}
178+
179+// GetAllAttachment returns all secrets for an attachment container as a key-value map.
180+func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) {
181+ envFile, err := s.validateAttachmentEnvFilePath(containerName)
182+ if err != nil {
183+ return nil, err
184+ }
185+
186+ data, err := os.ReadFile(envFile)
187+ if err != nil {
188+ if os.IsNotExist(err) {
189+ return map[string]string{}, nil
190+ }
191+ return nil, fmt.Errorf("failed to read attachment env file: %w", err)
192+ }
193+
194+ secrets, err := domain.ParseEnvData(data)
195+ if err != nil {
196+ return nil, fmt.Errorf("failed to parse attachment env file: %w", err)
197+ }
198+
199+ return secrets, nil
200+}
201+
151202 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
152203 // It writes to a temporary file first, syncs it, then renames to the final path.
153204 func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
@@ -174,9 +225,15 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str
174225 return fmt.Errorf("failed to write header: %w", err)
175226 }
176227
177- // Write each secret
178- for key, value := range secrets {
179- if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {
228+ // Write each secret in sorted order for deterministic output
229+ keys := make([]string, 0, len(secrets))
230+ for key := range secrets {
231+ keys = append(keys, key)
232+ }
233+ sort.Strings(keys)
234+
235+ for _, key := range keys {
236+ if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil {
180237 file.Close()
181238 os.Remove(tmpFile)
182239 return fmt.Errorf("failed to write secret %s: %w", key, err)
@@ -248,12 +305,118 @@ func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
248305 return path, nil
249306 }
250307
308+// getAttachmentEnvFilePath converts a container name to its attachment env file path.
309+// Attachment env files follow the pattern: gordon-<containerName>.env
310+//
311+// SECURITY: Validates container name and ensures the resulting path stays within envDir.
312+func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) {
313+ if err := domain.ValidateContainerName(containerName); err != nil {
314+ s.log.Warn().
315+ Str(zerowrap.FieldLayer, "adapter").
316+ Str(zerowrap.FieldAdapter, "domainsecrets").
317+ Str("container", containerName).
318+ Err(err).
319+ Msg("rejected invalid container name")
320+ return "", err
321+ }
322+
323+ fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env")
324+
325+ // SECURITY: Final validation - ensure path stays within envDir
326+ cleanPath := filepath.Clean(fullPath)
327+ cleanEnvDir := filepath.Clean(s.envDir)
328+ if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
329+ s.log.Error().
330+ Str(zerowrap.FieldLayer, "adapter").
331+ Str(zerowrap.FieldAdapter, "domainsecrets").
332+ Str("container", containerName).
333+ Str("attempted_path", fullPath).
334+ Msg("path traversal attempt blocked - path escapes env directory")
335+ return "", domain.ErrPathTraversal
336+ }
337+
338+ return fullPath, nil
339+}
340+
341+// validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path.
342+// Returns an error if the container name is invalid or would result in path traversal.
343+func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) {
344+ return s.getAttachmentEnvFilePath(containerName)
345+}
346+
347+// writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.
348+// It writes to a temporary file first, syncs it, then renames to the final path.
349+func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {
350+ envFile, err := s.validateAttachmentEnvFilePath(containerName)
351+ if err != nil {
352+ return err
353+ }
354+ tmpFile := envFile + ".tmp"
355+
356+ file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
357+ if err != nil {
358+ return fmt.Errorf("failed to create temp attachment env file: %w", err)
359+ }
360+
361+ // Write header comment
362+ if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil {
363+ file.Close()
364+ os.Remove(tmpFile)
365+ return fmt.Errorf("failed to write header: %w", err)
366+ }
367+ if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
368+ file.Close()
369+ os.Remove(tmpFile)
370+ return fmt.Errorf("failed to write header: %w", err)
371+ }
372+
373+ // Write each secret in sorted order for deterministic output
374+ keys := make([]string, 0, len(secrets))
375+ for key := range secrets {
376+ keys = append(keys, key)
377+ }
378+ sort.Strings(keys)
379+
380+ for _, key := range keys {
381+ if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil {
382+ file.Close()
383+ os.Remove(tmpFile)
384+ return fmt.Errorf("failed to write secret %s: %w", key, err)
385+ }
386+ }
387+
388+ // Sync to ensure data is on disk before rename
389+ if err := file.Sync(); err != nil {
390+ file.Close()
391+ os.Remove(tmpFile)
392+ return fmt.Errorf("failed to sync attachment env file: %w", err)
393+ }
394+
395+ if err := file.Close(); err != nil {
396+ os.Remove(tmpFile)
397+ return fmt.Errorf("failed to close attachment env file: %w", err)
398+ }
399+
400+ // Atomic rename
401+ if err := os.Rename(tmpFile, envFile); err != nil {
402+ os.Remove(tmpFile)
403+ return fmt.Errorf("failed to rename attachment env file: %w", err)
404+ }
405+
406+ return nil
407+}
408+
251409 // ListAttachmentKeys finds attachment env files for a domain and returns their keys.
410+// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
252411 // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env
253412 func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
254- // Sanitize domain the same way container service does
255- sanitized := sanitizeDomainForContainer(domainName)
256- prefix := "gordon-" + sanitized + "-"
413+ // Try both new and legacy sanitization for backwards compatibility
414+ sanitized := domain.SanitizeDomainForContainer(domainName)
415+ sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
416+ prefixes := []string{
417+ "gordon-" + sanitized + "-", // New format (collision-resistant)
418+ "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible)
419+ }
257420
258421 // List all files in env directory
259422 entries, err := os.ReadDir(s.envDir)
@@ -264,57 +427,66 @@ func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre
264427 return nil, fmt.Errorf("failed to read env directory: %w", err)
265428 }
266429
430+ seen := make(map[string]bool)
267431 var results []out.AttachmentSecrets
268432 for _, entry := range entries {
269433 if entry.IsDir() {
270434 continue
271435 }
272436 name := entry.Name()
273- // Check if this is an attachment file for the domain
274- if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
275- // Extract service name from filename
276- // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres"
277- serviceName := strings.TrimPrefix(name, prefix)
278- serviceName = strings.TrimSuffix(serviceName, ".env")
279- if serviceName == "" {
280- continue
281- }
282437
283- // The full container name is the filename without .env
284- containerName := strings.TrimSuffix(name, ".env")
285-
286- // Read keys from this file using existing method
287- // Note: We use the container name directly since it matches the env file naming
288- keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name))
289- if err != nil {
290- s.log.Warn().
291- Err(err).
292- Str("file", name).
293- Str("domain", domainName).
294- Msg("failed to read attachment secrets file")
295- continue
438+ // Check if this matches any of our prefixes
439+ var matchedPrefix string
440+ for _, prefix := range prefixes {
441+ if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
442+ matchedPrefix = prefix
443+ break
296444 }
445+ }
446+ if matchedPrefix == "" {
447+ continue
448+ }
297449
298- if len(keys) > 0 {
299- results = append(results, out.AttachmentSecrets{
300- Service: containerName,
301- Keys: keys,
302- })
303- }
450+ // Extract container name from filename
451+ containerName := strings.TrimSuffix(name, ".env")
452+
453+ // Deduplicate results
454+ if seen[containerName] {
455+ continue
456+ }
457+ seen[containerName] = true
458+
459+ // Extract service name from filename
460+ // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres"
461+ serviceName := strings.TrimPrefix(name, matchedPrefix)
462+ serviceName = strings.TrimSuffix(serviceName, ".env")
463+ if serviceName == "" {
464+ continue
465+ }
466+
467+ // Read keys from this file using existing method
468+ // Note: We use the container name directly since it matches the env file naming
469+ keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name))
470+ if err != nil {
471+ s.log.Warn().
472+ Err(err).
473+ Str("file", name).
474+ Str("domain", domainName).
475+ Msg("failed to read attachment secrets file")
476+ continue
477+ }
478+
479+ if len(keys) > 0 {
480+ results = append(results, out.AttachmentSecrets{
481+ Service: containerName,
482+ Keys: keys,
483+ })
304484 }
305485 }
306486
307487 return results, nil
308488 }
309489
310-// sanitizeDomainForContainer matches the sanitizeName function in container service.
311-func sanitizeDomainForContainer(domain string) string {
312- result := strings.ReplaceAll(domain, ".", "-")
313- result = strings.ReplaceAll(result, ":", "-")
314- result = strings.ReplaceAll(result, "/", "-")
315- return result
316-}
317-
318490 // listKeysFromFile reads secret keys from a specific env file path.
319491 func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) {
320492 file, err := os.Open(filePath)
@@ -6,6 +6,7 @@ import (
6 "fmt"6 "fmt"
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9+ "sort"
9 "strings"10 "strings"
10 11
11 "github.com/bnema/zerowrap"12 "github.com/bnema/zerowrap"
@@ -148,6 +149,56 @@ func (s *FileStore) Delete(domainName, key string) error {
148 return s.writeSecretsAtomic(domainName, existing)149 return s.writeSecretsAtomic(domainName, existing)
149 }150 }
150 151
152+// SetAttachment sets or updates multiple secrets for an attachment container.
153+func (s *FileStore) SetAttachment(containerName string, secrets map[string]string) error {
154+ // Validate container name first
155+ if _, err := s.validateAttachmentEnvFilePath(containerName); err != nil {
156+ return err
157+ }
158+
159+ // Ensure env directory exists
160+ if err := os.MkdirAll(s.envDir, 0700); err != nil {
161+ return fmt.Errorf("failed to create env directory: %w", err)
162+ }
163+
164+ // Read existing secrets
165+ existing, err := s.GetAllAttachment(containerName)
166+ if err != nil {
167+ return err
168+ }
169+
170+ // Merge new secrets with existing
171+ for key, value := range secrets {
172+ existing[key] = value
173+ }
174+
175+ // Write back atomically
176+ return s.writeAttachmentSecretsAtomic(containerName, existing)
177+}
178+
179+// GetAllAttachment returns all secrets for an attachment container as a key-value map.
180+func (s *FileStore) GetAllAttachment(containerName string) (map[string]string, error) {
181+ envFile, err := s.validateAttachmentEnvFilePath(containerName)
182+ if err != nil {
183+ return nil, err
184+ }
185+
186+ data, err := os.ReadFile(envFile)
187+ if err != nil {
188+ if os.IsNotExist(err) {
189+ return map[string]string{}, nil
190+ }
191+ return nil, fmt.Errorf("failed to read attachment env file: %w", err)
192+ }
193+
194+ secrets, err := domain.ParseEnvData(data)
195+ if err != nil {
196+ return nil, fmt.Errorf("failed to parse attachment env file: %w", err)
197+ }
198+
199+ return secrets, nil
200+}
201+
151 // writeSecretsAtomic writes all secrets to the domain's env file atomically.202 // writeSecretsAtomic writes all secrets to the domain's env file atomically.
152 // It writes to a temporary file first, syncs it, then renames to the final path.203 // It writes to a temporary file first, syncs it, then renames to the final path.
153 func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {204 func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]string) error {
@@ -174,9 +225,15 @@ func (s *FileStore) writeSecretsAtomic(domainName string, secrets map[string]str
174 return fmt.Errorf("failed to write header: %w", err)225 return fmt.Errorf("failed to write header: %w", err)
175 }226 }
176 227
177- // Write each secret228+ // Write each secret in sorted order for deterministic output
178- for key, value := range secrets {229+ keys := make([]string, 0, len(secrets))
179- if _, err := fmt.Fprintf(file, "%s=%s\n", key, value); err != nil {230+ for key := range secrets {
231+ keys = append(keys, key)
232+ }
233+ sort.Strings(keys)
234+
235+ for _, key := range keys {
236+ if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil {
180 file.Close()237 file.Close()
181 os.Remove(tmpFile)238 os.Remove(tmpFile)
182 return fmt.Errorf("failed to write secret %s: %w", key, err)239 return fmt.Errorf("failed to write secret %s: %w", key, err)
@@ -248,12 +305,118 @@ func (s *FileStore) validateEnvFilePath(domainName string) (string, error) {
248 return path, nil305 return path, nil
249 }306 }
250 307
308+// getAttachmentEnvFilePath converts a container name to its attachment env file path.
309+// Attachment env files follow the pattern: gordon-<containerName>.env
310+//
311+// SECURITY: Validates container name and ensures the resulting path stays within envDir.
312+func (s *FileStore) getAttachmentEnvFilePath(containerName string) (string, error) {
313+ if err := domain.ValidateContainerName(containerName); err != nil {
314+ s.log.Warn().
315+ Str(zerowrap.FieldLayer, "adapter").
316+ Str(zerowrap.FieldAdapter, "domainsecrets").
317+ Str("container", containerName).
318+ Err(err).
319+ Msg("rejected invalid container name")
320+ return "", err
321+ }
322+
323+ fullPath := filepath.Join(s.envDir, "gordon-"+containerName+".env")
324+
325+ // SECURITY: Final validation - ensure path stays within envDir
326+ cleanPath := filepath.Clean(fullPath)
327+ cleanEnvDir := filepath.Clean(s.envDir)
328+ if !strings.HasPrefix(cleanPath, cleanEnvDir+string(filepath.Separator)) {
329+ s.log.Error().
330+ Str(zerowrap.FieldLayer, "adapter").
331+ Str(zerowrap.FieldAdapter, "domainsecrets").
332+ Str("container", containerName).
333+ Str("attempted_path", fullPath).
334+ Msg("path traversal attempt blocked - path escapes env directory")
335+ return "", domain.ErrPathTraversal
336+ }
337+
338+ return fullPath, nil
339+}
340+
341+// validateAttachmentEnvFilePath validates that a container name produces a valid attachment env file path.
342+// Returns an error if the container name is invalid or would result in path traversal.
343+func (s *FileStore) validateAttachmentEnvFilePath(containerName string) (string, error) {
344+ return s.getAttachmentEnvFilePath(containerName)
345+}
346+
347+// writeAttachmentSecretsAtomic writes all secrets to the attachment's env file atomically.
348+// It writes to a temporary file first, syncs it, then renames to the final path.
349+func (s *FileStore) writeAttachmentSecretsAtomic(containerName string, secrets map[string]string) error {
350+ envFile, err := s.validateAttachmentEnvFilePath(containerName)
351+ if err != nil {
352+ return err
353+ }
354+ tmpFile := envFile + ".tmp"
355+
356+ file, err := os.OpenFile(tmpFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
357+ if err != nil {
358+ return fmt.Errorf("failed to create temp attachment env file: %w", err)
359+ }
360+
361+ // Write header comment
362+ if _, err := fmt.Fprintf(file, "# Environment variables for attachment %s\n", containerName); err != nil {
363+ file.Close()
364+ os.Remove(tmpFile)
365+ return fmt.Errorf("failed to write header: %w", err)
366+ }
367+ if _, err := fmt.Fprintf(file, "# Managed by Gordon admin API\n\n"); err != nil {
368+ file.Close()
369+ os.Remove(tmpFile)
370+ return fmt.Errorf("failed to write header: %w", err)
371+ }
372+
373+ // Write each secret in sorted order for deterministic output
374+ keys := make([]string, 0, len(secrets))
375+ for key := range secrets {
376+ keys = append(keys, key)
377+ }
378+ sort.Strings(keys)
379+
380+ for _, key := range keys {
381+ if _, err := fmt.Fprintf(file, "%s=%s\n", key, secrets[key]); err != nil {
382+ file.Close()
383+ os.Remove(tmpFile)
384+ return fmt.Errorf("failed to write secret %s: %w", key, err)
385+ }
386+ }
387+
388+ // Sync to ensure data is on disk before rename
389+ if err := file.Sync(); err != nil {
390+ file.Close()
391+ os.Remove(tmpFile)
392+ return fmt.Errorf("failed to sync attachment env file: %w", err)
393+ }
394+
395+ if err := file.Close(); err != nil {
396+ os.Remove(tmpFile)
397+ return fmt.Errorf("failed to close attachment env file: %w", err)
398+ }
399+
400+ // Atomic rename
401+ if err := os.Rename(tmpFile, envFile); err != nil {
402+ os.Remove(tmpFile)
403+ return fmt.Errorf("failed to rename attachment env file: %w", err)
404+ }
405+
406+ return nil
407+}
408+
251 // ListAttachmentKeys finds attachment env files for a domain and returns their keys.409 // ListAttachmentKeys finds attachment env files for a domain and returns their keys.
410+// Supports both new (collision-resistant) and legacy container naming for backwards compatibility.
252 // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env411 // Attachment env files follow the naming pattern: gordon-{sanitized-domain}-{service}.env
253 func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {412 func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecrets, error) {
254- // Sanitize domain the same way container service does413+ // Try both new and legacy sanitization for backwards compatibility
255- sanitized := sanitizeDomainForContainer(domainName)414+ sanitized := domain.SanitizeDomainForContainer(domainName)
256- prefix := "gordon-" + sanitized + "-"415+ sanitizedLegacy := domain.SanitizeDomainForContainerLegacy(domainName)
416+ prefixes := []string{
417+ "gordon-" + sanitized + "-", // New format (collision-resistant)
418+ "gordon-" + sanitizedLegacy + "-", // Old format (buggy but backwards compatible)
419+ }
257 420
258 // List all files in env directory421 // List all files in env directory
259 entries, err := os.ReadDir(s.envDir)422 entries, err := os.ReadDir(s.envDir)
@@ -264,57 +427,66 @@ func (s *FileStore) ListAttachmentKeys(domainName string) ([]out.AttachmentSecre
264 return nil, fmt.Errorf("failed to read env directory: %w", err)427 return nil, fmt.Errorf("failed to read env directory: %w", err)
265 }428 }
266 429
430+ seen := make(map[string]bool)
267 var results []out.AttachmentSecrets431 var results []out.AttachmentSecrets
268 for _, entry := range entries {432 for _, entry := range entries {
269 if entry.IsDir() {433 if entry.IsDir() {
270 continue434 continue
271 }435 }
272 name := entry.Name()436 name := entry.Name()
273- // Check if this is an attachment file for the domain
274- if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
275- // Extract service name from filename
276- // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres"
277- serviceName := strings.TrimPrefix(name, prefix)
278- serviceName = strings.TrimSuffix(serviceName, ".env")
279- if serviceName == "" {
280- continue
281- }
282 437
283- // The full container name is the filename without .env438+ // Check if this matches any of our prefixes
284- containerName := strings.TrimSuffix(name, ".env")439+ var matchedPrefix string
285-440+ for _, prefix := range prefixes {
286- // Read keys from this file using existing method441+ if strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".env") {
287- // Note: We use the container name directly since it matches the env file naming442+ matchedPrefix = prefix
288- keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name))443+ break
289- if err != nil {
290- s.log.Warn().
291- Err(err).
292- Str("file", name).
293- Str("domain", domainName).
294- Msg("failed to read attachment secrets file")
295- continue
296 }444 }
445+ }
446+ if matchedPrefix == "" {
447+ continue
448+ }
297 449
298- if len(keys) > 0 {450+ // Extract container name from filename
299- results = append(results, out.AttachmentSecrets{451+ containerName := strings.TrimSuffix(name, ".env")
300- Service: containerName,452+
301- Keys: keys,453+ // Deduplicate results
302- })454+ if seen[containerName] {
303- }455+ continue
456+ }
457+ seen[containerName] = true
458+
459+ // Extract service name from filename
460+ // e.g., "gordon-git-bnema-dev-gitea-postgres.env" → "gitea-postgres"
461+ serviceName := strings.TrimPrefix(name, matchedPrefix)
462+ serviceName = strings.TrimSuffix(serviceName, ".env")
463+ if serviceName == "" {
464+ continue
465+ }
466+
467+ // Read keys from this file using existing method
468+ // Note: We use the container name directly since it matches the env file naming
469+ keys, err := s.listKeysFromFile(filepath.Join(s.envDir, name))
470+ if err != nil {
471+ s.log.Warn().
472+ Err(err).
473+ Str("file", name).
474+ Str("domain", domainName).
475+ Msg("failed to read attachment secrets file")
476+ continue
477+ }
478+
479+ if len(keys) > 0 {
480+ results = append(results, out.AttachmentSecrets{
481+ Service: containerName,
482+ Keys: keys,
483+ })
304 }484 }
305 }485 }
306 486
307 return results, nil487 return results, nil
308 }488 }
309 489
310-// sanitizeDomainForContainer matches the sanitizeName function in container service.
311-func sanitizeDomainForContainer(domain string) string {
312- result := strings.ReplaceAll(domain, ".", "-")
313- result = strings.ReplaceAll(result, ":", "-")
314- result = strings.ReplaceAll(result, "/", "-")
315- return result
316-}
317-
318 // listKeysFromFile reads secret keys from a specific env file path.490 // listKeysFromFile reads secret keys from a specific env file path.
319 func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) {491 func (s *FileStore) listKeysFromFile(filePath string) ([]string, error) {
320 file, err := os.Open(filePath)492 file, err := os.Open(filePath)
A internal/domain/sanitize.go +38 -0
@@ -0,0 +1,38 @@
1+package domain
2+
3+import "strings"
4+
5+// SanitizeDomainForContainer makes a domain safe for container naming.
6+// It uses distinct replacements to avoid collisions between domains like
7+// "git.example.com" and "git-example.com" (which would both become "git-example-com"
8+// if we naively replaced all separators with hyphens).
9+//
10+// Replacements:
11+//
12+// . → __ (double underscore, distinct from single _ in some conventions)
13+// : → -_ (hyphen-underscore combo, used for ports)
14+// / → -- (double hyphen, distinct from colon to avoid :/ collisions)
15+func SanitizeDomainForContainer(domain string) string {
16+ result := strings.ReplaceAll(domain, ".", "__")
17+ result = strings.ReplaceAll(result, ":", "-_")
18+ result = strings.ReplaceAll(result, "/", "--")
19+ return result
20+}
21+
22+// SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces
23+// all separators with hyphens, for backwards compatibility.
24+//
25+// IMPORTANT: This is maintained ONLY for finding old containers during upgrades.
26+// New containers always use the collision-resistant SanitizeDomainForContainer().
27+//
28+// Migration strategy:
29+// - During binary upgrades, legacy containers are found using this function
30+// - Old containers are stopped and removed, then replaced with new containers
31+// - New containers use the collision-resistant SanitizeDomainForContainer() naming
32+// - After all deployments are upgraded, this function can be removed in a major version
33+func SanitizeDomainForContainerLegacy(domain string) string {
34+ result := strings.ReplaceAll(domain, ".", "-")
35+ result = strings.ReplaceAll(result, ":", "-")
36+ result = strings.ReplaceAll(result, "/", "-")
37+ return result
38+}
@@ -0,0 +1,38 @@
1+package domain
2+
3+import "strings"
4+
5+// SanitizeDomainForContainer makes a domain safe for container naming.
6+// It uses distinct replacements to avoid collisions between domains like
7+// "git.example.com" and "git-example.com" (which would both become "git-example-com"
8+// if we naively replaced all separators with hyphens).
9+//
10+// Replacements:
11+//
12+// . → __ (double underscore, distinct from single _ in some conventions)
13+// : → -_ (hyphen-underscore combo, used for ports)
14+// / → -- (double hyphen, distinct from colon to avoid :/ collisions)
15+func SanitizeDomainForContainer(domain string) string {
16+ result := strings.ReplaceAll(domain, ".", "__")
17+ result = strings.ReplaceAll(result, ":", "-_")
18+ result = strings.ReplaceAll(result, "/", "--")
19+ return result
20+}
21+
22+// SanitizeDomainForContainerLegacy uses the OLD (buggy) sanitization that replaces
23+// all separators with hyphens, for backwards compatibility.
24+//
25+// IMPORTANT: This is maintained ONLY for finding old containers during upgrades.
26+// New containers always use the collision-resistant SanitizeDomainForContainer().
27+//
28+// Migration strategy:
29+// - During binary upgrades, legacy containers are found using this function
30+// - Old containers are stopped and removed, then replaced with new containers
31+// - New containers use the collision-resistant SanitizeDomainForContainer() naming
32+// - After all deployments are upgraded, this function can be removed in a major version
33+func SanitizeDomainForContainerLegacy(domain string) string {
34+ result := strings.ReplaceAll(domain, ".", "-")
35+ result = strings.ReplaceAll(result, ":", "-")
36+ result = strings.ReplaceAll(result, "/", "-")
37+ return result
38+}
M internal/usecase/container/service.go +27 -8
@@ -1095,7 +1095,25 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
10951095 })
10961096
10971097 // Check if already running (idempotent)
1098+ // Try new name first, then fall back to legacy name for backwards compatibility
10981099 existingContainer := s.findContainerByName(ctx, containerName)
1100+ if existingContainer == nil {
1101+ // Try legacy naming scheme for backwards compatibility during upgrades
1102+ containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName)
1103+ existingContainer = s.findContainerByName(ctx, containerNameLegacy)
1104+ if existingContainer != nil {
1105+ log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
1106+ // Remove the old container and recreate with new name
1107+ if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
1108+ return log.WrapErr(err, "failed to stop legacy attachment container")
1109+ }
1110+ if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1111+ return log.WrapErr(err, "failed to remove legacy attachment container")
1112+ }
1113+ existingContainer = nil // Clear so we create new one below
1114+ }
1115+ }
1116+
10991117 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
11001118 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
11011119 return nil
@@ -1105,7 +1123,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
11051123 if existingContainer != nil {
11061124 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
11071125 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1108- log.WrapErr(err, "failed to remove existing attachment container")
1126+ return log.WrapErr(err, "failed to remove existing attachment container")
11091127 }
11101128 }
11111129
@@ -1306,13 +1324,14 @@ func extractServiceName(image string) string {
13061324 return name
13071325 }
13081326
1309-// sanitizeName makes a domain safe for container naming.
1310-func sanitizeName(domain string) string {
1311- // Replace dots and other special chars with dashes
1312- result := strings.ReplaceAll(domain, ".", "-")
1313- result = strings.ReplaceAll(result, ":", "-")
1314- result = strings.ReplaceAll(result, "/", "-")
1315- return result
1327+// sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer.
1328+func sanitizeName(d string) string {
1329+ return domain.SanitizeDomainForContainer(d)
1330+}
1331+
1332+// sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy.
1333+func sanitizeNameLegacy(d string) string {
1334+ return domain.SanitizeDomainForContainerLegacy(d)
13161335 }
13171336
13181337 // findContainerByName finds a container by its name.
@@ -1095,7 +1095,25 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
1095 })1095 })
1096 1096
1097 // Check if already running (idempotent)1097 // Check if already running (idempotent)
1098+ // Try new name first, then fall back to legacy name for backwards compatibility
1098 existingContainer := s.findContainerByName(ctx, containerName)1099 existingContainer := s.findContainerByName(ctx, containerName)
1100+ if existingContainer == nil {
1101+ // Try legacy naming scheme for backwards compatibility during upgrades
1102+ containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName)
1103+ existingContainer = s.findContainerByName(ctx, containerNameLegacy)
1104+ if existingContainer != nil {
1105+ log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
1106+ // Remove the old container and recreate with new name
1107+ if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
1108+ return log.WrapErr(err, "failed to stop legacy attachment container")
1109+ }
1110+ if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1111+ return log.WrapErr(err, "failed to remove legacy attachment container")
1112+ }
1113+ existingContainer = nil // Clear so we create new one below
1114+ }
1115+ }
1116+
1099 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {1117 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
1100 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")1118 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
1101 return nil1119 return nil
@@ -1105,7 +1123,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
1105 if existingContainer != nil {1123 if existingContainer != nil {
1106 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")1124 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
1107 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {1125 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1108- log.WrapErr(err, "failed to remove existing attachment container")1126+ return log.WrapErr(err, "failed to remove existing attachment container")
1109 }1127 }
1110 }1128 }
1111 1129
@@ -1306,13 +1324,14 @@ func extractServiceName(image string) string {
1306 return name1324 return name
1307 }1325 }
1308 1326
1309-// sanitizeName makes a domain safe for container naming.1327+// sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer.
1310-func sanitizeName(domain string) string {1328+func sanitizeName(d string) string {
1311- // Replace dots and other special chars with dashes1329+ return domain.SanitizeDomainForContainer(d)
1312- result := strings.ReplaceAll(domain, ".", "-")1330+}
1313- result = strings.ReplaceAll(result, ":", "-")1331+
1314- result = strings.ReplaceAll(result, "/", "-")1332+// sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy.
1315- return result1333+func sanitizeNameLegacy(d string) string {
1334+ return domain.SanitizeDomainForContainerLegacy(d)
1316 }1335 }
1317 1336
1318 // findContainerByName finds a container by its name.1337 // findContainerByName finds a container by its name.

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