detached 30c748c

fix(container): preserve legacy attachment volumes (#236)

commit
30c748c6f79baddec959ddde1a4f4e9590cbd282
author
bnema · · 2026-08-31 10:03 UTC
committer
GitHub · · 2026-08-31 10:03 UTC
parent
e02f654
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
3 files · +411 -62
M internal/domain/errors.go +4 -3
@@ -42,9 +42,10 @@ var (
4242 ErrNetworkExists = errors.New("network already exists")
4343
4444 // Volume errors
45- ErrVolumeNotFound = errors.New("volume not found")
46- ErrVolumeExists = errors.New("volume already exists")
47- ErrVolumeBackupUnavailable = errors.New("volume backup service unavailable")
45+ ErrVolumeNotFound = errors.New("volume not found")
46+ ErrVolumeExists = errors.New("volume already exists")
47+ ErrVolumeBackupUnavailable = errors.New("volume backup service unavailable")
48+ ErrVolumeOwnershipUnverified = errors.New("volume ownership cannot be verified")
4849
4950 // Preview errors
5051 ErrPreviewNotFound = errors.New("preview not found")
@@ -42,9 +42,10 @@ var (
42 ErrNetworkExists = errors.New("network already exists")42 ErrNetworkExists = errors.New("network already exists")
43 43
44 // Volume errors44 // Volume errors
45- ErrVolumeNotFound = errors.New("volume not found")45+ ErrVolumeNotFound = errors.New("volume not found")
46- ErrVolumeExists = errors.New("volume already exists")46+ ErrVolumeExists = errors.New("volume already exists")
47- ErrVolumeBackupUnavailable = errors.New("volume backup service unavailable")47+ ErrVolumeBackupUnavailable = errors.New("volume backup service unavailable")
48+ ErrVolumeOwnershipUnverified = errors.New("volume ownership cannot be verified")
48 49
49 // Preview errors50 // Preview errors
50 ErrPreviewNotFound = errors.New("preview not found")51 ErrPreviewNotFound = errors.New("preview not found")
M internal/usecase/container/service.go +129 -57
@@ -607,7 +607,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
607607 }
608608 envHash := hashEnvironment(envVars)
609609
610- volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
610+ volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef, nil)
611611 if err != nil {
612612 return nil, err
613613 }
@@ -2513,13 +2513,16 @@ func (s *Service) loadEnvironment(ctx context.Context, preResolved []string, dom
25132513 return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
25142514 }
25152515
2516-func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
2516+func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string, preferredVolumes map[string]namedVolumeMount) (map[string]string, error) {
25172517 s.mu.RLock()
25182518 cfg := s.config
25192519 s.mu.RUnlock()
25202520
25212521 log := zerowrap.FromCtx(ctx)
2522- volumes := make(map[string]string)
2522+ volumes, err := s.validatePreferredVolumes(ctx, preferredVolumes)
2523+ if err != nil {
2524+ return nil, err
2525+ }
25232526
25242527 if !cfg.VolumeAutoCreate {
25252528 return volumes, nil
@@ -2532,6 +2535,10 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
25322535 }
25332536
25342537 for _, path := range volumePaths {
2538+ if _, preferred := volumes[path]; preferred {
2539+ continue
2540+ }
2541+
25352542 name := generateVolumeName(cfg.VolumePrefix, domainName, path)
25362543 legacyName := legacyVolumeName(cfg.VolumePrefix, domainName, path)
25372544 legacyExists, err := s.runtime.VolumeExists(ctx, legacyName)
@@ -2539,11 +2546,12 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
25392546 log.WrapErrWithFields(err, "failed to check legacy volume", map[string]any{"volume": legacyName})
25402547 continue
25412548 }
2549+ legacyOwnership := legacyVolumeOwnershipUnverified
25422550 if legacyExists {
2543- owned, ownershipErr := s.legacyVolumeOwnedByDomain(ctx, legacyName, domainName)
2544- if ownershipErr != nil {
2545- log.Warn().Err(ownershipErr).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name")
2546- } else if owned {
2551+ legacyOwnership, err = s.legacyVolumeOwnership(ctx, legacyName, domainName)
2552+ if err != nil {
2553+ log.Warn().Err(err).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name")
2554+ } else if legacyOwnership == legacyVolumeOwnershipOwned {
25472555 volumes[path] = legacyName
25482556 continue
25492557 }
@@ -2556,6 +2564,11 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
25562564 }
25572565
25582566 if !exists {
2567+ if legacyExists && legacyOwnership != legacyVolumeOwnershipForeign {
2568+ // No verified mount may mean the owning container was already removed;
2569+ // it does not prove that the preserved volume belongs elsewhere.
2570+ return nil, fmt.Errorf("legacy volume %q exists but its ownership cannot be verified; refusing to create a replacement volume: %w", legacyName, domain.ErrVolumeOwnershipUnverified)
2571+ }
25592572 if err := s.runtime.CreateVolume(ctx, name); err != nil {
25602573 log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
25612574 continue
@@ -2569,6 +2582,24 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
25692582 return volumes, nil
25702583 }
25712584
2585+func (s *Service) validatePreferredVolumes(ctx context.Context, preferredVolumes map[string]namedVolumeMount) (map[string]string, error) {
2586+ volumes := make(map[string]string)
2587+ for path, preferred := range preferredVolumes {
2588+ if preferred.Name == "" {
2589+ continue
2590+ }
2591+ exists, err := s.runtime.VolumeExists(ctx, preferred.Name)
2592+ if err != nil {
2593+ return nil, fmt.Errorf("check previously mounted volume %q: %w", preferred.Name, err)
2594+ }
2595+ if !exists {
2596+ return nil, fmt.Errorf("previously mounted volume %q no longer exists: %w", preferred.Name, domain.ErrVolumeNotFound)
2597+ }
2598+ volumes[path] = preferred.Name
2599+ }
2600+ return volumes, nil
2601+}
2602+
25722603 func (s *Service) getNetworkForApp(domainName string) string {
25732604 var networkIsolation bool
25742605 var networkGroups map[string][]string
@@ -2922,8 +2953,9 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
29222953 if err != nil {
29232954 return err
29242955 }
2956+ preferredVolumes := namedVolumeMounts(existingContainer)
29252957
2926- if existingContainer != nil {
2958+ if existingContainer != nil && existingContainer.Name == containerName {
29272959 shouldSkip, err := s.handleRunningAttachment(ctx, existingContainer, containerName, serviceImage)
29282960 if err != nil {
29292961 return err
@@ -2931,22 +2963,19 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
29312963 if shouldSkip {
29322964 return nil
29332965 }
2934- if existingContainer.Status == string(domain.ContainerStatusRunning) {
2935- existingContainer = nil
2936- }
2937- }
2938-
2939- if err := s.removeStoppedAttachment(ctx, existingContainer, containerName); err != nil {
2940- return err
29412966 }
29422967
29432968 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
29442969
2945- config, err := s.buildAttachmentContainerConfig(ctx, ownerDomain, serviceName, serviceImage, containerName, networkName)
2970+ config, err := s.buildAttachmentContainerConfig(ctx, ownerDomain, serviceName, serviceImage, containerName, networkName, preferredVolumes)
29462971 if err != nil {
29472972 return err
29482973 }
29492974
2975+ if err := s.removeAttachmentForReplacement(ctx, existingContainer, containerName); err != nil {
2976+ return err
2977+ }
2978+
29502979 container, err := s.runtime.CreateContainer(ctx, config)
29512980 if err != nil {
29522981 return log.WrapErr(err, "failed to create attachment container")
@@ -2979,7 +3008,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
29793008 return nil
29803009 }
29813010
2982-func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomain, serviceName, serviceImage, containerName, networkName string) (*domain.ContainerConfig, error) {
3011+func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomain, serviceName, serviceImage, containerName, networkName string, preferredVolumes map[string]namedVolumeMount) (*domain.ContainerConfig, error) {
29833012 imageRef, err := s.buildValidatedImageRef(ctx, serviceImage)
29843013 if err != nil {
29853014 return nil, err
@@ -2990,7 +3019,7 @@ func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomai
29903019 }
29913020
29923021 exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef)
2993- volumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef)
3022+ volumes, readOnlyVolumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef, preferredVolumes)
29943023 if err != nil {
29953024 return nil, err
29963025 }
@@ -3004,14 +3033,15 @@ func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomai
30043033 s.mu.RUnlock()
30053034
30063035 config := &domain.ContainerConfig{
3007- Image: actualImageRef,
3008- Name: containerName,
3009- Hostname: serviceName,
3010- Aliases: []string{serviceName},
3011- Ports: exposedPorts,
3012- Env: envVars,
3013- Volumes: volumes,
3014- NetworkMode: networkName,
3036+ Image: actualImageRef,
3037+ Name: containerName,
3038+ Hostname: serviceName,
3039+ Aliases: []string{serviceName},
3040+ Ports: exposedPorts,
3041+ Env: envVars,
3042+ Volumes: volumes,
3043+ ReadOnlyVolumes: readOnlyVolumes,
3044+ NetworkMode: networkName,
30153045 Labels: map[string]string{
30163046 domain.LabelManaged: "true",
30173047 domain.LabelAttachment: "true",
@@ -3038,12 +3068,37 @@ func (s *Service) attachmentExposedPorts(ctx context.Context, imageRef string) [
30383068 return exposedPorts
30393069 }
30403070
3041-func (s *Service) attachmentVolumes(ctx context.Context, containerName, imageRef string) (map[string]string, error) {
3042- volumes, err := s.setupVolumes(ctx, containerName, imageRef)
3071+func (s *Service) attachmentVolumes(ctx context.Context, containerName, imageRef string, preferredVolumes map[string]namedVolumeMount) (map[string]string, map[string]string, error) {
3072+ volumes, err := s.setupVolumes(ctx, containerName, imageRef, preferredVolumes)
30433073 if err != nil {
3044- return nil, fmt.Errorf("failed to setup volumes for attachment %s with image %s: %w", containerName, imageRef, err)
3074+ return nil, nil, fmt.Errorf("failed to setup volumes for attachment %s with image %s: %w", containerName, imageRef, err)
30453075 }
3046- return volumes, nil
3076+ readOnlyVolumes := make(map[string]string)
3077+ for path, preferred := range preferredVolumes {
3078+ if preferred.ReadOnly && volumes[path] == preferred.Name {
3079+ delete(volumes, path)
3080+ readOnlyVolumes[path] = preferred.Name
3081+ }
3082+ }
3083+ return volumes, readOnlyVolumes, nil
3084+}
3085+
3086+type namedVolumeMount struct {
3087+ Name string
3088+ ReadOnly bool
3089+}
3090+
3091+func namedVolumeMounts(container *domain.Container) map[string]namedVolumeMount {
3092+ volumes := make(map[string]namedVolumeMount)
3093+ if container == nil {
3094+ return volumes
3095+ }
3096+ for _, mount := range container.VolumeMounts {
3097+ if mount.Type == "volume" && mount.Name != "" && mount.Destination != "" {
3098+ volumes[mount.Destination] = namedVolumeMount{Name: mount.Name, ReadOnly: mount.ReadOnly}
3099+ }
3100+ }
3101+ return volumes
30473102 }
30483103
30493104 func (s *Service) attachmentEnv(ctx context.Context, containerName, imageRef string) ([]string, error) {
@@ -3102,25 +3157,24 @@ func (s *Service) handleRunningAttachment(ctx context.Context, existing *domain.
31023157 log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating")
31033158 }
31043159
3105- if err := s.runtime.StopContainer(ctx, existing.ID); err != nil {
3106- return false, log.WrapErr(err, "failed to stop attachment for drift update")
3107- }
3108- if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
3109- return false, log.WrapErr(err, "failed to remove attachment for drift update")
3110- }
3111-
31123160 return false, nil
31133161 }
31143162
3115-func (s *Service) removeStoppedAttachment(ctx context.Context, existing *domain.Container, containerName string) error {
3163+func (s *Service) removeAttachmentForReplacement(ctx context.Context, existing *domain.Container, containerName string) error {
31163164 if existing == nil {
31173165 return nil
31183166 }
31193167
31203168 log := zerowrap.FromCtx(ctx)
3121- log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
3169+ if existing.Status == string(domain.ContainerStatusRunning) {
3170+ log.Info().Str("container_name", containerName).Msg("stopping attachment container for replacement")
3171+ if err := s.runtime.StopContainer(ctx, existing.ID); err != nil {
3172+ return log.WrapErr(err, "failed to stop attachment for replacement")
3173+ }
3174+ }
3175+ log.Info().Str("container_name", containerName).Msg("removing attachment container for replacement")
31223176 if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
3123- return log.WrapErr(err, "failed to remove existing attachment container")
3177+ return log.WrapErr(err, "failed to remove attachment for replacement")
31243178 }
31253179
31263180 return nil
@@ -3147,14 +3201,7 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
31473201 return nil, err
31483202 }
31493203 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
3150- if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
3151- return nil, log.WrapErr(err, "failed to stop legacy attachment container")
3152- }
3153- if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
3154- return nil, log.WrapErr(err, "failed to remove legacy attachment container")
3155- }
3156-
3157- return nil, nil
3204+ return existingContainer, nil
31583205 }
31593206
31603207 func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error {
@@ -3226,23 +3273,48 @@ func legacyVolumeName(prefix, domainName, volumePath string) string {
32263273 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
32273274 }
32283275
3229-func (s *Service) legacyVolumeOwnedByDomain(ctx context.Context, volumeName, domainName string) (bool, error) {
3276+type legacyVolumeOwnership uint8
3277+
3278+const (
3279+ legacyVolumeOwnershipUnverified legacyVolumeOwnership = iota
3280+ legacyVolumeOwnershipOwned
3281+ legacyVolumeOwnershipForeign
3282+)
3283+
3284+func (s *Service) legacyVolumeOwnership(ctx context.Context, volumeName, domainName string) (legacyVolumeOwnership, error) {
32303285 containers, err := s.runtime.ListContainers(ctx, true)
32313286 if err != nil {
3232- return false, fmt.Errorf("list containers for legacy volume ownership: %w", err)
3287+ return legacyVolumeOwnershipUnverified, fmt.Errorf("list containers for legacy volume ownership: %w", err)
32333288 }
3289+ ownerFound := false
3290+ foreignFound := false
32343291 for _, container := range containers {
3235- if container.Labels[domain.LabelManaged] != "true" ||
3236- (container.Labels[domain.LabelRoute] != domainName && container.Labels[domain.LabelDomain] != domainName) {
3292+ if container.Labels[domain.LabelManaged] != "true" || !containerMountsVolume(container, volumeName) {
32373293 continue
32383294 }
3239- for _, mount := range container.VolumeMounts {
3240- if mount.Name == volumeName || mount.Source == volumeName {
3241- return true, nil
3242- }
3295+ if container.Name == domainName || container.Labels[domain.LabelRoute] == domainName || container.Labels[domain.LabelDomain] == domainName {
3296+ ownerFound = true
3297+ } else {
3298+ foreignFound = true
32433299 }
32443300 }
3245- return false, nil
3301+ switch {
3302+ case ownerFound && !foreignFound:
3303+ return legacyVolumeOwnershipOwned, nil
3304+ case foreignFound && !ownerFound:
3305+ return legacyVolumeOwnershipForeign, nil
3306+ default:
3307+ return legacyVolumeOwnershipUnverified, nil
3308+ }
3309+}
3310+
3311+func containerMountsVolume(container *domain.Container, volumeName string) bool {
3312+ for _, mount := range container.VolumeMounts {
3313+ if mount.Name == volumeName || mount.Source == volumeName {
3314+ return true
3315+ }
3316+ }
3317+ return false
32463318 }
32473319
32483320 func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
@@ -607,7 +607,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
607 }607 }
608 envHash := hashEnvironment(envVars)608 envHash := hashEnvironment(envVars)
609 609
610- volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)610+ volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef, nil)
611 if err != nil {611 if err != nil {
612 return nil, err612 return nil, err
613 }613 }
@@ -2513,13 +2513,16 @@ func (s *Service) loadEnvironment(ctx context.Context, preResolved []string, dom
2513 return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil2513 return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
2514 }2514 }
2515 2515
2516-func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {2516+func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string, preferredVolumes map[string]namedVolumeMount) (map[string]string, error) {
2517 s.mu.RLock()2517 s.mu.RLock()
2518 cfg := s.config2518 cfg := s.config
2519 s.mu.RUnlock()2519 s.mu.RUnlock()
2520 2520
2521 log := zerowrap.FromCtx(ctx)2521 log := zerowrap.FromCtx(ctx)
2522- volumes := make(map[string]string)2522+ volumes, err := s.validatePreferredVolumes(ctx, preferredVolumes)
2523+ if err != nil {
2524+ return nil, err
2525+ }
2523 2526
2524 if !cfg.VolumeAutoCreate {2527 if !cfg.VolumeAutoCreate {
2525 return volumes, nil2528 return volumes, nil
@@ -2532,6 +2535,10 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
2532 }2535 }
2533 2536
2534 for _, path := range volumePaths {2537 for _, path := range volumePaths {
2538+ if _, preferred := volumes[path]; preferred {
2539+ continue
2540+ }
2541+
2535 name := generateVolumeName(cfg.VolumePrefix, domainName, path)2542 name := generateVolumeName(cfg.VolumePrefix, domainName, path)
2536 legacyName := legacyVolumeName(cfg.VolumePrefix, domainName, path)2543 legacyName := legacyVolumeName(cfg.VolumePrefix, domainName, path)
2537 legacyExists, err := s.runtime.VolumeExists(ctx, legacyName)2544 legacyExists, err := s.runtime.VolumeExists(ctx, legacyName)
@@ -2539,11 +2546,12 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
2539 log.WrapErrWithFields(err, "failed to check legacy volume", map[string]any{"volume": legacyName})2546 log.WrapErrWithFields(err, "failed to check legacy volume", map[string]any{"volume": legacyName})
2540 continue2547 continue
2541 }2548 }
2549+ legacyOwnership := legacyVolumeOwnershipUnverified
2542 if legacyExists {2550 if legacyExists {
2543- owned, ownershipErr := s.legacyVolumeOwnedByDomain(ctx, legacyName, domainName)2551+ legacyOwnership, err = s.legacyVolumeOwnership(ctx, legacyName, domainName)
2544- if ownershipErr != nil {2552+ if err != nil {
2545- log.Warn().Err(ownershipErr).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name")2553+ log.Warn().Err(err).Str("volume", legacyName).Msg("could not verify legacy volume ownership; using stable volume name")
2546- } else if owned {2554+ } else if legacyOwnership == legacyVolumeOwnershipOwned {
2547 volumes[path] = legacyName2555 volumes[path] = legacyName
2548 continue2556 continue
2549 }2557 }
@@ -2556,6 +2564,11 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
2556 }2564 }
2557 2565
2558 if !exists {2566 if !exists {
2567+ if legacyExists && legacyOwnership != legacyVolumeOwnershipForeign {
2568+ // No verified mount may mean the owning container was already removed;
2569+ // it does not prove that the preserved volume belongs elsewhere.
2570+ return nil, fmt.Errorf("legacy volume %q exists but its ownership cannot be verified; refusing to create a replacement volume: %w", legacyName, domain.ErrVolumeOwnershipUnverified)
2571+ }
2559 if err := s.runtime.CreateVolume(ctx, name); err != nil {2572 if err := s.runtime.CreateVolume(ctx, name); err != nil {
2560 log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})2573 log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
2561 continue2574 continue
@@ -2569,6 +2582,24 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
2569 return volumes, nil2582 return volumes, nil
2570 }2583 }
2571 2584
2585+func (s *Service) validatePreferredVolumes(ctx context.Context, preferredVolumes map[string]namedVolumeMount) (map[string]string, error) {
2586+ volumes := make(map[string]string)
2587+ for path, preferred := range preferredVolumes {
2588+ if preferred.Name == "" {
2589+ continue
2590+ }
2591+ exists, err := s.runtime.VolumeExists(ctx, preferred.Name)
2592+ if err != nil {
2593+ return nil, fmt.Errorf("check previously mounted volume %q: %w", preferred.Name, err)
2594+ }
2595+ if !exists {
2596+ return nil, fmt.Errorf("previously mounted volume %q no longer exists: %w", preferred.Name, domain.ErrVolumeNotFound)
2597+ }
2598+ volumes[path] = preferred.Name
2599+ }
2600+ return volumes, nil
2601+}
2602+
2572 func (s *Service) getNetworkForApp(domainName string) string {2603 func (s *Service) getNetworkForApp(domainName string) string {
2573 var networkIsolation bool2604 var networkIsolation bool
2574 var networkGroups map[string][]string2605 var networkGroups map[string][]string
@@ -2922,8 +2953,9 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2922 if err != nil {2953 if err != nil {
2923 return err2954 return err
2924 }2955 }
2956+ preferredVolumes := namedVolumeMounts(existingContainer)
2925 2957
2926- if existingContainer != nil {2958+ if existingContainer != nil && existingContainer.Name == containerName {
2927 shouldSkip, err := s.handleRunningAttachment(ctx, existingContainer, containerName, serviceImage)2959 shouldSkip, err := s.handleRunningAttachment(ctx, existingContainer, containerName, serviceImage)
2928 if err != nil {2960 if err != nil {
2929 return err2961 return err
@@ -2931,22 +2963,19 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2931 if shouldSkip {2963 if shouldSkip {
2932 return nil2964 return nil
2933 }2965 }
2934- if existingContainer.Status == string(domain.ContainerStatusRunning) {
2935- existingContainer = nil
2936- }
2937- }
2938-
2939- if err := s.removeStoppedAttachment(ctx, existingContainer, containerName); err != nil {
2940- return err
2941 }2966 }
2942 2967
2943 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")2968 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
2944 2969
2945- config, err := s.buildAttachmentContainerConfig(ctx, ownerDomain, serviceName, serviceImage, containerName, networkName)2970+ config, err := s.buildAttachmentContainerConfig(ctx, ownerDomain, serviceName, serviceImage, containerName, networkName, preferredVolumes)
2946 if err != nil {2971 if err != nil {
2947 return err2972 return err
2948 }2973 }
2949 2974
2975+ if err := s.removeAttachmentForReplacement(ctx, existingContainer, containerName); err != nil {
2976+ return err
2977+ }
2978+
2950 container, err := s.runtime.CreateContainer(ctx, config)2979 container, err := s.runtime.CreateContainer(ctx, config)
2951 if err != nil {2980 if err != nil {
2952 return log.WrapErr(err, "failed to create attachment container")2981 return log.WrapErr(err, "failed to create attachment container")
@@ -2979,7 +3008,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
2979 return nil3008 return nil
2980 }3009 }
2981 3010
2982-func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomain, serviceName, serviceImage, containerName, networkName string) (*domain.ContainerConfig, error) {3011+func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomain, serviceName, serviceImage, containerName, networkName string, preferredVolumes map[string]namedVolumeMount) (*domain.ContainerConfig, error) {
2983 imageRef, err := s.buildValidatedImageRef(ctx, serviceImage)3012 imageRef, err := s.buildValidatedImageRef(ctx, serviceImage)
2984 if err != nil {3013 if err != nil {
2985 return nil, err3014 return nil, err
@@ -2990,7 +3019,7 @@ func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomai
2990 }3019 }
2991 3020
2992 exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef)3021 exposedPorts := s.attachmentExposedPorts(ctx, actualImageRef)
2993- volumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef)3022+ volumes, readOnlyVolumes, err := s.attachmentVolumes(ctx, containerName, actualImageRef, preferredVolumes)
2994 if err != nil {3023 if err != nil {
2995 return nil, err3024 return nil, err
2996 }3025 }
@@ -3004,14 +3033,15 @@ func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomai
3004 s.mu.RUnlock()3033 s.mu.RUnlock()
3005 3034
3006 config := &domain.ContainerConfig{3035 config := &domain.ContainerConfig{
3007- Image: actualImageRef,3036+ Image: actualImageRef,
3008- Name: containerName,3037+ Name: containerName,
3009- Hostname: serviceName,3038+ Hostname: serviceName,
3010- Aliases: []string{serviceName},3039+ Aliases: []string{serviceName},
3011- Ports: exposedPorts,3040+ Ports: exposedPorts,
3012- Env: envVars,3041+ Env: envVars,
3013- Volumes: volumes,3042+ Volumes: volumes,
3014- NetworkMode: networkName,3043+ ReadOnlyVolumes: readOnlyVolumes,
3044+ NetworkMode: networkName,
3015 Labels: map[string]string{3045 Labels: map[string]string{
3016 domain.LabelManaged: "true",3046 domain.LabelManaged: "true",
3017 domain.LabelAttachment: "true",3047 domain.LabelAttachment: "true",
@@ -3038,12 +3068,37 @@ func (s *Service) attachmentExposedPorts(ctx context.Context, imageRef string) [
3038 return exposedPorts3068 return exposedPorts
3039 }3069 }
3040 3070
3041-func (s *Service) attachmentVolumes(ctx context.Context, containerName, imageRef string) (map[string]string, error) {3071+func (s *Service) attachmentVolumes(ctx context.Context, containerName, imageRef string, preferredVolumes map[string]namedVolumeMount) (map[string]string, map[string]string, error) {
3042- volumes, err := s.setupVolumes(ctx, containerName, imageRef)3072+ volumes, err := s.setupVolumes(ctx, containerName, imageRef, preferredVolumes)
3043 if err != nil {3073 if err != nil {
3044- return nil, fmt.Errorf("failed to setup volumes for attachment %s with image %s: %w", containerName, imageRef, err)3074+ return nil, nil, fmt.Errorf("failed to setup volumes for attachment %s with image %s: %w", containerName, imageRef, err)
3045 }3075 }
3046- return volumes, nil3076+ readOnlyVolumes := make(map[string]string)
3077+ for path, preferred := range preferredVolumes {
3078+ if preferred.ReadOnly && volumes[path] == preferred.Name {
3079+ delete(volumes, path)
3080+ readOnlyVolumes[path] = preferred.Name
3081+ }
3082+ }
3083+ return volumes, readOnlyVolumes, nil
3084+}
3085+
3086+type namedVolumeMount struct {
3087+ Name string
3088+ ReadOnly bool
3089+}
3090+
3091+func namedVolumeMounts(container *domain.Container) map[string]namedVolumeMount {
3092+ volumes := make(map[string]namedVolumeMount)
3093+ if container == nil {
3094+ return volumes
3095+ }
3096+ for _, mount := range container.VolumeMounts {
3097+ if mount.Type == "volume" && mount.Name != "" && mount.Destination != "" {
3098+ volumes[mount.Destination] = namedVolumeMount{Name: mount.Name, ReadOnly: mount.ReadOnly}
3099+ }
3100+ }
3101+ return volumes
3047 }3102 }
3048 3103
3049 func (s *Service) attachmentEnv(ctx context.Context, containerName, imageRef string) ([]string, error) {3104 func (s *Service) attachmentEnv(ctx context.Context, containerName, imageRef string) ([]string, error) {
@@ -3102,25 +3157,24 @@ func (s *Service) handleRunningAttachment(ctx context.Context, existing *domain.
3102 log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating")3157 log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating")
3103 }3158 }
3104 3159
3105- if err := s.runtime.StopContainer(ctx, existing.ID); err != nil {
3106- return false, log.WrapErr(err, "failed to stop attachment for drift update")
3107- }
3108- if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
3109- return false, log.WrapErr(err, "failed to remove attachment for drift update")
3110- }
3111-
3112 return false, nil3160 return false, nil
3113 }3161 }
3114 3162
3115-func (s *Service) removeStoppedAttachment(ctx context.Context, existing *domain.Container, containerName string) error {3163+func (s *Service) removeAttachmentForReplacement(ctx context.Context, existing *domain.Container, containerName string) error {
3116 if existing == nil {3164 if existing == nil {
3117 return nil3165 return nil
3118 }3166 }
3119 3167
3120 log := zerowrap.FromCtx(ctx)3168 log := zerowrap.FromCtx(ctx)
3121- log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")3169+ if existing.Status == string(domain.ContainerStatusRunning) {
3170+ log.Info().Str("container_name", containerName).Msg("stopping attachment container for replacement")
3171+ if err := s.runtime.StopContainer(ctx, existing.ID); err != nil {
3172+ return log.WrapErr(err, "failed to stop attachment for replacement")
3173+ }
3174+ }
3175+ log.Info().Str("container_name", containerName).Msg("removing attachment container for replacement")
3122 if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {3176 if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
3123- return log.WrapErr(err, "failed to remove existing attachment container")3177+ return log.WrapErr(err, "failed to remove attachment for replacement")
3124 }3178 }
3125 3179
3126 return nil3180 return nil
@@ -3147,14 +3201,7 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se
3147 return nil, err3201 return nil, err
3148 }3202 }
3149 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")3203 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
3150- if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {3204+ return existingContainer, nil
3151- return nil, log.WrapErr(err, "failed to stop legacy attachment container")
3152- }
3153- if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
3154- return nil, log.WrapErr(err, "failed to remove legacy attachment container")
3155- }
3156-
3157- return nil, nil
3158 }3205 }
3159 3206
3160 func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error {3207 func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error {
@@ -3226,23 +3273,48 @@ func legacyVolumeName(prefix, domainName, volumePath string) string {
3226 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))3273 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
3227 }3274 }
3228 3275
3229-func (s *Service) legacyVolumeOwnedByDomain(ctx context.Context, volumeName, domainName string) (bool, error) {3276+type legacyVolumeOwnership uint8
3277+
3278+const (
3279+ legacyVolumeOwnershipUnverified legacyVolumeOwnership = iota
3280+ legacyVolumeOwnershipOwned
3281+ legacyVolumeOwnershipForeign
3282+)
3283+
3284+func (s *Service) legacyVolumeOwnership(ctx context.Context, volumeName, domainName string) (legacyVolumeOwnership, error) {
3230 containers, err := s.runtime.ListContainers(ctx, true)3285 containers, err := s.runtime.ListContainers(ctx, true)
3231 if err != nil {3286 if err != nil {
3232- return false, fmt.Errorf("list containers for legacy volume ownership: %w", err)3287+ return legacyVolumeOwnershipUnverified, fmt.Errorf("list containers for legacy volume ownership: %w", err)
3233 }3288 }
3289+ ownerFound := false
3290+ foreignFound := false
3234 for _, container := range containers {3291 for _, container := range containers {
3235- if container.Labels[domain.LabelManaged] != "true" ||3292+ if container.Labels[domain.LabelManaged] != "true" || !containerMountsVolume(container, volumeName) {
3236- (container.Labels[domain.LabelRoute] != domainName && container.Labels[domain.LabelDomain] != domainName) {
3237 continue3293 continue
3238 }3294 }
3239- for _, mount := range container.VolumeMounts {3295+ if container.Name == domainName || container.Labels[domain.LabelRoute] == domainName || container.Labels[domain.LabelDomain] == domainName {
3240- if mount.Name == volumeName || mount.Source == volumeName {3296+ ownerFound = true
3241- return true, nil3297+ } else {
3242- }3298+ foreignFound = true
3243 }3299 }
3244 }3300 }
3245- return false, nil3301+ switch {
3302+ case ownerFound && !foreignFound:
3303+ return legacyVolumeOwnershipOwned, nil
3304+ case foreignFound && !ownerFound:
3305+ return legacyVolumeOwnershipForeign, nil
3306+ default:
3307+ return legacyVolumeOwnershipUnverified, nil
3308+ }
3309+}
3310+
3311+func containerMountsVolume(container *domain.Container, volumeName string) bool {
3312+ for _, mount := range container.VolumeMounts {
3313+ if mount.Name == volumeName || mount.Source == volumeName {
3314+ return true
3315+ }
3316+ }
3317+ return false
3246 }3318 }
3247 3319
3248 func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {3320 func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
M internal/usecase/container/service_test.go +278 -2
@@ -1799,7 +1799,7 @@ func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) {
17991799 VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
18001800 }}, nil)
18011801
1802- volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1802+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", nil)
18031803
18041804 require.NoError(t, err)
18051805 assert.Equal(t, map[string]string{"/data": legacyName}, volumes)
@@ -1820,12 +1820,93 @@ func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) {
18201820 }}, nil)
18211821 runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil)
18221822
1823- volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")
1823+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", nil)
18241824
18251825 require.NoError(t, err)
18261826 assert.Equal(t, map[string]string{"/data": stableName}, volumes)
18271827 }
18281828
1829+func TestService_SetupVolumes_CreatesStableVolumeWhenLegacyVolumeIsForeign(t *testing.T) {
1830+ runtime := mocks.NewMockContainerRuntime(t)
1831+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1832+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1833+ stableName := generateVolumeName("gordon", "app.example.com", "/data")
1834+
1835+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil).Once()
1836+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil).Once()
1837+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{
1838+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "other.example.com"},
1839+ VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1840+ }}, nil).Once()
1841+ runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(false, nil).Once()
1842+ runtime.EXPECT().CreateVolume(mock.Anything, stableName).Return(nil).Once()
1843+
1844+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", nil)
1845+
1846+ require.NoError(t, err)
1847+ assert.Equal(t, map[string]string{"/data": stableName}, volumes)
1848+}
1849+
1850+func TestService_SetupVolumes_PreservesPreferredVolumesWhenAutoCreateDisabled(t *testing.T) {
1851+ runtime := mocks.NewMockContainerRuntime(t)
1852+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: false}, nil)
1853+ preferred := map[string]namedVolumeMount{
1854+ "/data": {Name: "existing-data"},
1855+ }
1856+
1857+ runtime.EXPECT().VolumeExists(mock.Anything, "existing-data").Return(true, nil).Once()
1858+
1859+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", preferred)
1860+
1861+ require.NoError(t, err)
1862+ assert.Equal(t, map[string]string{"/data": "existing-data"}, volumes)
1863+}
1864+
1865+func TestService_SetupVolumes_RejectsUnverifiedLegacyVolumeWithoutStableReplacement(t *testing.T) {
1866+ runtime := mocks.NewMockContainerRuntime(t)
1867+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1868+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1869+ stableName := generateVolumeName("gordon", "app.example.com", "/data")
1870+
1871+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil).Once()
1872+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil).Once()
1873+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1874+ runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(false, nil).Once()
1875+
1876+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", nil)
1877+
1878+ require.ErrorContains(t, err, "refusing to create a replacement")
1879+ require.ErrorIs(t, err, domain.ErrVolumeOwnershipUnverified)
1880+ assert.Empty(t, volumes)
1881+}
1882+
1883+func TestService_SetupVolumes_RejectsLegacyVolumeSharedWithForeignWorkload(t *testing.T) {
1884+ runtime := mocks.NewMockContainerRuntime(t)
1885+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1886+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1887+ stableName := generateVolumeName("gordon", "app.example.com", "/data")
1888+ mount := []domain.ContainerVolumeMount{{Name: legacyName}}
1889+
1890+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil).Once()
1891+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil).Once()
1892+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1893+ {
1894+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app.example.com"},
1895+ VolumeMounts: mount,
1896+ },
1897+ {
1898+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "other.example.com"},
1899+ VolumeMounts: mount,
1900+ },
1901+ }, nil).Once()
1902+ runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(false, nil).Once()
1903+
1904+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", nil)
1905+
1906+ require.ErrorIs(t, err, domain.ErrVolumeOwnershipUnverified)
1907+ assert.Empty(t, volumes)
1908+}
1909+
18291910 func TestValidateAttachmentOwnership(t *testing.T) {
18301911 owned := &domain.Container{Labels: map[string]string{
18311912 domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com",
@@ -4374,6 +4455,201 @@ func TestService_WaitForAttachmentReady_FallsBackToDelay(t *testing.T) {
43744455 assert.NoError(t, err)
43754456 }
43764457
4458+func TestDeployAttachedService_ReusesVerifiedLegacyVolumeFromStoppedAttachment(t *testing.T) {
4459+ runtime := mocks.NewMockContainerRuntime(t)
4460+ envLoader := mocks.NewMockEnvLoader(t)
4461+ eventBus := mocks.NewMockEventPublisher(t)
4462+
4463+ config := testMinDelayConfig()
4464+ config.VolumeAutoCreate = true
4465+ config.VolumePrefix = "gordon"
4466+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4467+ ctx := testContext()
4468+
4469+ ownerDomain := "app.example.com"
4470+ serviceImage := "postgres:16"
4471+ networkName := "gordon-net"
4472+ containerName := fmt.Sprintf("gordon-%s-postgres", domain.SanitizeDomainForContainer(ownerDomain))
4473+ volumePath := "/var/lib/postgresql"
4474+ legacyVolume := legacyVolumeName(config.VolumePrefix, containerName, volumePath)
4475+ existing := &domain.Container{
4476+ ID: "stopped-postgres",
4477+ Name: containerName,
4478+ Status: string(domain.ContainerStatusExited),
4479+ Labels: map[string]string{
4480+ domain.LabelManaged: "true",
4481+ domain.LabelAttachment: "true",
4482+ domain.LabelAttachedTo: ownerDomain,
4483+ },
4484+ VolumeMounts: []domain.ContainerVolumeMount{{
4485+ Name: legacyVolume,
4486+ Type: "volume",
4487+ Destination: volumePath,
4488+ ReadOnly: true,
4489+ }},
4490+ }
4491+
4492+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{existing}, nil).Once()
4493+ runtime.EXPECT().RemoveContainer(mock.Anything, existing.ID, true).Return(nil).Once()
4494+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{serviceImage}, nil).Once()
4495+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, serviceImage).Return([]int{}, nil).Once()
4496+ runtime.EXPECT().InspectImageVolumes(mock.Anything, serviceImage).Return([]string{volumePath}, nil).Once()
4497+ runtime.EXPECT().VolumeExists(mock.Anything, legacyVolume).Return(true, nil).Once()
4498+ envLoader.EXPECT().LoadEnv(mock.Anything, containerName).Return([]string{}, nil).Once()
4499+ runtime.EXPECT().InspectImageEnv(mock.Anything, serviceImage).Return([]string{}, nil).Once()
4500+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
4501+ Run(func(_ context.Context, cfg *domain.ContainerConfig) {
4502+ assert.NotContains(t, cfg.Volumes, volumePath)
4503+ assert.Equal(t, map[string]string{volumePath: legacyVolume}, cfg.ReadOnlyVolumes)
4504+ }).
4505+ Return(&domain.Container{ID: "new-postgres", Name: containerName}, nil).Once()
4506+ runtime.EXPECT().StartContainer(mock.Anything, "new-postgres").Return(nil).Once()
4507+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-postgres").Return(true, nil).Times(2)
4508+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-postgres").Return("", false, nil).Once()
4509+
4510+ require.NoError(t, svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName))
4511+}
4512+
4513+func TestDeployAttachedService_KeepsStoppedAttachmentWhenVolumeValidationFails(t *testing.T) {
4514+ runtime := mocks.NewMockContainerRuntime(t)
4515+ envLoader := mocks.NewMockEnvLoader(t)
4516+ eventBus := mocks.NewMockEventPublisher(t)
4517+
4518+ config := testMinDelayConfig()
4519+ config.VolumeAutoCreate = true
4520+ config.VolumePrefix = "gordon"
4521+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4522+
4523+ ownerDomain := "app.example.com"
4524+ serviceImage := "postgres:16"
4525+ containerName := fmt.Sprintf("gordon-%s-postgres", domain.SanitizeDomainForContainer(ownerDomain))
4526+ existing := &domain.Container{
4527+ ID: "stopped-postgres",
4528+ Name: containerName,
4529+ Status: string(domain.ContainerStatusExited),
4530+ Labels: map[string]string{
4531+ domain.LabelManaged: "true",
4532+ domain.LabelAttachment: "true",
4533+ domain.LabelAttachedTo: ownerDomain,
4534+ },
4535+ VolumeMounts: []domain.ContainerVolumeMount{{
4536+ Name: "missing-data",
4537+ Type: "volume",
4538+ Destination: "/var/lib/postgresql",
4539+ }},
4540+ }
4541+
4542+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{existing}, nil).Once()
4543+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{serviceImage}, nil).Once()
4544+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, serviceImage).Return([]int{}, nil).Once()
4545+ runtime.EXPECT().VolumeExists(mock.Anything, "missing-data").Return(false, nil).Once()
4546+
4547+ err := svc.deployAttachedService(testContext(), ownerDomain, serviceImage, "gordon-net")
4548+
4549+ require.ErrorContains(t, err, "previously mounted volume")
4550+ require.ErrorIs(t, err, domain.ErrVolumeNotFound)
4551+}
4552+
4553+func TestDeployAttachedService_KeepsRunningAttachmentWhenVolumeValidationFails(t *testing.T) {
4554+ runtime := mocks.NewMockContainerRuntime(t)
4555+ envLoader := mocks.NewMockEnvLoader(t)
4556+ eventBus := mocks.NewMockEventPublisher(t)
4557+
4558+ config := testMinDelayConfig()
4559+ config.VolumeAutoCreate = true
4560+ config.VolumePrefix = "gordon"
4561+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4562+
4563+ ownerDomain := "app.example.com"
4564+ serviceImage := "postgres:16"
4565+ containerName := fmt.Sprintf("gordon-%s-postgres", domain.SanitizeDomainForContainer(ownerDomain))
4566+ existing := &domain.Container{
4567+ ID: "running-postgres",
4568+ Name: containerName,
4569+ Status: string(domain.ContainerStatusRunning),
4570+ Labels: map[string]string{
4571+ domain.LabelManaged: "true",
4572+ domain.LabelAttachment: "true",
4573+ domain.LabelAttachedTo: ownerDomain,
4574+ domain.LabelImage: "postgres:15",
4575+ domain.LabelEnvHash: hashEnvironment(nil),
4576+ },
4577+ VolumeMounts: []domain.ContainerVolumeMount{{
4578+ Name: "missing-data",
4579+ Type: "volume",
4580+ Destination: "/var/lib/postgresql",
4581+ }},
4582+ }
4583+
4584+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{existing}, nil).Once()
4585+ envLoader.EXPECT().LoadEnv(mock.Anything, containerName).Return([]string{}, nil).Once()
4586+ runtime.EXPECT().InspectImageEnv(mock.Anything, serviceImage).Return([]string{}, nil).Once()
4587+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{serviceImage}, nil).Once()
4588+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, serviceImage).Return([]int{}, nil).Once()
4589+ runtime.EXPECT().VolumeExists(mock.Anything, "missing-data").Return(false, nil).Once()
4590+
4591+ err := svc.deployAttachedService(testContext(), ownerDomain, serviceImage, "gordon-net")
4592+
4593+ require.ErrorContains(t, err, "previously mounted volume")
4594+ require.ErrorIs(t, err, domain.ErrVolumeNotFound)
4595+}
4596+
4597+func TestDeployAttachedService_ReusesVolumeFromLegacyNamedAttachment(t *testing.T) {
4598+ runtime := mocks.NewMockContainerRuntime(t)
4599+ envLoader := mocks.NewMockEnvLoader(t)
4600+ eventBus := mocks.NewMockEventPublisher(t)
4601+
4602+ config := testMinDelayConfig()
4603+ config.VolumeAutoCreate = true
4604+ config.VolumePrefix = "gordon"
4605+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4606+ ctx := testContext()
4607+
4608+ ownerDomain := "app.example.com"
4609+ serviceImage := "postgres:16"
4610+ serviceName := "postgres"
4611+ networkName := "gordon-net"
4612+ containerName := fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainer(ownerDomain), serviceName)
4613+ legacyContainerName := fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainerLegacy(ownerDomain), serviceName)
4614+ volumePath := "/var/lib/postgresql"
4615+ legacyVolume := legacyVolumeName(config.VolumePrefix, legacyContainerName, volumePath)
4616+ existing := &domain.Container{
4617+ ID: "legacy-postgres",
4618+ Name: legacyContainerName,
4619+ Status: string(domain.ContainerStatusRunning),
4620+ Labels: map[string]string{
4621+ domain.LabelManaged: "true",
4622+ domain.LabelAttachment: "true",
4623+ domain.LabelAttachedTo: ownerDomain,
4624+ },
4625+ VolumeMounts: []domain.ContainerVolumeMount{{
4626+ Name: legacyVolume,
4627+ Type: "volume",
4628+ Destination: volumePath,
4629+ }},
4630+ }
4631+
4632+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{existing}, nil).Times(2)
4633+ runtime.EXPECT().StopContainer(mock.Anything, existing.ID).Return(nil).Once()
4634+ runtime.EXPECT().RemoveContainer(mock.Anything, existing.ID, true).Return(nil).Once()
4635+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{serviceImage}, nil).Once()
4636+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, serviceImage).Return([]int{}, nil).Once()
4637+ runtime.EXPECT().InspectImageVolumes(mock.Anything, serviceImage).Return([]string{volumePath}, nil).Once()
4638+ runtime.EXPECT().VolumeExists(mock.Anything, legacyVolume).Return(true, nil).Once()
4639+ envLoader.EXPECT().LoadEnv(mock.Anything, containerName).Return([]string{}, nil).Once()
4640+ runtime.EXPECT().InspectImageEnv(mock.Anything, serviceImage).Return([]string{}, nil).Once()
4641+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
4642+ Run(func(_ context.Context, cfg *domain.ContainerConfig) {
4643+ assert.Equal(t, map[string]string{volumePath: legacyVolume}, cfg.Volumes)
4644+ }).
4645+ Return(&domain.Container{ID: "new-postgres", Name: containerName}, nil).Once()
4646+ runtime.EXPECT().StartContainer(mock.Anything, "new-postgres").Return(nil).Once()
4647+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-postgres").Return(true, nil).Times(2)
4648+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-postgres").Return("", false, nil).Once()
4649+
4650+ require.NoError(t, svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName))
4651+}
4652+
43774653 func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
43784654 runtime := mocks.NewMockContainerRuntime(t)
43794655 envLoader := mocks.NewMockEnvLoader(t)
@@ -1799,7 +1799,7 @@ func TestService_SetupVolumes_ReusesLegacyVolume(t *testing.T) {
1799 VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},1799 VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1800 }}, nil)1800 }}, nil)
1801 1801
1802- volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")1802+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", nil)
1803 1803
1804 require.NoError(t, err)1804 require.NoError(t, err)
1805 assert.Equal(t, map[string]string{"/data": legacyName}, volumes)1805 assert.Equal(t, map[string]string{"/data": legacyName}, volumes)
@@ -1820,12 +1820,93 @@ func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) {
1820 }}, nil)1820 }}, nil)
1821 runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil)1821 runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(true, nil)
1822 1822
1823- volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest")1823+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", nil)
1824 1824
1825 require.NoError(t, err)1825 require.NoError(t, err)
1826 assert.Equal(t, map[string]string{"/data": stableName}, volumes)1826 assert.Equal(t, map[string]string{"/data": stableName}, volumes)
1827 }1827 }
1828 1828
1829+func TestService_SetupVolumes_CreatesStableVolumeWhenLegacyVolumeIsForeign(t *testing.T) {
1830+ runtime := mocks.NewMockContainerRuntime(t)
1831+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1832+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1833+ stableName := generateVolumeName("gordon", "app.example.com", "/data")
1834+
1835+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil).Once()
1836+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil).Once()
1837+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{{
1838+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "other.example.com"},
1839+ VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}},
1840+ }}, nil).Once()
1841+ runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(false, nil).Once()
1842+ runtime.EXPECT().CreateVolume(mock.Anything, stableName).Return(nil).Once()
1843+
1844+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", nil)
1845+
1846+ require.NoError(t, err)
1847+ assert.Equal(t, map[string]string{"/data": stableName}, volumes)
1848+}
1849+
1850+func TestService_SetupVolumes_PreservesPreferredVolumesWhenAutoCreateDisabled(t *testing.T) {
1851+ runtime := mocks.NewMockContainerRuntime(t)
1852+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: false}, nil)
1853+ preferred := map[string]namedVolumeMount{
1854+ "/data": {Name: "existing-data"},
1855+ }
1856+
1857+ runtime.EXPECT().VolumeExists(mock.Anything, "existing-data").Return(true, nil).Once()
1858+
1859+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", preferred)
1860+
1861+ require.NoError(t, err)
1862+ assert.Equal(t, map[string]string{"/data": "existing-data"}, volumes)
1863+}
1864+
1865+func TestService_SetupVolumes_RejectsUnverifiedLegacyVolumeWithoutStableReplacement(t *testing.T) {
1866+ runtime := mocks.NewMockContainerRuntime(t)
1867+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1868+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1869+ stableName := generateVolumeName("gordon", "app.example.com", "/data")
1870+
1871+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil).Once()
1872+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil).Once()
1873+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil).Once()
1874+ runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(false, nil).Once()
1875+
1876+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", nil)
1877+
1878+ require.ErrorContains(t, err, "refusing to create a replacement")
1879+ require.ErrorIs(t, err, domain.ErrVolumeOwnershipUnverified)
1880+ assert.Empty(t, volumes)
1881+}
1882+
1883+func TestService_SetupVolumes_RejectsLegacyVolumeSharedWithForeignWorkload(t *testing.T) {
1884+ runtime := mocks.NewMockContainerRuntime(t)
1885+ svc := NewService(runtime, nil, nil, nil, Config{VolumeAutoCreate: true, VolumePrefix: "gordon"}, nil)
1886+ legacyName := legacyVolumeName("gordon", "app.example.com", "/data")
1887+ stableName := generateVolumeName("gordon", "app.example.com", "/data")
1888+ mount := []domain.ContainerVolumeMount{{Name: legacyName}}
1889+
1890+ runtime.EXPECT().InspectImageVolumes(mock.Anything, "myapp:latest").Return([]string{"/data"}, nil).Once()
1891+ runtime.EXPECT().VolumeExists(mock.Anything, legacyName).Return(true, nil).Once()
1892+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
1893+ {
1894+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "app.example.com"},
1895+ VolumeMounts: mount,
1896+ },
1897+ {
1898+ Labels: map[string]string{domain.LabelManaged: "true", domain.LabelRoute: "other.example.com"},
1899+ VolumeMounts: mount,
1900+ },
1901+ }, nil).Once()
1902+ runtime.EXPECT().VolumeExists(mock.Anything, stableName).Return(false, nil).Once()
1903+
1904+ volumes, err := svc.setupVolumes(testContext(), "app.example.com", "myapp:latest", nil)
1905+
1906+ require.ErrorIs(t, err, domain.ErrVolumeOwnershipUnverified)
1907+ assert.Empty(t, volumes)
1908+}
1909+
1829 func TestValidateAttachmentOwnership(t *testing.T) {1910 func TestValidateAttachmentOwnership(t *testing.T) {
1830 owned := &domain.Container{Labels: map[string]string{1911 owned := &domain.Container{Labels: map[string]string{
1831 domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com",1912 domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com",
@@ -4374,6 +4455,201 @@ func TestService_WaitForAttachmentReady_FallsBackToDelay(t *testing.T) {
4374 assert.NoError(t, err)4455 assert.NoError(t, err)
4375 }4456 }
4376 4457
4458+func TestDeployAttachedService_ReusesVerifiedLegacyVolumeFromStoppedAttachment(t *testing.T) {
4459+ runtime := mocks.NewMockContainerRuntime(t)
4460+ envLoader := mocks.NewMockEnvLoader(t)
4461+ eventBus := mocks.NewMockEventPublisher(t)
4462+
4463+ config := testMinDelayConfig()
4464+ config.VolumeAutoCreate = true
4465+ config.VolumePrefix = "gordon"
4466+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4467+ ctx := testContext()
4468+
4469+ ownerDomain := "app.example.com"
4470+ serviceImage := "postgres:16"
4471+ networkName := "gordon-net"
4472+ containerName := fmt.Sprintf("gordon-%s-postgres", domain.SanitizeDomainForContainer(ownerDomain))
4473+ volumePath := "/var/lib/postgresql"
4474+ legacyVolume := legacyVolumeName(config.VolumePrefix, containerName, volumePath)
4475+ existing := &domain.Container{
4476+ ID: "stopped-postgres",
4477+ Name: containerName,
4478+ Status: string(domain.ContainerStatusExited),
4479+ Labels: map[string]string{
4480+ domain.LabelManaged: "true",
4481+ domain.LabelAttachment: "true",
4482+ domain.LabelAttachedTo: ownerDomain,
4483+ },
4484+ VolumeMounts: []domain.ContainerVolumeMount{{
4485+ Name: legacyVolume,
4486+ Type: "volume",
4487+ Destination: volumePath,
4488+ ReadOnly: true,
4489+ }},
4490+ }
4491+
4492+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{existing}, nil).Once()
4493+ runtime.EXPECT().RemoveContainer(mock.Anything, existing.ID, true).Return(nil).Once()
4494+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{serviceImage}, nil).Once()
4495+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, serviceImage).Return([]int{}, nil).Once()
4496+ runtime.EXPECT().InspectImageVolumes(mock.Anything, serviceImage).Return([]string{volumePath}, nil).Once()
4497+ runtime.EXPECT().VolumeExists(mock.Anything, legacyVolume).Return(true, nil).Once()
4498+ envLoader.EXPECT().LoadEnv(mock.Anything, containerName).Return([]string{}, nil).Once()
4499+ runtime.EXPECT().InspectImageEnv(mock.Anything, serviceImage).Return([]string{}, nil).Once()
4500+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
4501+ Run(func(_ context.Context, cfg *domain.ContainerConfig) {
4502+ assert.NotContains(t, cfg.Volumes, volumePath)
4503+ assert.Equal(t, map[string]string{volumePath: legacyVolume}, cfg.ReadOnlyVolumes)
4504+ }).
4505+ Return(&domain.Container{ID: "new-postgres", Name: containerName}, nil).Once()
4506+ runtime.EXPECT().StartContainer(mock.Anything, "new-postgres").Return(nil).Once()
4507+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-postgres").Return(true, nil).Times(2)
4508+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-postgres").Return("", false, nil).Once()
4509+
4510+ require.NoError(t, svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName))
4511+}
4512+
4513+func TestDeployAttachedService_KeepsStoppedAttachmentWhenVolumeValidationFails(t *testing.T) {
4514+ runtime := mocks.NewMockContainerRuntime(t)
4515+ envLoader := mocks.NewMockEnvLoader(t)
4516+ eventBus := mocks.NewMockEventPublisher(t)
4517+
4518+ config := testMinDelayConfig()
4519+ config.VolumeAutoCreate = true
4520+ config.VolumePrefix = "gordon"
4521+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4522+
4523+ ownerDomain := "app.example.com"
4524+ serviceImage := "postgres:16"
4525+ containerName := fmt.Sprintf("gordon-%s-postgres", domain.SanitizeDomainForContainer(ownerDomain))
4526+ existing := &domain.Container{
4527+ ID: "stopped-postgres",
4528+ Name: containerName,
4529+ Status: string(domain.ContainerStatusExited),
4530+ Labels: map[string]string{
4531+ domain.LabelManaged: "true",
4532+ domain.LabelAttachment: "true",
4533+ domain.LabelAttachedTo: ownerDomain,
4534+ },
4535+ VolumeMounts: []domain.ContainerVolumeMount{{
4536+ Name: "missing-data",
4537+ Type: "volume",
4538+ Destination: "/var/lib/postgresql",
4539+ }},
4540+ }
4541+
4542+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{existing}, nil).Once()
4543+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{serviceImage}, nil).Once()
4544+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, serviceImage).Return([]int{}, nil).Once()
4545+ runtime.EXPECT().VolumeExists(mock.Anything, "missing-data").Return(false, nil).Once()
4546+
4547+ err := svc.deployAttachedService(testContext(), ownerDomain, serviceImage, "gordon-net")
4548+
4549+ require.ErrorContains(t, err, "previously mounted volume")
4550+ require.ErrorIs(t, err, domain.ErrVolumeNotFound)
4551+}
4552+
4553+func TestDeployAttachedService_KeepsRunningAttachmentWhenVolumeValidationFails(t *testing.T) {
4554+ runtime := mocks.NewMockContainerRuntime(t)
4555+ envLoader := mocks.NewMockEnvLoader(t)
4556+ eventBus := mocks.NewMockEventPublisher(t)
4557+
4558+ config := testMinDelayConfig()
4559+ config.VolumeAutoCreate = true
4560+ config.VolumePrefix = "gordon"
4561+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4562+
4563+ ownerDomain := "app.example.com"
4564+ serviceImage := "postgres:16"
4565+ containerName := fmt.Sprintf("gordon-%s-postgres", domain.SanitizeDomainForContainer(ownerDomain))
4566+ existing := &domain.Container{
4567+ ID: "running-postgres",
4568+ Name: containerName,
4569+ Status: string(domain.ContainerStatusRunning),
4570+ Labels: map[string]string{
4571+ domain.LabelManaged: "true",
4572+ domain.LabelAttachment: "true",
4573+ domain.LabelAttachedTo: ownerDomain,
4574+ domain.LabelImage: "postgres:15",
4575+ domain.LabelEnvHash: hashEnvironment(nil),
4576+ },
4577+ VolumeMounts: []domain.ContainerVolumeMount{{
4578+ Name: "missing-data",
4579+ Type: "volume",
4580+ Destination: "/var/lib/postgresql",
4581+ }},
4582+ }
4583+
4584+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{existing}, nil).Once()
4585+ envLoader.EXPECT().LoadEnv(mock.Anything, containerName).Return([]string{}, nil).Once()
4586+ runtime.EXPECT().InspectImageEnv(mock.Anything, serviceImage).Return([]string{}, nil).Once()
4587+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{serviceImage}, nil).Once()
4588+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, serviceImage).Return([]int{}, nil).Once()
4589+ runtime.EXPECT().VolumeExists(mock.Anything, "missing-data").Return(false, nil).Once()
4590+
4591+ err := svc.deployAttachedService(testContext(), ownerDomain, serviceImage, "gordon-net")
4592+
4593+ require.ErrorContains(t, err, "previously mounted volume")
4594+ require.ErrorIs(t, err, domain.ErrVolumeNotFound)
4595+}
4596+
4597+func TestDeployAttachedService_ReusesVolumeFromLegacyNamedAttachment(t *testing.T) {
4598+ runtime := mocks.NewMockContainerRuntime(t)
4599+ envLoader := mocks.NewMockEnvLoader(t)
4600+ eventBus := mocks.NewMockEventPublisher(t)
4601+
4602+ config := testMinDelayConfig()
4603+ config.VolumeAutoCreate = true
4604+ config.VolumePrefix = "gordon"
4605+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
4606+ ctx := testContext()
4607+
4608+ ownerDomain := "app.example.com"
4609+ serviceImage := "postgres:16"
4610+ serviceName := "postgres"
4611+ networkName := "gordon-net"
4612+ containerName := fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainer(ownerDomain), serviceName)
4613+ legacyContainerName := fmt.Sprintf("gordon-%s-%s", domain.SanitizeDomainForContainerLegacy(ownerDomain), serviceName)
4614+ volumePath := "/var/lib/postgresql"
4615+ legacyVolume := legacyVolumeName(config.VolumePrefix, legacyContainerName, volumePath)
4616+ existing := &domain.Container{
4617+ ID: "legacy-postgres",
4618+ Name: legacyContainerName,
4619+ Status: string(domain.ContainerStatusRunning),
4620+ Labels: map[string]string{
4621+ domain.LabelManaged: "true",
4622+ domain.LabelAttachment: "true",
4623+ domain.LabelAttachedTo: ownerDomain,
4624+ },
4625+ VolumeMounts: []domain.ContainerVolumeMount{{
4626+ Name: legacyVolume,
4627+ Type: "volume",
4628+ Destination: volumePath,
4629+ }},
4630+ }
4631+
4632+ runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{existing}, nil).Times(2)
4633+ runtime.EXPECT().StopContainer(mock.Anything, existing.ID).Return(nil).Once()
4634+ runtime.EXPECT().RemoveContainer(mock.Anything, existing.ID, true).Return(nil).Once()
4635+ runtime.EXPECT().ListImages(mock.Anything).Return([]string{serviceImage}, nil).Once()
4636+ runtime.EXPECT().GetImageExposedPorts(mock.Anything, serviceImage).Return([]int{}, nil).Once()
4637+ runtime.EXPECT().InspectImageVolumes(mock.Anything, serviceImage).Return([]string{volumePath}, nil).Once()
4638+ runtime.EXPECT().VolumeExists(mock.Anything, legacyVolume).Return(true, nil).Once()
4639+ envLoader.EXPECT().LoadEnv(mock.Anything, containerName).Return([]string{}, nil).Once()
4640+ runtime.EXPECT().InspectImageEnv(mock.Anything, serviceImage).Return([]string{}, nil).Once()
4641+ runtime.EXPECT().CreateContainer(mock.Anything, mock.AnythingOfType("*domain.ContainerConfig")).
4642+ Run(func(_ context.Context, cfg *domain.ContainerConfig) {
4643+ assert.Equal(t, map[string]string{volumePath: legacyVolume}, cfg.Volumes)
4644+ }).
4645+ Return(&domain.Container{ID: "new-postgres", Name: containerName}, nil).Once()
4646+ runtime.EXPECT().StartContainer(mock.Anything, "new-postgres").Return(nil).Once()
4647+ runtime.EXPECT().IsContainerRunning(mock.Anything, "new-postgres").Return(true, nil).Times(2)
4648+ runtime.EXPECT().GetContainerHealthStatus(mock.Anything, "new-postgres").Return("", false, nil).Once()
4649+
4650+ require.NoError(t, svc.deployAttachedService(ctx, ownerDomain, serviceImage, networkName))
4651+}
4652+
4377 func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {4653 func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) {
4378 runtime := mocks.NewMockContainerRuntime(t)4654 runtime := mocks.NewMockContainerRuntime(t)
4379 envLoader := mocks.NewMockEnvLoader(t)4655 envLoader := mocks.NewMockEnvLoader(t)

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