feat: add env-aware redundant deploy detection with hash label
- Add EventSecretsChanged event type and SecretsChangedPayload to domain - Add LabelEnvHash container label constant (gordon.env-hash) - Implement hashEnvironment() with length-prefixed framing for collision safety - Make mergeEnvironmentVariables() output sorted for deterministic hashing - Store env hash label on newly deployed containers via buildContainerConfig - Extend skipRedundantDeploy to compare env hash alongside image ID - Missing env hash label (pre-upgrade containers) forces redeploy (safe default) Closes half of #109: deploy now detects env drift and will not skip when secrets/env have changed, even if the image is identical.
- commit
- e7424b044eebfd0e8259aec266dc8b357c2db85c
- author
- brice · · 2026-03-14 18:18 UTC
- committer
- brice · · 2026-03-14 18:55 UTC
- parent
- 9a3a764
- signature
- unsigned
6 files · +412 -30
M internal/domain/event.go +8 -0
| @@ -18,6 +18,7 @@ const ( | |||
| 18 | 18 | EventContainerStart EventType = "container.start" | |
| 19 | 19 | EventContainerHealthCheck EventType = "container.health_check" | |
| 20 | 20 | EventContainerDeployed EventType = "container.deployed" | |
| 21 | + | EventSecretsChanged EventType = "secrets.changed" | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| 23 | 24 | // Event represents a domain event that occurred in the system. | |
| @@ -61,6 +62,13 @@ type ManualDeployPayload struct { | |||
| 61 | 62 | Domain string `json:"domain"` | |
| 62 | 63 | } | |
| 63 | 64 | ||
| 65 | + | // SecretsChangedPayload contains data for secrets.changed events. | |
| 66 | + | type SecretsChangedPayload struct { | |
| 67 | + | Domain string // Route domain whose secrets changed | |
| 68 | + | Operation string // "set" or "delete" | |
| 69 | + | Keys []string // Secret key names (not values) | |
| 70 | + | } | |
| 71 | + | ||
| 64 | 72 | // Context keys for domain-level concerns. | |
| 65 | 73 | type contextKey string | |
| 66 | 74 | ||
| @@ -18,6 +18,7 @@ const ( | |||||
| 18 | EventContainerStart EventType = "container.start" | 18 | EventContainerStart EventType = "container.start" | ||
| 19 | EventContainerHealthCheck EventType = "container.health_check" | 19 | EventContainerHealthCheck EventType = "container.health_check" | ||
| 20 | EventContainerDeployed EventType = "container.deployed" | 20 | EventContainerDeployed EventType = "container.deployed" | ||
| 21 | + | EventSecretsChanged EventType = "secrets.changed" | |||
| 21 | ) | 22 | ) | ||
| 22 | 23 | ||||
| 23 | // Event represents a domain event that occurred in the system. | 24 | // Event represents a domain event that occurred in the system. | ||
| @@ -61,6 +62,13 @@ type ManualDeployPayload struct { | |||||
| 61 | Domain string `json:"domain"` | 62 | Domain string `json:"domain"` | ||
| 62 | } | 63 | } | ||
| 63 | 64 | ||||
| 65 | + | // SecretsChangedPayload contains data for secrets.changed events. | |||
| 66 | + | type SecretsChangedPayload struct { | |||
| 67 | + | Domain string // Route domain whose secrets changed | |||
| 68 | + | Operation string // "set" or "delete" | |||
| 69 | + | Keys []string // Secret key names (not values) | |||
| 70 | + | } | |||
| 71 | + | ||||
| 64 | // Context keys for domain-level concerns. | 72 | // Context keys for domain-level concerns. | ||
| 65 | type contextKey string | 73 | type contextKey string | ||
| 66 | 74 | ||||
M internal/domain/labels.go +4 -0
| @@ -10,6 +10,10 @@ const ( | |||
| 10 | 10 | LabelAttachment = "gordon.attachment" | |
| 11 | 11 | LabelAttachedTo = "gordon.attached-to" | |
| 12 | 12 | LabelCreated = "gordon.created" | |
| 13 | + | // LabelEnvHash stores a SHA-256 hash of the effective environment | |
| 14 | + | // variables at deploy time, used to detect env drift without | |
| 15 | + | // exposing secret values. | |
| 16 | + | LabelEnvHash = "gordon.env-hash" | |
| 13 | 17 | ||
| 14 | 18 | // Image labels (set in Dockerfile) | |
| 15 | 19 | LabelProxyPort = "gordon.proxy.port" | |
| @@ -10,6 +10,10 @@ const ( | |||||
| 10 | LabelAttachment = "gordon.attachment" | 10 | LabelAttachment = "gordon.attachment" | ||
| 11 | LabelAttachedTo = "gordon.attached-to" | 11 | LabelAttachedTo = "gordon.attached-to" | ||
| 12 | LabelCreated = "gordon.created" | 12 | LabelCreated = "gordon.created" | ||
| 13 | + | // LabelEnvHash stores a SHA-256 hash of the effective environment | |||
| 14 | + | // variables at deploy time, used to detect env drift without | |||
| 15 | + | // exposing secret values. | |||
| 16 | + | LabelEnvHash = "gordon.env-hash" | |||
| 13 | 17 | ||||
| 14 | // Image labels (set in Dockerfile) | 18 | // Image labels (set in Dockerfile) | ||
| 15 | LabelProxyPort = "gordon.proxy.port" | 19 | LabelProxyPort = "gordon.proxy.port" | ||
A internal/usecase/container/env_hash.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "fmt" | |
| 6 | + | "slices" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // hashEnvironment computes a stable SHA-256 hash of a set of KEY=VALUE pairs. | |
| 10 | + | // The input is sorted by key before hashing so map iteration order does not | |
| 11 | + | // affect the result. The returned string is the hex-encoded digest. | |
| 12 | + | func hashEnvironment(env []string) string { | |
| 13 | + | sorted := make([]string, len(env)) | |
| 14 | + | copy(sorted, env) | |
| 15 | + | slices.Sort(sorted) | |
| 16 | + | ||
| 17 | + | h := sha256.New() | |
| 18 | + | for _, kv := range sorted { | |
| 19 | + | // Length-prefix each entry so embedded newlines cannot collide. | |
| 20 | + | _, _ = fmt.Fprintf(h, "%d:%s\n", len(kv), kv) | |
| 21 | + | } | |
| 22 | + | return fmt.Sprintf("%x", h.Sum(nil)) | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/sha256" | |||
| 5 | + | "fmt" | |||
| 6 | + | "slices" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // hashEnvironment computes a stable SHA-256 hash of a set of KEY=VALUE pairs. | |||
| 10 | + | // The input is sorted by key before hashing so map iteration order does not | |||
| 11 | + | // affect the result. The returned string is the hex-encoded digest. | |||
| 12 | + | func hashEnvironment(env []string) string { | |||
| 13 | + | sorted := make([]string, len(env)) | |||
| 14 | + | copy(sorted, env) | |||
| 15 | + | slices.Sort(sorted) | |||
| 16 | + | ||||
| 17 | + | h := sha256.New() | |||
| 18 | + | for _, kv := range sorted { | |||
| 19 | + | // Length-prefix each entry so embedded newlines cannot collide. | |||
| 20 | + | _, _ = fmt.Fprintf(h, "%d:%s\n", len(kv), kv) | |||
| 21 | + | } | |||
| 22 | + | return fmt.Sprintf("%x", h.Sum(nil)) | |||
| 23 | + | } | |||
A internal/usecase/container/env_hash_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package container | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestHashEnvironment_Deterministic(t *testing.T) { | |
| 11 | + | env1 := []string{"B=2", "A=1", "C=3"} | |
| 12 | + | env2 := []string{"A=1", "C=3", "B=2"} | |
| 13 | + | assert.Equal(t, hashEnvironment(env1), hashEnvironment(env2)) | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func TestHashEnvironment_DifferentValues(t *testing.T) { | |
| 17 | + | env1 := []string{"A=1", "B=2"} | |
| 18 | + | env2 := []string{"A=1", "B=3"} | |
| 19 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func TestHashEnvironment_DifferentKeys(t *testing.T) { | |
| 23 | + | env1 := []string{"A=1", "B=2"} | |
| 24 | + | env2 := []string{"A=1", "C=2"} | |
| 25 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestHashEnvironment_Empty(t *testing.T) { | |
| 29 | + | hash := hashEnvironment(nil) | |
| 30 | + | require.NotEmpty(t, hash) | |
| 31 | + | assert.Equal(t, hash, hashEnvironment([]string{})) | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestHashEnvironment_ExtraKey(t *testing.T) { | |
| 35 | + | env1 := []string{"A=1"} | |
| 36 | + | env2 := []string{"A=1", "B=2"} | |
| 37 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestHashEnvironment_NewlineInValue(t *testing.T) { | |
| 41 | + | env1 := []string{"A=1\nB=2"} | |
| 42 | + | env2 := []string{"A=1", "B=2"} | |
| 43 | + | ||
| 44 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestMergeEnvironmentVariables_Sorted(t *testing.T) { | |
| 48 | + | dockerfile := []string{"Z=26", "A=1"} | |
| 49 | + | user := []string{"M=13", "B=2"} | |
| 50 | + | result := mergeEnvironmentVariables(dockerfile, user) | |
| 51 | + | assert.Equal(t, []string{"A=1", "B=2", "M=13", "Z=26"}, result) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func TestMergeEnvironmentVariables_UserOverridesDockerfile(t *testing.T) { | |
| 55 | + | dockerfile := []string{"A=old"} | |
| 56 | + | user := []string{"A=new"} | |
| 57 | + | result := mergeEnvironmentVariables(dockerfile, user) | |
| 58 | + | assert.Equal(t, []string{"A=new"}, result) | |
| 59 | + | } | |
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package container | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestHashEnvironment_Deterministic(t *testing.T) { | |||
| 11 | + | env1 := []string{"B=2", "A=1", "C=3"} | |||
| 12 | + | env2 := []string{"A=1", "C=3", "B=2"} | |||
| 13 | + | assert.Equal(t, hashEnvironment(env1), hashEnvironment(env2)) | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func TestHashEnvironment_DifferentValues(t *testing.T) { | |||
| 17 | + | env1 := []string{"A=1", "B=2"} | |||
| 18 | + | env2 := []string{"A=1", "B=3"} | |||
| 19 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func TestHashEnvironment_DifferentKeys(t *testing.T) { | |||
| 23 | + | env1 := []string{"A=1", "B=2"} | |||
| 24 | + | env2 := []string{"A=1", "C=2"} | |||
| 25 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestHashEnvironment_Empty(t *testing.T) { | |||
| 29 | + | hash := hashEnvironment(nil) | |||
| 30 | + | require.NotEmpty(t, hash) | |||
| 31 | + | assert.Equal(t, hash, hashEnvironment([]string{})) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestHashEnvironment_ExtraKey(t *testing.T) { | |||
| 35 | + | env1 := []string{"A=1"} | |||
| 36 | + | env2 := []string{"A=1", "B=2"} | |||
| 37 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestHashEnvironment_NewlineInValue(t *testing.T) { | |||
| 41 | + | env1 := []string{"A=1\nB=2"} | |||
| 42 | + | env2 := []string{"A=1", "B=2"} | |||
| 43 | + | ||||
| 44 | + | assert.NotEqual(t, hashEnvironment(env1), hashEnvironment(env2)) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestMergeEnvironmentVariables_Sorted(t *testing.T) { | |||
| 48 | + | dockerfile := []string{"Z=26", "A=1"} | |||
| 49 | + | user := []string{"M=13", "B=2"} | |||
| 50 | + | result := mergeEnvironmentVariables(dockerfile, user) | |||
| 51 | + | assert.Equal(t, []string{"A=1", "B=2", "M=13", "Z=26"}, result) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func TestMergeEnvironmentVariables_UserOverridesDockerfile(t *testing.T) { | |||
| 55 | + | dockerfile := []string{"A=old"} | |||
| 56 | + | user := []string{"A=new"} | |||
| 57 | + | result := mergeEnvironmentVariables(dockerfile, user) | |||
| 58 | + | assert.Equal(t, []string{"A=new"}, result) | |||
| 59 | + | } | |||
M internal/usecase/container/service.go +128 -30
| @@ -210,7 +210,7 @@ func (s *Service) deploymentContainerName(containerDomain string, existing *doma | |||
| 210 | 210 | } | |
| 211 | 211 | } | |
| 212 | 212 | ||
| 213 | - | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig { | |
| 213 | + | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, envHash string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig { | |
| 214 | 214 | containerName := s.deploymentContainerName(containerDomain, existing) | |
| 215 | 215 | ||
| 216 | 216 | return &domain.ContainerConfig{ | |
| @@ -223,6 +223,7 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st | |||
| 223 | 223 | Hostname: containerDomain, | |
| 224 | 224 | Labels: map[string]string{ | |
| 225 | 225 | domain.LabelDomain: containerDomain, | |
| 226 | + | domain.LabelEnvHash: envHash, | |
| 226 | 227 | domain.LabelImage: image, | |
| 227 | 228 | domain.LabelManaged: "true", | |
| 228 | 229 | domain.LabelRoute: containerDomain, | |
| @@ -279,7 +280,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||
| 279 | 280 | existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip) | |
| 280 | 281 | } | |
| 281 | 282 | if existingForSkip.ImageID != "" { | |
| 282 | - | if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef); skip { | |
| 283 | + | if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef, resources.envHash); skip { | |
| 283 | 284 | return container, nil | |
| 284 | 285 | } | |
| 285 | 286 | } | |
| @@ -351,7 +352,7 @@ func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, rout | |||
| 351 | 352 | // When a push triggers both an event-based deploy and an explicit CLI deploy, | |
| 352 | 353 | // the second one arrives after the first has already completed; this avoids | |
| 353 | 354 | // a full redundant create-start-readiness cycle. | |
| 354 | - | func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef string) (bool, *domain.Container) { | |
| 355 | + | func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef, envHash string) (bool, *domain.Container) { | |
| 355 | 356 | log := zerowrap.FromCtx(ctx) | |
| 356 | 357 | ||
| 357 | 358 | newImageID, err := s.runtime.GetImageID(ctx, actualImageRef) | |
| @@ -370,6 +371,19 @@ func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Cont | |||
| 370 | 371 | return false, nil | |
| 371 | 372 | } | |
| 372 | 373 | ||
| 374 | + | if existing.Labels != nil { | |
| 375 | + | existingEnvHash, hasEnvHash := existing.Labels[domain.LabelEnvHash] | |
| 376 | + | if !hasEnvHash || existingEnvHash != envHash { | |
| 377 | + | log.Info(). | |
| 378 | + | Str("container_id", existing.ID). | |
| 379 | + | Bool("has_env_hash", hasEnvHash). | |
| 380 | + | Msg("env changed, proceeding with deploy despite same image") | |
| 381 | + | return false, nil | |
| 382 | + | } | |
| 383 | + | } else { | |
| 384 | + | return false, nil | |
| 385 | + | } | |
| 386 | + | ||
| 373 | 387 | log.Info(). | |
| 374 | 388 | Str("container_id", existing.ID). | |
| 375 | 389 | Str("image_id", newImageID). | |
| @@ -403,6 +417,7 @@ type deployResources struct { | |||
| 403 | 417 | actualImageRef string | |
| 404 | 418 | exposedPorts []int | |
| 405 | 419 | envVars []string | |
| 420 | + | envHash string | |
| 406 | 421 | volumes map[string]string | |
| 407 | 422 | } | |
| 408 | 423 | ||
| @@ -505,6 +520,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||
| 505 | 520 | if err != nil { | |
| 506 | 521 | return nil, err | |
| 507 | 522 | } | |
| 523 | + | envHash := hashEnvironment(envVars) | |
| 508 | 524 | ||
| 509 | 525 | volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef) | |
| 510 | 526 | if err != nil { | |
| @@ -516,6 +532,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||
| 516 | 532 | actualImageRef: actualImageRef, | |
| 517 | 533 | exposedPorts: exposedPorts, | |
| 518 | 534 | envVars: envVars, | |
| 535 | + | envHash: envHash, | |
| 519 | 536 | volumes: volumes, | |
| 520 | 537 | }, nil | |
| 521 | 538 | } | |
| @@ -532,6 +549,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||
| 532 | 549 | resources.actualImageRef, | |
| 533 | 550 | resources.exposedPorts, | |
| 534 | 551 | resources.envVars, | |
| 552 | + | resources.envHash, | |
| 535 | 553 | resources.volumes, | |
| 536 | 554 | resources.networkName, | |
| 537 | 555 | existing, | |
| @@ -2031,37 +2049,28 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2031 | 2049 | ||
| 2032 | 2050 | // Check if already running (idempotent) | |
| 2033 | 2051 | // Try new name first, then fall back to legacy name for backwards compatibility | |
| 2034 | - | existingContainer := s.findContainerByName(ctx, containerName) | |
| 2035 | - | if existingContainer == nil { | |
| 2036 | - | // Try legacy naming scheme for backwards compatibility during upgrades | |
| 2037 | - | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |
| 2038 | - | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |
| 2039 | - | if existingContainer != nil { | |
| 2040 | - | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |
| 2041 | - | // Remove the old container and recreate with new name | |
| 2042 | - | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |
| 2043 | - | return log.WrapErr(err, "failed to stop legacy attachment container") | |
| 2044 | - | } | |
| 2045 | - | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |
| 2046 | - | return log.WrapErr(err, "failed to remove legacy attachment container") | |
| 2047 | - | } | |
| 2048 | - | existingContainer = nil // Clear so we create new one below | |
| 2049 | - | } | |
| 2050 | - | } | |
| 2051 | - | ||
| 2052 | - | if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) { | |
| 2053 | - | log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping") | |
| 2054 | - | return nil | |
| 2052 | + | existingContainer, err := s.resolveExistingAttachment(ctx, ownerDomain, serviceName) | |
| 2053 | + | if err != nil { | |
| 2054 | + | return err | |
| 2055 | 2055 | } | |
| 2056 | 2056 | ||
| 2057 | - | // Remove existing stopped container if present | |
| 2058 | 2057 | if existingContainer != nil { | |
| 2059 | - | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | |
| 2060 | - | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |
| 2061 | - | return log.WrapErr(err, "failed to remove existing attachment container") | |
| 2058 | + | shouldSkip, err := s.handleRunningAttachment(ctx, existingContainer, containerName, serviceImage) | |
| 2059 | + | if err != nil { | |
| 2060 | + | return err | |
| 2061 | + | } | |
| 2062 | + | if shouldSkip { | |
| 2063 | + | return nil | |
| 2064 | + | } | |
| 2065 | + | if existingContainer.Status == string(domain.ContainerStatusRunning) { | |
| 2066 | + | existingContainer = nil | |
| 2062 | 2067 | } | |
| 2063 | 2068 | } | |
| 2064 | 2069 | ||
| 2070 | + | if err := s.removeStoppedAttachment(ctx, existingContainer, containerName); err != nil { | |
| 2071 | + | return err | |
| 2072 | + | } | |
| 2073 | + | ||
| 2065 | 2074 | log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service") | |
| 2066 | 2075 | ||
| 2067 | 2076 | // Ensure image (canonical ref is returned; internal pulls may use the local registry). | |
| @@ -2091,6 +2100,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2091 | 2100 | log.WrapErr(err, "failed to load environment for attachment") | |
| 2092 | 2101 | envVars = []string{} | |
| 2093 | 2102 | } | |
| 2103 | + | envHash := hashEnvironment(envVars) | |
| 2094 | 2104 | ||
| 2095 | 2105 | // Create container with attachment labels (use actual ref, track original in labels) | |
| 2096 | 2106 | config := &domain.ContainerConfig{ | |
| @@ -2105,6 +2115,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2105 | 2115 | domain.LabelManaged: "true", | |
| 2106 | 2116 | domain.LabelAttachment: "true", | |
| 2107 | 2117 | domain.LabelAttachedTo: ownerDomain, | |
| 2118 | + | domain.LabelEnvHash: envHash, | |
| 2108 | 2119 | domain.LabelImage: serviceImage, | |
| 2109 | 2120 | }, | |
| 2110 | 2121 | } | |
| @@ -2132,6 +2143,87 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||
| 2132 | 2143 | return nil | |
| 2133 | 2144 | } | |
| 2134 | 2145 | ||
| 2146 | + | func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | |
| 2147 | + | currentEnv, err := s.loadEnvironment(ctx, containerName, s.buildImageRef(serviceImage)) | |
| 2148 | + | if err != nil { | |
| 2149 | + | return false, err | |
| 2150 | + | } | |
| 2151 | + | ||
| 2152 | + | currentHash := hashEnvironment(currentEnv) | |
| 2153 | + | existingHash, ok := existing.Labels[domain.LabelEnvHash] | |
| 2154 | + | if ok && existingHash == currentHash { | |
| 2155 | + | return false, nil | |
| 2156 | + | } | |
| 2157 | + | ||
| 2158 | + | return true, nil | |
| 2159 | + | } | |
| 2160 | + | ||
| 2161 | + | func (s *Service) handleRunningAttachment(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | |
| 2162 | + | if existing.Status != string(domain.ContainerStatusRunning) { | |
| 2163 | + | return false, nil | |
| 2164 | + | } | |
| 2165 | + | ||
| 2166 | + | log := zerowrap.FromCtx(ctx) | |
| 2167 | + | envDrifted, err := s.attachmentEnvDrifted(ctx, existing, containerName, serviceImage) | |
| 2168 | + | if err != nil { | |
| 2169 | + | log.WrapErr(err, "failed to check attachment env drift") | |
| 2170 | + | return true, nil | |
| 2171 | + | } | |
| 2172 | + | if !envDrifted { | |
| 2173 | + | log.Debug().Str("container_name", containerName).Msg("attachment already running with current env, skipping") | |
| 2174 | + | return true, nil | |
| 2175 | + | } | |
| 2176 | + | ||
| 2177 | + | log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating") | |
| 2178 | + | if err := s.runtime.StopContainer(ctx, existing.ID); err != nil { | |
| 2179 | + | return false, log.WrapErr(err, "failed to stop attachment for env update") | |
| 2180 | + | } | |
| 2181 | + | if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |
| 2182 | + | return false, log.WrapErr(err, "failed to remove attachment for env update") | |
| 2183 | + | } | |
| 2184 | + | ||
| 2185 | + | return false, nil | |
| 2186 | + | } | |
| 2187 | + | ||
| 2188 | + | func (s *Service) removeStoppedAttachment(ctx context.Context, existing *domain.Container, containerName string) error { | |
| 2189 | + | if existing == nil { | |
| 2190 | + | return nil | |
| 2191 | + | } | |
| 2192 | + | ||
| 2193 | + | log := zerowrap.FromCtx(ctx) | |
| 2194 | + | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | |
| 2195 | + | if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |
| 2196 | + | return log.WrapErr(err, "failed to remove existing attachment container") | |
| 2197 | + | } | |
| 2198 | + | ||
| 2199 | + | return nil | |
| 2200 | + | } | |
| 2201 | + | ||
| 2202 | + | func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, serviceName string) (*domain.Container, error) { | |
| 2203 | + | log := zerowrap.FromCtx(ctx) | |
| 2204 | + | containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName) | |
| 2205 | + | existingContainer := s.findContainerByName(ctx, containerName) | |
| 2206 | + | if existingContainer != nil { | |
| 2207 | + | return existingContainer, nil | |
| 2208 | + | } | |
| 2209 | + | ||
| 2210 | + | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |
| 2211 | + | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |
| 2212 | + | if existingContainer == nil { | |
| 2213 | + | return nil, nil | |
| 2214 | + | } | |
| 2215 | + | ||
| 2216 | + | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |
| 2217 | + | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |
| 2218 | + | return nil, log.WrapErr(err, "failed to stop legacy attachment container") | |
| 2219 | + | } | |
| 2220 | + | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |
| 2221 | + | return nil, log.WrapErr(err, "failed to remove legacy attachment container") | |
| 2222 | + | } | |
| 2223 | + | ||
| 2224 | + | return nil, nil | |
| 2225 | + | } | |
| 2226 | + | ||
| 2135 | 2227 | // Utility functions | |
| 2136 | 2228 | ||
| 2137 | 2229 | func normalizeImageRef(image string) string { | |
| @@ -2192,9 +2284,15 @@ func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | |||
| 2192 | 2284 | } | |
| 2193 | 2285 | } | |
| 2194 | 2286 | ||
| 2287 | + | keys := make([]string, 0, len(envMap)) | |
| 2288 | + | for k := range envMap { | |
| 2289 | + | keys = append(keys, k) | |
| 2290 | + | } | |
| 2291 | + | slices.Sort(keys) | |
| 2292 | + | ||
| 2195 | 2293 | result := make([]string, 0, len(envMap)) | |
| 2196 | - | for k, v := range envMap { | |
| 2197 | - | result = append(result, k+"="+v) | |
| 2294 | + | for _, k := range keys { | |
| 2295 | + | result = append(result, k+"="+envMap[k]) | |
| 2198 | 2296 | } | |
| 2199 | 2297 | ||
| 2200 | 2298 | return result | |
| @@ -210,7 +210,7 @@ func (s *Service) deploymentContainerName(containerDomain string, existing *doma | |||||
| 210 | } | 210 | } | ||
| 211 | } | 211 | } | ||
| 212 | 212 | ||||
| 213 | - | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig { | 213 | + | func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, envHash string, volumes map[string]string, networkName string, existing *domain.Container) *domain.ContainerConfig { |
| 214 | containerName := s.deploymentContainerName(containerDomain, existing) | 214 | containerName := s.deploymentContainerName(containerDomain, existing) | ||
| 215 | 215 | ||||
| 216 | return &domain.ContainerConfig{ | 216 | return &domain.ContainerConfig{ | ||
| @@ -223,6 +223,7 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st | |||||
| 223 | Hostname: containerDomain, | 223 | Hostname: containerDomain, | ||
| 224 | Labels: map[string]string{ | 224 | Labels: map[string]string{ | ||
| 225 | domain.LabelDomain: containerDomain, | 225 | domain.LabelDomain: containerDomain, | ||
| 226 | + | domain.LabelEnvHash: envHash, | |||
| 226 | domain.LabelImage: image, | 227 | domain.LabelImage: image, | ||
| 227 | domain.LabelManaged: "true", | 228 | domain.LabelManaged: "true", | ||
| 228 | domain.LabelRoute: containerDomain, | 229 | domain.LabelRoute: containerDomain, | ||
| @@ -279,7 +280,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta | |||||
| 279 | existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip) | 280 | existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip) | ||
| 280 | } | 281 | } | ||
| 281 | if existingForSkip.ImageID != "" { | 282 | if existingForSkip.ImageID != "" { | ||
| 282 | - | if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef); skip { | 283 | + | if skip, container := s.skipRedundantDeploy(ctx, existingForSkip, resources.actualImageRef, resources.envHash); skip { |
| 283 | return container, nil | 284 | return container, nil | ||
| 284 | } | 285 | } | ||
| 285 | } | 286 | } | ||
| @@ -351,7 +352,7 @@ func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, rout | |||||
| 351 | // When a push triggers both an event-based deploy and an explicit CLI deploy, | 352 | // When a push triggers both an event-based deploy and an explicit CLI deploy, | ||
| 352 | // the second one arrives after the first has already completed; this avoids | 353 | // the second one arrives after the first has already completed; this avoids | ||
| 353 | // a full redundant create-start-readiness cycle. | 354 | // a full redundant create-start-readiness cycle. | ||
| 354 | - | func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef string) (bool, *domain.Container) { | 355 | + | func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Container, actualImageRef, envHash string) (bool, *domain.Container) { |
| 355 | log := zerowrap.FromCtx(ctx) | 356 | log := zerowrap.FromCtx(ctx) | ||
| 356 | 357 | ||||
| 357 | newImageID, err := s.runtime.GetImageID(ctx, actualImageRef) | 358 | newImageID, err := s.runtime.GetImageID(ctx, actualImageRef) | ||
| @@ -370,6 +371,19 @@ func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Cont | |||||
| 370 | return false, nil | 371 | return false, nil | ||
| 371 | } | 372 | } | ||
| 372 | 373 | ||||
| 374 | + | if existing.Labels != nil { | |||
| 375 | + | existingEnvHash, hasEnvHash := existing.Labels[domain.LabelEnvHash] | |||
| 376 | + | if !hasEnvHash || existingEnvHash != envHash { | |||
| 377 | + | log.Info(). | |||
| 378 | + | Str("container_id", existing.ID). | |||
| 379 | + | Bool("has_env_hash", hasEnvHash). | |||
| 380 | + | Msg("env changed, proceeding with deploy despite same image") | |||
| 381 | + | return false, nil | |||
| 382 | + | } | |||
| 383 | + | } else { | |||
| 384 | + | return false, nil | |||
| 385 | + | } | |||
| 386 | + | ||||
| 373 | log.Info(). | 387 | log.Info(). | ||
| 374 | Str("container_id", existing.ID). | 388 | Str("container_id", existing.ID). | ||
| 375 | Str("image_id", newImageID). | 389 | Str("image_id", newImageID). | ||
| @@ -403,6 +417,7 @@ type deployResources struct { | |||||
| 403 | actualImageRef string | 417 | actualImageRef string | ||
| 404 | exposedPorts []int | 418 | exposedPorts []int | ||
| 405 | envVars []string | 419 | envVars []string | ||
| 420 | + | envHash string | |||
| 406 | volumes map[string]string | 421 | volumes map[string]string | ||
| 407 | } | 422 | } | ||
| 408 | 423 | ||||
| @@ -505,6 +520,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||||
| 505 | if err != nil { | 520 | if err != nil { | ||
| 506 | return nil, err | 521 | return nil, err | ||
| 507 | } | 522 | } | ||
| 523 | + | envHash := hashEnvironment(envVars) | |||
| 508 | 524 | ||||
| 509 | volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef) | 525 | volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef) | ||
| 510 | if err != nil { | 526 | if err != nil { | ||
| @@ -516,6 +532,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route | |||||
| 516 | actualImageRef: actualImageRef, | 532 | actualImageRef: actualImageRef, | ||
| 517 | exposedPorts: exposedPorts, | 533 | exposedPorts: exposedPorts, | ||
| 518 | envVars: envVars, | 534 | envVars: envVars, | ||
| 535 | + | envHash: envHash, | |||
| 519 | volumes: volumes, | 536 | volumes: volumes, | ||
| 520 | }, nil | 537 | }, nil | ||
| 521 | } | 538 | } | ||
| @@ -532,6 +549,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route | |||||
| 532 | resources.actualImageRef, | 549 | resources.actualImageRef, | ||
| 533 | resources.exposedPorts, | 550 | resources.exposedPorts, | ||
| 534 | resources.envVars, | 551 | resources.envVars, | ||
| 552 | + | resources.envHash, | |||
| 535 | resources.volumes, | 553 | resources.volumes, | ||
| 536 | resources.networkName, | 554 | resources.networkName, | ||
| 537 | existing, | 555 | existing, | ||
| @@ -2031,37 +2049,28 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2031 | 2049 | ||||
| 2032 | // Check if already running (idempotent) | 2050 | // Check if already running (idempotent) | ||
| 2033 | // Try new name first, then fall back to legacy name for backwards compatibility | 2051 | // Try new name first, then fall back to legacy name for backwards compatibility | ||
| 2034 | - | existingContainer := s.findContainerByName(ctx, containerName) | 2052 | + | existingContainer, err := s.resolveExistingAttachment(ctx, ownerDomain, serviceName) |
| 2035 | - | if existingContainer == nil { | 2053 | + | if err != nil { |
| 2036 | - | // Try legacy naming scheme for backwards compatibility during upgrades | 2054 | + | return err |
| 2037 | - | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |||
| 2038 | - | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |||
| 2039 | - | if existingContainer != nil { | |||
| 2040 | - | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |||
| 2041 | - | // Remove the old container and recreate with new name | |||
| 2042 | - | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |||
| 2043 | - | return log.WrapErr(err, "failed to stop legacy attachment container") | |||
| 2044 | - | } | |||
| 2045 | - | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |||
| 2046 | - | return log.WrapErr(err, "failed to remove legacy attachment container") | |||
| 2047 | - | } | |||
| 2048 | - | existingContainer = nil // Clear so we create new one below | |||
| 2049 | - | } | |||
| 2050 | - | } | |||
| 2051 | - | ||||
| 2052 | - | if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) { | |||
| 2053 | - | log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping") | |||
| 2054 | - | return nil | |||
| 2055 | } | 2055 | } | ||
| 2056 | 2056 | ||||
| 2057 | - | // Remove existing stopped container if present | |||
| 2058 | if existingContainer != nil { | 2057 | if existingContainer != nil { | ||
| 2059 | - | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | 2058 | + | shouldSkip, err := s.handleRunningAttachment(ctx, existingContainer, containerName, serviceImage) |
| 2060 | - | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | 2059 | + | if err != nil { |
| 2061 | - | return log.WrapErr(err, "failed to remove existing attachment container") | 2060 | + | return err |
| 2061 | + | } | |||
| 2062 | + | if shouldSkip { | |||
| 2063 | + | return nil | |||
| 2064 | + | } | |||
| 2065 | + | if existingContainer.Status == string(domain.ContainerStatusRunning) { | |||
| 2066 | + | existingContainer = nil | |||
| 2062 | } | 2067 | } | ||
| 2063 | } | 2068 | } | ||
| 2064 | 2069 | ||||
| 2070 | + | if err := s.removeStoppedAttachment(ctx, existingContainer, containerName); err != nil { | |||
| 2071 | + | return err | |||
| 2072 | + | } | |||
| 2073 | + | ||||
| 2065 | log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service") | 2074 | log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service") | ||
| 2066 | 2075 | ||||
| 2067 | // Ensure image (canonical ref is returned; internal pulls may use the local registry). | 2076 | // Ensure image (canonical ref is returned; internal pulls may use the local registry). | ||
| @@ -2091,6 +2100,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2091 | log.WrapErr(err, "failed to load environment for attachment") | 2100 | log.WrapErr(err, "failed to load environment for attachment") | ||
| 2092 | envVars = []string{} | 2101 | envVars = []string{} | ||
| 2093 | } | 2102 | } | ||
| 2103 | + | envHash := hashEnvironment(envVars) | |||
| 2094 | 2104 | ||||
| 2095 | // Create container with attachment labels (use actual ref, track original in labels) | 2105 | // Create container with attachment labels (use actual ref, track original in labels) | ||
| 2096 | config := &domain.ContainerConfig{ | 2106 | config := &domain.ContainerConfig{ | ||
| @@ -2105,6 +2115,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2105 | domain.LabelManaged: "true", | 2115 | domain.LabelManaged: "true", | ||
| 2106 | domain.LabelAttachment: "true", | 2116 | domain.LabelAttachment: "true", | ||
| 2107 | domain.LabelAttachedTo: ownerDomain, | 2117 | domain.LabelAttachedTo: ownerDomain, | ||
| 2118 | + | domain.LabelEnvHash: envHash, | |||
| 2108 | domain.LabelImage: serviceImage, | 2119 | domain.LabelImage: serviceImage, | ||
| 2109 | }, | 2120 | }, | ||
| 2110 | } | 2121 | } | ||
| @@ -2132,6 +2143,87 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic | |||||
| 2132 | return nil | 2143 | return nil | ||
| 2133 | } | 2144 | } | ||
| 2134 | 2145 | ||||
| 2146 | + | func (s *Service) attachmentEnvDrifted(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | |||
| 2147 | + | currentEnv, err := s.loadEnvironment(ctx, containerName, s.buildImageRef(serviceImage)) | |||
| 2148 | + | if err != nil { | |||
| 2149 | + | return false, err | |||
| 2150 | + | } | |||
| 2151 | + | ||||
| 2152 | + | currentHash := hashEnvironment(currentEnv) | |||
| 2153 | + | existingHash, ok := existing.Labels[domain.LabelEnvHash] | |||
| 2154 | + | if ok && existingHash == currentHash { | |||
| 2155 | + | return false, nil | |||
| 2156 | + | } | |||
| 2157 | + | ||||
| 2158 | + | return true, nil | |||
| 2159 | + | } | |||
| 2160 | + | ||||
| 2161 | + | func (s *Service) handleRunningAttachment(ctx context.Context, existing *domain.Container, containerName, serviceImage string) (bool, error) { | |||
| 2162 | + | if existing.Status != string(domain.ContainerStatusRunning) { | |||
| 2163 | + | return false, nil | |||
| 2164 | + | } | |||
| 2165 | + | ||||
| 2166 | + | log := zerowrap.FromCtx(ctx) | |||
| 2167 | + | envDrifted, err := s.attachmentEnvDrifted(ctx, existing, containerName, serviceImage) | |||
| 2168 | + | if err != nil { | |||
| 2169 | + | log.WrapErr(err, "failed to check attachment env drift") | |||
| 2170 | + | return true, nil | |||
| 2171 | + | } | |||
| 2172 | + | if !envDrifted { | |||
| 2173 | + | log.Debug().Str("container_name", containerName).Msg("attachment already running with current env, skipping") | |||
| 2174 | + | return true, nil | |||
| 2175 | + | } | |||
| 2176 | + | ||||
| 2177 | + | log.Info().Str("container_name", containerName).Msg("attachment env changed, recreating") | |||
| 2178 | + | if err := s.runtime.StopContainer(ctx, existing.ID); err != nil { | |||
| 2179 | + | return false, log.WrapErr(err, "failed to stop attachment for env update") | |||
| 2180 | + | } | |||
| 2181 | + | if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |||
| 2182 | + | return false, log.WrapErr(err, "failed to remove attachment for env update") | |||
| 2183 | + | } | |||
| 2184 | + | ||||
| 2185 | + | return false, nil | |||
| 2186 | + | } | |||
| 2187 | + | ||||
| 2188 | + | func (s *Service) removeStoppedAttachment(ctx context.Context, existing *domain.Container, containerName string) error { | |||
| 2189 | + | if existing == nil { | |||
| 2190 | + | return nil | |||
| 2191 | + | } | |||
| 2192 | + | ||||
| 2193 | + | log := zerowrap.FromCtx(ctx) | |||
| 2194 | + | log.Info().Str("container_name", containerName).Msg("removing stopped attachment container") | |||
| 2195 | + | if err := s.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |||
| 2196 | + | return log.WrapErr(err, "failed to remove existing attachment container") | |||
| 2197 | + | } | |||
| 2198 | + | ||||
| 2199 | + | return nil | |||
| 2200 | + | } | |||
| 2201 | + | ||||
| 2202 | + | func (s *Service) resolveExistingAttachment(ctx context.Context, ownerDomain, serviceName string) (*domain.Container, error) { | |||
| 2203 | + | log := zerowrap.FromCtx(ctx) | |||
| 2204 | + | containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName) | |||
| 2205 | + | existingContainer := s.findContainerByName(ctx, containerName) | |||
| 2206 | + | if existingContainer != nil { | |||
| 2207 | + | return existingContainer, nil | |||
| 2208 | + | } | |||
| 2209 | + | ||||
| 2210 | + | containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName) | |||
| 2211 | + | existingContainer = s.findContainerByName(ctx, containerNameLegacy) | |||
| 2212 | + | if existingContainer == nil { | |||
| 2213 | + | return nil, nil | |||
| 2214 | + | } | |||
| 2215 | + | ||||
| 2216 | + | log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced") | |||
| 2217 | + | if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil { | |||
| 2218 | + | return nil, log.WrapErr(err, "failed to stop legacy attachment container") | |||
| 2219 | + | } | |||
| 2220 | + | if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil { | |||
| 2221 | + | return nil, log.WrapErr(err, "failed to remove legacy attachment container") | |||
| 2222 | + | } | |||
| 2223 | + | ||||
| 2224 | + | return nil, nil | |||
| 2225 | + | } | |||
| 2226 | + | ||||
| 2135 | // Utility functions | 2227 | // Utility functions | ||
| 2136 | 2228 | ||||
| 2137 | func normalizeImageRef(image string) string { | 2229 | func normalizeImageRef(image string) string { | ||
| @@ -2192,9 +2284,15 @@ func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string { | |||||
| 2192 | } | 2284 | } | ||
| 2193 | } | 2285 | } | ||
| 2194 | 2286 | ||||
| 2287 | + | keys := make([]string, 0, len(envMap)) | |||
| 2288 | + | for k := range envMap { | |||
| 2289 | + | keys = append(keys, k) | |||
| 2290 | + | } | |||
| 2291 | + | slices.Sort(keys) | |||
| 2292 | + | ||||
| 2195 | result := make([]string, 0, len(envMap)) | 2293 | result := make([]string, 0, len(envMap)) | ||
| 2196 | - | for k, v := range envMap { | 2294 | + | for _, k := range keys { |
| 2197 | - | result = append(result, k+"="+v) | 2295 | + | result = append(result, k+"="+envMap[k]) |
| 2198 | } | 2296 | } | ||
| 2199 | 2297 | ||||
| 2200 | return result | 2298 | return result | ||
M internal/usecase/container/service_test.go +190 -0
| @@ -606,6 +606,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||
| 606 | 606 | config := testMinDelayConfig() | |
| 607 | 607 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 608 | 608 | ctx := testContext() | |
| 609 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 609 | 610 | ||
| 610 | 611 | // Existing container has ImageID, but GetImageID will fail | |
| 611 | 612 | existingContainer := &domain.Container{ | |
| @@ -614,6 +615,9 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||
| 614 | 615 | Image: "myapp:latest", | |
| 615 | 616 | ImageID: "sha256:abc123", | |
| 616 | 617 | Status: "running", | |
| 618 | + | Labels: map[string]string{ | |
| 619 | + | domain.LabelEnvHash: expectedEnvHash, | |
| 620 | + | }, | |
| 617 | 621 | } | |
| 618 | 622 | svc.containers["test.example.com"] = existingContainer | |
| 619 | 623 | ||
| @@ -2378,6 +2382,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||
| 2378 | 2382 | } | |
| 2379 | 2383 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2380 | 2384 | ctx := testContext() | |
| 2385 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 2381 | 2386 | ||
| 2382 | 2387 | // Pre-populate with existing container that has an ImageID (set by InspectContainer). | |
| 2383 | 2388 | // This simulates the first deploy (from image.pushed event) having already completed. | |
| @@ -2387,6 +2392,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||
| 2387 | 2392 | Image: "myapp:latest", | |
| 2388 | 2393 | ImageID: "sha256:abc123", | |
| 2389 | 2394 | Status: "running", | |
| 2395 | + | Labels: map[string]string{ | |
| 2396 | + | domain.LabelEnvHash: expectedEnvHash, | |
| 2397 | + | }, | |
| 2390 | 2398 | } | |
| 2391 | 2399 | svc.containers["test.example.com"] = existingContainer | |
| 2392 | 2400 | ||
| @@ -2432,6 +2440,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin | |||
| 2432 | 2440 | } | |
| 2433 | 2441 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2434 | 2442 | ctx := testContext() | |
| 2443 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 2435 | 2444 | ||
| 2436 | 2445 | // Pre-populate with existing container missing ImageID (common after stale in-memory state). | |
| 2437 | 2446 | existingContainer := &domain.Container{ | |
| @@ -2439,6 +2448,9 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin | |||
| 2439 | 2448 | Name: "gordon-test.example.com", | |
| 2440 | 2449 | Image: "myapp:latest", | |
| 2441 | 2450 | Status: "running", | |
| 2451 | + | Labels: map[string]string{ | |
| 2452 | + | domain.LabelEnvHash: expectedEnvHash, | |
| 2453 | + | }, | |
| 2442 | 2454 | } | |
| 2443 | 2455 | svc.containers["test.example.com"] = existingContainer | |
| 2444 | 2456 | ||
| @@ -2583,6 +2595,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 2583 | 2595 | config := testMinDelayConfig() | |
| 2584 | 2596 | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2585 | 2597 | ctx := testContext() | |
| 2598 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 2586 | 2599 | ||
| 2587 | 2600 | // Existing container has same image ID but is NOT running (crashed) | |
| 2588 | 2601 | existingContainer := &domain.Container{ | |
| @@ -2591,6 +2604,9 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 2591 | 2604 | Image: "myapp:latest", | |
| 2592 | 2605 | ImageID: "sha256:abc123", | |
| 2593 | 2606 | Status: "exited", | |
| 2607 | + | Labels: map[string]string{ | |
| 2608 | + | domain.LabelEnvHash: expectedEnvHash, | |
| 2609 | + | }, | |
| 2594 | 2610 | } | |
| 2595 | 2611 | svc.containers["test.example.com"] = existingContainer | |
| 2596 | 2612 | ||
| @@ -2632,6 +2648,180 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||
| 2632 | 2648 | assert.Equal(t, "new-container", result.ID) | |
| 2633 | 2649 | } | |
| 2634 | 2650 | ||
| 2651 | + | func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) { | |
| 2652 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2653 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2654 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2655 | + | ||
| 2656 | + | config := testMinDelayConfig() | |
| 2657 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2658 | + | ctx := testContext() | |
| 2659 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 2660 | + | ||
| 2661 | + | existingContainer := &domain.Container{ | |
| 2662 | + | ID: "existing-container", | |
| 2663 | + | Name: "gordon-test.example.com", | |
| 2664 | + | Image: "myapp:latest", | |
| 2665 | + | ImageID: "sha256:abc123", | |
| 2666 | + | Status: "running", | |
| 2667 | + | Labels: map[string]string{ | |
| 2668 | + | domain.LabelEnvHash: "different-hash", | |
| 2669 | + | }, | |
| 2670 | + | } | |
| 2671 | + | svc.containers["test.example.com"] = existingContainer | |
| 2672 | + | ||
| 2673 | + | route := domain.Route{ | |
| 2674 | + | Domain: "test.example.com", | |
| 2675 | + | Image: "myapp:latest", | |
| 2676 | + | } | |
| 2677 | + | ||
| 2678 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2679 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |
| 2680 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |
| 2681 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2682 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |
| 2683 | + | ||
| 2684 | + | runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil) | |
| 2685 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil) | |
| 2686 | + | ||
| 2687 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2688 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 2689 | + | return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash | |
| 2690 | + | })).Return(newContainer, nil) | |
| 2691 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2692 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2693 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2694 | + | ID: "new-container", Status: "running", | |
| 2695 | + | }, nil) | |
| 2696 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2697 | + | runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil) | |
| 2698 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil) | |
| 2699 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 2700 | + | ||
| 2701 | + | result, err := svc.Deploy(ctx, route) | |
| 2702 | + | ||
| 2703 | + | assert.NoError(t, err) | |
| 2704 | + | assert.Equal(t, "new-container", result.ID) | |
| 2705 | + | } | |
| 2706 | + | ||
| 2707 | + | func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) { | |
| 2708 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2709 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2710 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2711 | + | ||
| 2712 | + | config := testMinDelayConfig() | |
| 2713 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |
| 2714 | + | ctx := testContext() | |
| 2715 | + | expectedEnvHash := hashEnvironment([]string{}) | |
| 2716 | + | ||
| 2717 | + | existingContainer := &domain.Container{ | |
| 2718 | + | ID: "existing-container", | |
| 2719 | + | Name: "gordon-test.example.com", | |
| 2720 | + | Image: "myapp:latest", | |
| 2721 | + | ImageID: "sha256:abc123", | |
| 2722 | + | Status: "running", | |
| 2723 | + | Labels: map[string]string{}, | |
| 2724 | + | } | |
| 2725 | + | svc.containers["test.example.com"] = existingContainer | |
| 2726 | + | ||
| 2727 | + | route := domain.Route{ | |
| 2728 | + | Domain: "test.example.com", | |
| 2729 | + | Image: "myapp:latest", | |
| 2730 | + | } | |
| 2731 | + | ||
| 2732 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |
| 2733 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |
| 2734 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |
| 2735 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |
| 2736 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |
| 2737 | + | ||
| 2738 | + | runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil) | |
| 2739 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil) | |
| 2740 | + | ||
| 2741 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |
| 2742 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |
| 2743 | + | return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash | |
| 2744 | + | })).Return(newContainer, nil) | |
| 2745 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |
| 2746 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |
| 2747 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |
| 2748 | + | ID: "new-container", Status: "running", | |
| 2749 | + | }, nil) | |
| 2750 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |
| 2751 | + | runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil) | |
| 2752 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil) | |
| 2753 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |
| 2754 | + | ||
| 2755 | + | result, err := svc.Deploy(ctx, route) | |
| 2756 | + | ||
| 2757 | + | assert.NoError(t, err) | |
| 2758 | + | assert.Equal(t, "new-container", result.ID) | |
| 2759 | + | } | |
| 2760 | + | ||
| 2761 | + | func TestService_attachmentEnvDrifted_ReturnsFalseWhenHashMatches(t *testing.T) { | |
| 2762 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2763 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2764 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2765 | + | ||
| 2766 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |
| 2767 | + | ctx := testContext() | |
| 2768 | + | imageRef := svc.buildImageRef("postgres:16") | |
| 2769 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |
| 2770 | + | envHash := hashEnvironment(envVars) | |
| 2771 | + | ||
| 2772 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |
| 2773 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |
| 2774 | + | ||
| 2775 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |
| 2776 | + | Labels: map[string]string{domain.LabelEnvHash: envHash}, | |
| 2777 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |
| 2778 | + | ||
| 2779 | + | assert.NoError(t, err) | |
| 2780 | + | assert.False(t, drifted) | |
| 2781 | + | } | |
| 2782 | + | ||
| 2783 | + | func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashDiffers(t *testing.T) { | |
| 2784 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2785 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2786 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2787 | + | ||
| 2788 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |
| 2789 | + | ctx := testContext() | |
| 2790 | + | imageRef := svc.buildImageRef("postgres:16") | |
| 2791 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |
| 2792 | + | ||
| 2793 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |
| 2794 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |
| 2795 | + | ||
| 2796 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |
| 2797 | + | Labels: map[string]string{domain.LabelEnvHash: "stale-hash"}, | |
| 2798 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |
| 2799 | + | ||
| 2800 | + | assert.NoError(t, err) | |
| 2801 | + | assert.True(t, drifted) | |
| 2802 | + | } | |
| 2803 | + | ||
| 2804 | + | func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) { | |
| 2805 | + | runtime := mocks.NewMockContainerRuntime(t) | |
| 2806 | + | envLoader := mocks.NewMockEnvLoader(t) | |
| 2807 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 2808 | + | ||
| 2809 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |
| 2810 | + | ctx := testContext() | |
| 2811 | + | imageRef := svc.buildImageRef("postgres:16") | |
| 2812 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |
| 2813 | + | ||
| 2814 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |
| 2815 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |
| 2816 | + | ||
| 2817 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |
| 2818 | + | Labels: map[string]string{}, | |
| 2819 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |
| 2820 | + | ||
| 2821 | + | assert.NoError(t, err) | |
| 2822 | + | assert.True(t, drifted) | |
| 2823 | + | } | |
| 2824 | + | ||
| 2635 | 2825 | // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan | |
| 2636 | 2826 | // cleanup never stops a running container with the canonical name. Only stopped | |
| 2637 | 2827 | // canonical containers and temp containers (-new/-next) should be cleaned up. | |
| @@ -606,6 +606,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||||
| 606 | config := testMinDelayConfig() | 606 | config := testMinDelayConfig() | ||
| 607 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 607 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 608 | ctx := testContext() | 608 | ctx := testContext() | ||
| 609 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 609 | 610 | ||||
| 610 | // Existing container has ImageID, but GetImageID will fail | 611 | // Existing container has ImageID, but GetImageID will fail | ||
| 611 | existingContainer := &domain.Container{ | 612 | existingContainer := &domain.Container{ | ||
| @@ -614,6 +615,9 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||||
| 614 | Image: "myapp:latest", | 615 | Image: "myapp:latest", | ||
| 615 | ImageID: "sha256:abc123", | 616 | ImageID: "sha256:abc123", | ||
| 616 | Status: "running", | 617 | Status: "running", | ||
| 618 | + | Labels: map[string]string{ | |||
| 619 | + | domain.LabelEnvHash: expectedEnvHash, | |||
| 620 | + | }, | |||
| 617 | } | 621 | } | ||
| 618 | svc.containers["test.example.com"] = existingContainer | 622 | svc.containers["test.example.com"] = existingContainer | ||
| 619 | 623 | ||||
| @@ -2378,6 +2382,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||||
| 2378 | } | 2382 | } | ||
| 2379 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2383 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2380 | ctx := testContext() | 2384 | ctx := testContext() | ||
| 2385 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 2381 | 2386 | ||||
| 2382 | // Pre-populate with existing container that has an ImageID (set by InspectContainer). | 2387 | // Pre-populate with existing container that has an ImageID (set by InspectContainer). | ||
| 2383 | // This simulates the first deploy (from image.pushed event) having already completed. | 2388 | // This simulates the first deploy (from image.pushed event) having already completed. | ||
| @@ -2387,6 +2392,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) { | |||||
| 2387 | Image: "myapp:latest", | 2392 | Image: "myapp:latest", | ||
| 2388 | ImageID: "sha256:abc123", | 2393 | ImageID: "sha256:abc123", | ||
| 2389 | Status: "running", | 2394 | Status: "running", | ||
| 2395 | + | Labels: map[string]string{ | |||
| 2396 | + | domain.LabelEnvHash: expectedEnvHash, | |||
| 2397 | + | }, | |||
| 2390 | } | 2398 | } | ||
| 2391 | svc.containers["test.example.com"] = existingContainer | 2399 | svc.containers["test.example.com"] = existingContainer | ||
| 2392 | 2400 | ||||
| @@ -2432,6 +2440,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin | |||||
| 2432 | } | 2440 | } | ||
| 2433 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2441 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2434 | ctx := testContext() | 2442 | ctx := testContext() | ||
| 2443 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 2435 | 2444 | ||||
| 2436 | // Pre-populate with existing container missing ImageID (common after stale in-memory state). | 2445 | // Pre-populate with existing container missing ImageID (common after stale in-memory state). | ||
| 2437 | existingContainer := &domain.Container{ | 2446 | existingContainer := &domain.Container{ | ||
| @@ -2439,6 +2448,9 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin | |||||
| 2439 | Name: "gordon-test.example.com", | 2448 | Name: "gordon-test.example.com", | ||
| 2440 | Image: "myapp:latest", | 2449 | Image: "myapp:latest", | ||
| 2441 | Status: "running", | 2450 | Status: "running", | ||
| 2451 | + | Labels: map[string]string{ | |||
| 2452 | + | domain.LabelEnvHash: expectedEnvHash, | |||
| 2453 | + | }, | |||
| 2442 | } | 2454 | } | ||
| 2443 | svc.containers["test.example.com"] = existingContainer | 2455 | svc.containers["test.example.com"] = existingContainer | ||
| 2444 | 2456 | ||||
| @@ -2583,6 +2595,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||||
| 2583 | config := testMinDelayConfig() | 2595 | config := testMinDelayConfig() | ||
| 2584 | svc := NewService(runtime, envLoader, eventBus, nil, config) | 2596 | svc := NewService(runtime, envLoader, eventBus, nil, config) | ||
| 2585 | ctx := testContext() | 2597 | ctx := testContext() | ||
| 2598 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 2586 | 2599 | ||||
| 2587 | // Existing container has same image ID but is NOT running (crashed) | 2600 | // Existing container has same image ID but is NOT running (crashed) | ||
| 2588 | existingContainer := &domain.Container{ | 2601 | existingContainer := &domain.Container{ | ||
| @@ -2591,6 +2604,9 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||||
| 2591 | Image: "myapp:latest", | 2604 | Image: "myapp:latest", | ||
| 2592 | ImageID: "sha256:abc123", | 2605 | ImageID: "sha256:abc123", | ||
| 2593 | Status: "exited", | 2606 | Status: "exited", | ||
| 2607 | + | Labels: map[string]string{ | |||
| 2608 | + | domain.LabelEnvHash: expectedEnvHash, | |||
| 2609 | + | }, | |||
| 2594 | } | 2610 | } | ||
| 2595 | svc.containers["test.example.com"] = existingContainer | 2611 | svc.containers["test.example.com"] = existingContainer | ||
| 2596 | 2612 | ||||
| @@ -2632,6 +2648,180 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) { | |||||
| 2632 | assert.Equal(t, "new-container", result.ID) | 2648 | assert.Equal(t, "new-container", result.ID) | ||
| 2633 | } | 2649 | } | ||
| 2634 | 2650 | ||||
| 2651 | + | func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) { | |||
| 2652 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2653 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2654 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2655 | + | ||||
| 2656 | + | config := testMinDelayConfig() | |||
| 2657 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2658 | + | ctx := testContext() | |||
| 2659 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 2660 | + | ||||
| 2661 | + | existingContainer := &domain.Container{ | |||
| 2662 | + | ID: "existing-container", | |||
| 2663 | + | Name: "gordon-test.example.com", | |||
| 2664 | + | Image: "myapp:latest", | |||
| 2665 | + | ImageID: "sha256:abc123", | |||
| 2666 | + | Status: "running", | |||
| 2667 | + | Labels: map[string]string{ | |||
| 2668 | + | domain.LabelEnvHash: "different-hash", | |||
| 2669 | + | }, | |||
| 2670 | + | } | |||
| 2671 | + | svc.containers["test.example.com"] = existingContainer | |||
| 2672 | + | ||||
| 2673 | + | route := domain.Route{ | |||
| 2674 | + | Domain: "test.example.com", | |||
| 2675 | + | Image: "myapp:latest", | |||
| 2676 | + | } | |||
| 2677 | + | ||||
| 2678 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2679 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |||
| 2680 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |||
| 2681 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2682 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |||
| 2683 | + | ||||
| 2684 | + | runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil) | |||
| 2685 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil) | |||
| 2686 | + | ||||
| 2687 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 2688 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 2689 | + | return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash | |||
| 2690 | + | })).Return(newContainer, nil) | |||
| 2691 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2692 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2693 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 2694 | + | ID: "new-container", Status: "running", | |||
| 2695 | + | }, nil) | |||
| 2696 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2697 | + | runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil) | |||
| 2698 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil) | |||
| 2699 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 2700 | + | ||||
| 2701 | + | result, err := svc.Deploy(ctx, route) | |||
| 2702 | + | ||||
| 2703 | + | assert.NoError(t, err) | |||
| 2704 | + | assert.Equal(t, "new-container", result.ID) | |||
| 2705 | + | } | |||
| 2706 | + | ||||
| 2707 | + | func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) { | |||
| 2708 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2709 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2710 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2711 | + | ||||
| 2712 | + | config := testMinDelayConfig() | |||
| 2713 | + | svc := NewService(runtime, envLoader, eventBus, nil, config) | |||
| 2714 | + | ctx := testContext() | |||
| 2715 | + | expectedEnvHash := hashEnvironment([]string{}) | |||
| 2716 | + | ||||
| 2717 | + | existingContainer := &domain.Container{ | |||
| 2718 | + | ID: "existing-container", | |||
| 2719 | + | Name: "gordon-test.example.com", | |||
| 2720 | + | Image: "myapp:latest", | |||
| 2721 | + | ImageID: "sha256:abc123", | |||
| 2722 | + | Status: "running", | |||
| 2723 | + | Labels: map[string]string{}, | |||
| 2724 | + | } | |||
| 2725 | + | svc.containers["test.example.com"] = existingContainer | |||
| 2726 | + | ||||
| 2727 | + | route := domain.Route{ | |||
| 2728 | + | Domain: "test.example.com", | |||
| 2729 | + | Image: "myapp:latest", | |||
| 2730 | + | } | |||
| 2731 | + | ||||
| 2732 | + | runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{}, nil) | |||
| 2733 | + | runtime.EXPECT().ListImages(mock.Anything).Return([]string{"myapp:latest"}, nil) | |||
| 2734 | + | runtime.EXPECT().GetImageExposedPorts(mock.Anything, "myapp:latest").Return([]int{8080}, nil) | |||
| 2735 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "test.example.com").Return([]string{}, nil) | |||
| 2736 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, "myapp:latest").Return([]string{}, nil) | |||
| 2737 | + | ||||
| 2738 | + | runtime.EXPECT().GetImageID(mock.Anything, "myapp:latest").Return("sha256:abc123", nil) | |||
| 2739 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "existing-container").Return(true, nil) | |||
| 2740 | + | ||||
| 2741 | + | newContainer := &domain.Container{ID: "new-container", Name: "gordon-test.example.com-new", Status: "created"} | |||
| 2742 | + | runtime.EXPECT().CreateContainer(mock.Anything, mock.MatchedBy(func(cfg *domain.ContainerConfig) bool { | |||
| 2743 | + | return cfg.Labels[domain.LabelEnvHash] == expectedEnvHash | |||
| 2744 | + | })).Return(newContainer, nil) | |||
| 2745 | + | runtime.EXPECT().StartContainer(mock.Anything, "new-container").Return(nil) | |||
| 2746 | + | runtime.EXPECT().IsContainerRunning(mock.Anything, "new-container").Return(true, nil).Times(3) | |||
| 2747 | + | runtime.EXPECT().InspectContainer(mock.Anything, "new-container").Return(&domain.Container{ | |||
| 2748 | + | ID: "new-container", Status: "running", | |||
| 2749 | + | }, nil) | |||
| 2750 | + | eventBus.EXPECT().Publish(domain.EventContainerDeployed, mock.AnythingOfType("*domain.ContainerEventPayload")).Return(nil) | |||
| 2751 | + | runtime.EXPECT().StopContainer(mock.Anything, "existing-container").Return(nil) | |||
| 2752 | + | runtime.EXPECT().RemoveContainer(mock.Anything, "existing-container", true).Return(nil) | |||
| 2753 | + | runtime.EXPECT().RenameContainer(mock.Anything, "new-container", "gordon-test.example.com").Return(nil) | |||
| 2754 | + | ||||
| 2755 | + | result, err := svc.Deploy(ctx, route) | |||
| 2756 | + | ||||
| 2757 | + | assert.NoError(t, err) | |||
| 2758 | + | assert.Equal(t, "new-container", result.ID) | |||
| 2759 | + | } | |||
| 2760 | + | ||||
| 2761 | + | func TestService_attachmentEnvDrifted_ReturnsFalseWhenHashMatches(t *testing.T) { | |||
| 2762 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2763 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2764 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2765 | + | ||||
| 2766 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |||
| 2767 | + | ctx := testContext() | |||
| 2768 | + | imageRef := svc.buildImageRef("postgres:16") | |||
| 2769 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |||
| 2770 | + | envHash := hashEnvironment(envVars) | |||
| 2771 | + | ||||
| 2772 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |||
| 2773 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |||
| 2774 | + | ||||
| 2775 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |||
| 2776 | + | Labels: map[string]string{domain.LabelEnvHash: envHash}, | |||
| 2777 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |||
| 2778 | + | ||||
| 2779 | + | assert.NoError(t, err) | |||
| 2780 | + | assert.False(t, drifted) | |||
| 2781 | + | } | |||
| 2782 | + | ||||
| 2783 | + | func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashDiffers(t *testing.T) { | |||
| 2784 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2785 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2786 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2787 | + | ||||
| 2788 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |||
| 2789 | + | ctx := testContext() | |||
| 2790 | + | imageRef := svc.buildImageRef("postgres:16") | |||
| 2791 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |||
| 2792 | + | ||||
| 2793 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |||
| 2794 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |||
| 2795 | + | ||||
| 2796 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |||
| 2797 | + | Labels: map[string]string{domain.LabelEnvHash: "stale-hash"}, | |||
| 2798 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |||
| 2799 | + | ||||
| 2800 | + | assert.NoError(t, err) | |||
| 2801 | + | assert.True(t, drifted) | |||
| 2802 | + | } | |||
| 2803 | + | ||||
| 2804 | + | func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) { | |||
| 2805 | + | runtime := mocks.NewMockContainerRuntime(t) | |||
| 2806 | + | envLoader := mocks.NewMockEnvLoader(t) | |||
| 2807 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 2808 | + | ||||
| 2809 | + | svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig()) | |||
| 2810 | + | ctx := testContext() | |||
| 2811 | + | imageRef := svc.buildImageRef("postgres:16") | |||
| 2812 | + | envVars := []string{"POSTGRES_PASSWORD=secret"} | |||
| 2813 | + | ||||
| 2814 | + | envLoader.EXPECT().LoadEnv(mock.Anything, "gordon-app-example-com-postgres").Return(envVars, nil) | |||
| 2815 | + | runtime.EXPECT().InspectImageEnv(mock.Anything, imageRef).Return([]string{}, nil) | |||
| 2816 | + | ||||
| 2817 | + | drifted, err := svc.attachmentEnvDrifted(ctx, &domain.Container{ | |||
| 2818 | + | Labels: map[string]string{}, | |||
| 2819 | + | }, "gordon-app-example-com-postgres", "postgres:16") | |||
| 2820 | + | ||||
| 2821 | + | assert.NoError(t, err) | |||
| 2822 | + | assert.True(t, drifted) | |||
| 2823 | + | } | |||
| 2824 | + | ||||
| 2635 | // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan | 2825 | // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan | ||
| 2636 | // cleanup never stops a running container with the canonical name. Only stopped | 2826 | // cleanup never stops a running container with the canonical name. Only stopped | ||
| 2637 | // canonical containers and temp containers (-new/-next) should be cleaned up. | 2827 | // canonical containers and temp containers (-new/-next) should be cleaned up. | ||