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 Unverified
B5690EEEBB952194The key is unknown
3 files · +411 -62
M internal/domain/errors.go +4 -3
| @@ -42,9 +42,10 @@ var ( | |||
| 42 | 42 | ErrNetworkExists = errors.New("network already exists") | |
| 43 | 43 | ||
| 44 | 44 | // 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") | |
| 48 | 49 | ||
| 49 | 50 | // Preview errors | |
| 50 | 51 | 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 errors | 44 | // 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 errors | 50 | // 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 | |||
| 607 | 607 | } | |
| 608 | 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 | 611 | if err != nil { | |
| 612 | 612 | return nil, err | |
| 613 | 613 | } | |
| @@ -2513,13 +2513,16 @@ func (s *Service) loadEnvironment(ctx context.Context, preResolved []string, dom | |||
| 2513 | 2513 | 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 | 2517 | s.mu.RLock() | |
| 2518 | 2518 | cfg := s.config | |
| 2519 | 2519 | s.mu.RUnlock() | |
| 2520 | 2520 | ||
| 2521 | 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 | 2527 | if !cfg.VolumeAutoCreate { | |
| 2525 | 2528 | return volumes, nil | |
| @@ -2532,6 +2535,10 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) | |||
| 2532 | 2535 | } | |
| 2533 | 2536 | ||
| 2534 | 2537 | for _, path := range volumePaths { | |
| 2538 | + | if _, preferred := volumes[path]; preferred { | |
| 2539 | + | continue | |
| 2540 | + | } | |
| 2541 | + | ||
| 2535 | 2542 | name := generateVolumeName(cfg.VolumePrefix, domainName, path) | |
| 2536 | 2543 | legacyName := legacyVolumeName(cfg.VolumePrefix, domainName, path) | |
| 2537 | 2544 | legacyExists, err := s.runtime.VolumeExists(ctx, legacyName) | |
| @@ -2539,11 +2546,12 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) | |||
| 2539 | 2546 | log.WrapErrWithFields(err, "failed to check legacy volume", map[string]any{"volume": legacyName}) | |
| 2540 | 2547 | continue | |
| 2541 | 2548 | } | |
| 2549 | + | legacyOwnership := legacyVolumeOwnershipUnverified | |
| 2542 | 2550 | 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 { | |
| 2547 | 2555 | volumes[path] = legacyName | |
| 2548 | 2556 | continue | |
| 2549 | 2557 | } | |
| @@ -2556,6 +2564,11 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) | |||
| 2556 | 2564 | } | |
| 2557 | 2565 | ||
| 2558 | 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 | 2572 | if err := s.runtime.CreateVolume(ctx, name); err != nil { | |
| 2560 | 2573 | log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name}) | |
| 2561 | 2574 | continue | |
| @@ -2569,6 +2582,24 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) | |||
| 2569 | 2582 | 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 | 2603 | func (s *Service) getNetworkForApp(domainName string) string { | |
| 2573 | 2604 | var networkIsolation bool | |
| 2574 | 2605 | var networkGroups map[string][]string | |
| @@ -2922,8 +2953,9 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2922 | 2953 | if err != nil { | |
| 2923 | 2954 | return err | |
| 2924 | 2955 | } | |
| 2956 | + | preferredVolumes := namedVolumeMounts(existingContainer) | |
| 2925 | 2957 | ||
| 2926 | - | if existingContainer != nil { | |
| 2958 | + | if existingContainer != nil && existingContainer.Name == containerName { | |
| 2927 | 2959 | shouldSkip, err := s.handleRunningAttachment(ctx, existingContainer, containerName, serviceImage) | |
| 2928 | 2960 | if err != nil { | |
| 2929 | 2961 | return err | |
| @@ -2931,22 +2963,19 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2931 | 2963 | if shouldSkip { | |
| 2932 | 2964 | 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 | 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 | 2971 | if err != nil { | |
| 2947 | 2972 | return err | |
| 2948 | 2973 | } | |
| 2949 | 2974 | ||
| 2975 | + | if err := s.removeAttachmentForReplacement(ctx, existingContainer, containerName); err != nil { | |
| 2976 | + | return err | |
| 2977 | + | } | |
| 2978 | + | ||
| 2950 | 2979 | container, err := s.runtime.CreateContainer(ctx, config) | |
| 2951 | 2980 | if err != nil { | |
| 2952 | 2981 | return log.WrapErr(err, "failed to create attachment container") | |
| @@ -2979,7 +3008,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2979 | 3008 | 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 | 3012 | imageRef, err := s.buildValidatedImageRef(ctx, serviceImage) | |
| 2984 | 3013 | if err != nil { | |
| 2985 | 3014 | return nil, err | |
| @@ -2990,7 +3019,7 @@ func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomai | |||
| 2990 | 3019 | } | |
| 2991 | 3020 | ||
| 2992 | 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 | 3023 | if err != nil { | |
| 2995 | 3024 | return nil, err | |
| 2996 | 3025 | } | |
| @@ -3004,14 +3033,15 @@ func (s *Service) buildAttachmentContainerConfig(ctx context.Context, ownerDomai | |||
| 3004 | 3033 | s.mu.RUnlock() | |
| 3005 | 3034 | ||
| 3006 | 3035 | 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, | |
| 3015 | 3045 | Labels: map[string]string{ | |
| 3016 | 3046 | domain.LabelManaged: "true", | |
| 3017 | 3047 | domain.LabelAttachment: "true", | |
| @@ -3038,12 +3068,37 @@ func (s *Service) attachmentExposedPorts(ctx context.Context, imageRef string) [ | |||
| 3038 | 3068 | return exposedPorts | |
| 3039 | 3069 | } | |
| 3040 | 3070 | ||
| 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) | |
| 3043 | 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, 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 | |
| 3047 | 3102 | } | |
| 3048 | 3103 | ||
| 3049 | 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 | 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 | 3160 | 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 | 3164 | if existing == nil { | |
| 3117 | 3165 | return nil | |
| 3118 | 3166 | } | |
| 3119 | 3167 | ||
| 3120 | 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 | 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 | 3180 | return nil | |
| @@ -3147,14 +3201,7 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||
| 3147 | 3201 | return nil, err | |
| 3148 | 3202 | } | |
| 3149 | 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 { | |
| 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 | |
| 3158 | 3205 | } | |
| 3159 | 3206 | ||
| 3160 | 3207 | func validateAttachmentOwnership(container *domain.Container, ownerDomain, containerName string) error { | |
| @@ -3226,23 +3273,48 @@ func legacyVolumeName(prefix, domainName, volumePath string) string { | |||
| 3226 | 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 | 3285 | containers, err := s.runtime.ListContainers(ctx, true) | |
| 3231 | 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 | 3291 | 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) { | |
| 3237 | 3293 | continue | |
| 3238 | 3294 | } | |
| 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 | |
| 3243 | 3299 | } | |
| 3244 | 3300 | } | |
| 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 | |
| 3246 | 3318 | } | |
| 3247 | 3319 | ||
| 3248 | 3320 | 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, err | 612 | return nil, err | ||
| 613 | } | 613 | } | ||
| @@ -2513,13 +2513,16 @@ func (s *Service) loadEnvironment(ctx context.Context, preResolved []string, dom | |||||
| 2513 | return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil | 2513 | 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.config | 2518 | 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, nil | 2528 | 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 | continue | 2547 | 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] = legacyName | 2555 | volumes[path] = legacyName | ||
| 2548 | continue | 2556 | 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 | continue | 2574 | continue | ||
| @@ -2569,6 +2582,24 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) | |||||
| 2569 | return volumes, nil | 2582 | 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 bool | 2604 | var networkIsolation bool | ||
| 2574 | var networkGroups map[string][]string | 2605 | 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 err | 2954 | 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 err | 2961 | return err | ||
| @@ -2931,22 +2963,19 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2931 | if shouldSkip { | 2963 | if shouldSkip { | ||
| 2932 | return nil | 2964 | 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 err | 2972 | 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 nil | 3008 | 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, err | 3014 | 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, err | 3024 | 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 exposedPorts | 3068 | 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, 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 | |||
| 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, nil | 3160 | 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 nil | 3165 | 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 nil | 3180 | return nil | ||
| @@ -3147,14 +3201,7 @@ func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, se | |||||
| 3147 | return nil, err | 3201 | 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 | continue | 3293 | 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, nil | 3297 | + | } else { |
| 3242 | - | } | 3298 | + | foreignFound = true |
| 3243 | } | 3299 | } | ||
| 3244 | } | 3300 | } | ||
| 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 | |||
| 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) { | |||
| 1799 | 1799 | VolumeMounts: []domain.ContainerVolumeMount{{Name: legacyName}}, | |
| 1800 | 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 | 1804 | require.NoError(t, err) | |
| 1805 | 1805 | assert.Equal(t, map[string]string{"/data": legacyName}, volumes) | |
| @@ -1820,12 +1820,93 @@ func TestService_SetupVolumes_DoesNotReuseCollidingLegacyVolume(t *testing.T) { | |||
| 1820 | 1820 | }}, nil) | |
| 1821 | 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 | 1825 | require.NoError(t, err) | |
| 1826 | 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 | 1910 | func TestValidateAttachmentOwnership(t *testing.T) { | |
| 1830 | 1911 | owned := &domain.Container{Labels: map[string]string{ | |
| 1831 | 1912 | domain.LabelManaged: "true", domain.LabelAttachment: "true", domain.LabelAttachedTo: "app.example.com", | |
| @@ -4374,6 +4455,201 @@ func TestService_WaitForAttachmentReady_FallsBackToDelay(t *testing.T) { | |||
| 4374 | 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 | 4653 | func TestDeployAttachedService_SetsAliasOnContainerConfig(t *testing.T) { | |
| 4378 | 4654 | runtime := mocks.NewMockContainerRuntime(t) | |
| 4379 | 4655 | 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) | ||