detached e7424b0

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 (
1818 EventContainerStart EventType = "container.start"
1919 EventContainerHealthCheck EventType = "container.health_check"
2020 EventContainerDeployed EventType = "container.deployed"
21+ EventSecretsChanged EventType = "secrets.changed"
2122 )
2223
2324 // Event represents a domain event that occurred in the system.
@@ -61,6 +62,13 @@ type ManualDeployPayload struct {
6162 Domain string `json:"domain"`
6263 }
6364
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+
6472 // Context keys for domain-level concerns.
6573 type contextKey string
6674
@@ -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 string73 type contextKey string
66 74
M internal/domain/labels.go +4 -0
@@ -10,6 +10,10 @@ const (
1010 LabelAttachment = "gordon.attachment"
1111 LabelAttachedTo = "gordon.attached-to"
1212 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"
1317
1418 // Image labels (set in Dockerfile)
1519 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
210210 }
211211 }
212212
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 {
214214 containerName := s.deploymentContainerName(containerDomain, existing)
215215
216216 return &domain.ContainerConfig{
@@ -223,6 +223,7 @@ func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef st
223223 Hostname: containerDomain,
224224 Labels: map[string]string{
225225 domain.LabelDomain: containerDomain,
226+ domain.LabelEnvHash: envHash,
226227 domain.LabelImage: image,
227228 domain.LabelManaged: "true",
228229 domain.LabelRoute: containerDomain,
@@ -279,7 +280,7 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
279280 existingForSkip = s.containerForRedundantCheck(ctx, existingForSkip)
280281 }
281282 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 {
283284 return container, nil
284285 }
285286 }
@@ -351,7 +352,7 @@ func (s *Service) recordDeployMetrics(ctx context.Context, span trace.Span, rout
351352 // When a push triggers both an event-based deploy and an explicit CLI deploy,
352353 // the second one arrives after the first has already completed; this avoids
353354 // 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) {
355356 log := zerowrap.FromCtx(ctx)
356357
357358 newImageID, err := s.runtime.GetImageID(ctx, actualImageRef)
@@ -370,6 +371,19 @@ func (s *Service) skipRedundantDeploy(ctx context.Context, existing *domain.Cont
370371 return false, nil
371372 }
372373
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+
373387 log.Info().
374388 Str("container_id", existing.ID).
375389 Str("image_id", newImageID).
@@ -403,6 +417,7 @@ type deployResources struct {
403417 actualImageRef string
404418 exposedPorts []int
405419 envVars []string
420+ envHash string
406421 volumes map[string]string
407422 }
408423
@@ -505,6 +520,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
505520 if err != nil {
506521 return nil, err
507522 }
523+ envHash := hashEnvironment(envVars)
508524
509525 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
510526 if err != nil {
@@ -516,6 +532,7 @@ func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route
516532 actualImageRef: actualImageRef,
517533 exposedPorts: exposedPorts,
518534 envVars: envVars,
535+ envHash: envHash,
519536 volumes: volumes,
520537 }, nil
521538 }
@@ -532,6 +549,7 @@ func (s *Service) createStartedContainer(ctx context.Context, route domain.Route
532549 resources.actualImageRef,
533550 resources.exposedPorts,
534551 resources.envVars,
552+ resources.envHash,
535553 resources.volumes,
536554 resources.networkName,
537555 existing,
@@ -2031,37 +2049,28 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
20312049
20322050 // Check if already running (idempotent)
20332051 // 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
20552055 }
20562056
2057- // Remove existing stopped container if present
20582057 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
20622067 }
20632068 }
20642069
2070+ if err := s.removeStoppedAttachment(ctx, existingContainer, containerName); err != nil {
2071+ return err
2072+ }
2073+
20652074 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
20662075
20672076 // 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
20912100 log.WrapErr(err, "failed to load environment for attachment")
20922101 envVars = []string{}
20932102 }
2103+ envHash := hashEnvironment(envVars)
20942104
20952105 // Create container with attachment labels (use actual ref, track original in labels)
20962106 config := &domain.ContainerConfig{
@@ -2105,6 +2115,7 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
21052115 domain.LabelManaged: "true",
21062116 domain.LabelAttachment: "true",
21072117 domain.LabelAttachedTo: ownerDomain,
2118+ domain.LabelEnvHash: envHash,
21082119 domain.LabelImage: serviceImage,
21092120 },
21102121 }
@@ -2132,6 +2143,87 @@ func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, servic
21322143 return nil
21332144 }
21342145
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+
21352227 // Utility functions
21362228
21372229 func normalizeImageRef(image string) string {
@@ -2192,9 +2284,15 @@ func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
21922284 }
21932285 }
21942286
2287+ keys := make([]string, 0, len(envMap))
2288+ for k := range envMap {
2289+ keys = append(keys, k)
2290+ }
2291+ slices.Sort(keys)
2292+
21952293 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])
21982296 }
21992297
22002298 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, nil284 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 avoids353 // 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, nil371 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 string417 actualImageRef string
404 exposedPorts []int418 exposedPorts []int
405 envVars []string419 envVars []string
420+ envHash string
406 volumes map[string]string421 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, err521 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 }, nil537 }, 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 compatibility2051 // 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 upgrades2054+ 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 nil2143 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 functions2227 // 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 result2298 return result
M internal/usecase/container/service_test.go +190 -0
@@ -606,6 +606,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
606606 config := testMinDelayConfig()
607607 svc := NewService(runtime, envLoader, eventBus, nil, config)
608608 ctx := testContext()
609+ expectedEnvHash := hashEnvironment([]string{})
609610
610611 // Existing container has ImageID, but GetImageID will fail
611612 existingContainer := &domain.Container{
@@ -614,6 +615,9 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
614615 Image: "myapp:latest",
615616 ImageID: "sha256:abc123",
616617 Status: "running",
618+ Labels: map[string]string{
619+ domain.LabelEnvHash: expectedEnvHash,
620+ },
617621 }
618622 svc.containers["test.example.com"] = existingContainer
619623
@@ -2378,6 +2382,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
23782382 }
23792383 svc := NewService(runtime, envLoader, eventBus, nil, config)
23802384 ctx := testContext()
2385+ expectedEnvHash := hashEnvironment([]string{})
23812386
23822387 // Pre-populate with existing container that has an ImageID (set by InspectContainer).
23832388 // This simulates the first deploy (from image.pushed event) having already completed.
@@ -2387,6 +2392,9 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
23872392 Image: "myapp:latest",
23882393 ImageID: "sha256:abc123",
23892394 Status: "running",
2395+ Labels: map[string]string{
2396+ domain.LabelEnvHash: expectedEnvHash,
2397+ },
23902398 }
23912399 svc.containers["test.example.com"] = existingContainer
23922400
@@ -2432,6 +2440,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
24322440 }
24332441 svc := NewService(runtime, envLoader, eventBus, nil, config)
24342442 ctx := testContext()
2443+ expectedEnvHash := hashEnvironment([]string{})
24352444
24362445 // Pre-populate with existing container missing ImageID (common after stale in-memory state).
24372446 existingContainer := &domain.Container{
@@ -2439,6 +2448,9 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
24392448 Name: "gordon-test.example.com",
24402449 Image: "myapp:latest",
24412450 Status: "running",
2451+ Labels: map[string]string{
2452+ domain.LabelEnvHash: expectedEnvHash,
2453+ },
24422454 }
24432455 svc.containers["test.example.com"] = existingContainer
24442456
@@ -2583,6 +2595,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
25832595 config := testMinDelayConfig()
25842596 svc := NewService(runtime, envLoader, eventBus, nil, config)
25852597 ctx := testContext()
2598+ expectedEnvHash := hashEnvironment([]string{})
25862599
25872600 // Existing container has same image ID but is NOT running (crashed)
25882601 existingContainer := &domain.Container{
@@ -2591,6 +2604,9 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
25912604 Image: "myapp:latest",
25922605 ImageID: "sha256:abc123",
25932606 Status: "exited",
2607+ Labels: map[string]string{
2608+ domain.LabelEnvHash: expectedEnvHash,
2609+ },
25942610 }
25952611 svc.containers["test.example.com"] = existingContainer
25962612
@@ -2632,6 +2648,180 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
26322648 assert.Equal(t, "new-container", result.ID)
26332649 }
26342650
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+
26352825 // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan
26362826 // cleanup never stops a running container with the canonical name. Only stopped
26372827 // 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 fail611 // 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"] = existingContainer622 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"] = existingContainer2399 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"] = existingContainer2455 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"] = existingContainer2611 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 orphan2825 // TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical verifies that orphan
2636 // cleanup never stops a running container with the canonical name. Only stopped2826 // 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close