detached 1b6cd1f

refactor(container): address PR review feedback

- Extract buildContainerConfig helper to reduce cyclomatic complexity (16→15)
- Add acquireDomainDeployLock with context cancellation support
- Add deployMu cleanup in Remove to prevent unbounded growth
- Add type assertion safety comment for domainDeployMu
- Fix test assertion: use assert.Equal for exact match
- Fix test mocks: use .Once() instead of .Maybe() for cleanup ops
- Fix InspectContainer mock to return correct container names

All tests pass and lint is clean (0 issues).
commit
1b6cd1f7fc1a9f1416bc45e69909b722c841701a
author
brice · · 2026-02-05 17:56 UTC
parent
38e7e8c
signature
unsigned
2 files · +73 -31
M internal/usecase/container/service.go +61 -26
@@ -78,17 +78,68 @@ func NewService(
7878 // domainDeployMu returns a per-domain mutex for serializing deploy operations.
7979 func (s *Service) domainDeployMu(domain string) *sync.Mutex {
8080 v, _ := s.deployMu.LoadOrStore(domain, &sync.Mutex{})
81+ // Type assertion is safe: LoadOrStore always stores *sync.Mutex values,
82+ // and any concurrent LoadOrStore calls will also store *sync.Mutex.
8183 return v.(*sync.Mutex)
8284 }
8385
86+// acquireDomainDeployLock acquires the per-domain deploy lock with context cancellation support.
87+// Returns an unlock function that must be called to release the lock.
88+func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (func(), error) {
89+ mu := s.domainDeployMu(domain)
90+
91+ // Check if context is already cancelled before acquiring lock
92+ if err := ctx.Err(); err != nil {
93+ return nil, err
94+ }
95+
96+ mu.Lock()
97+
98+ // Check if context was cancelled while waiting for lock
99+ if err := ctx.Err(); err != nil {
100+ mu.Unlock()
101+ return nil, err
102+ }
103+
104+ return func() { mu.Unlock() }, nil
105+}
106+
107+// buildContainerConfig constructs the container configuration for deployment.
108+func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, hasExisting bool) *domain.ContainerConfig {
109+ // Determine container name (use temp suffix for zero-downtime if existing)
110+ containerName := fmt.Sprintf("gordon-%s", containerDomain)
111+ if hasExisting {
112+ containerName = fmt.Sprintf("gordon-%s-new", containerDomain)
113+ }
114+
115+ return &domain.ContainerConfig{
116+ Image: actualImageRef,
117+ Name: containerName,
118+ Ports: exposedPorts,
119+ Env: envVars,
120+ Volumes: volumes,
121+ NetworkMode: networkName,
122+ Hostname: containerDomain,
123+ Labels: map[string]string{
124+ "gordon.domain": containerDomain,
125+ "gordon.image": image,
126+ "gordon.managed": "true",
127+ "gordon.route": containerDomain,
128+ },
129+ AutoRemove: false,
130+ }
131+}
132+
84133 // Deploy creates and starts a container for the given route.
85134 // Implements zero-downtime deployment: new container starts before old one stops.
86135 func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
87136 // Serialize deploys for the same domain to prevent race conditions
88137 // (e.g. multiple image.pushed events + explicit deploy call from CLI).
89- mu := s.domainDeployMu(route.Domain)
90- mu.Lock()
91- defer mu.Unlock()
138+ unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)
139+ if err != nil {
140+ return nil, err
141+ }
142+ defer unlock()
92143
93144 // Enrich context with use case fields for all downstream logs
94145 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -151,29 +202,8 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
151202 return nil, err
152203 }
153204
154- // Determine container name (use temp suffix for zero-downtime if existing)
155- containerName := fmt.Sprintf("gordon-%s", route.Domain)
156- if hasExisting {
157- containerName = fmt.Sprintf("gordon-%s-new", route.Domain)
158- }
159-
160- // Create container (use actual image ref from pull, but track original in labels)
161- containerConfig := &domain.ContainerConfig{
162- Image: actualImageRef,
163- Name: containerName,
164- Ports: exposedPorts,
165- Env: envVars,
166- Volumes: volumes,
167- NetworkMode: networkName,
168- Hostname: route.Domain,
169- Labels: map[string]string{
170- "gordon.domain": route.Domain,
171- "gordon.image": route.Image,
172- "gordon.managed": "true",
173- "gordon.route": route.Domain,
174- },
175- AutoRemove: false,
176- }
205+ // Build container configuration
206+ containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, hasExisting)
177207
178208 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
179209 if err != nil {
@@ -343,6 +373,11 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
343373 }
344374 s.mu.Unlock()
345375
376+ // Cleanup per-domain deploy mutex to prevent unbounded growth
377+ if removedDomain != "" {
378+ s.deployMu.Delete(removedDomain)
379+ }
380+
346381 // Cleanup network
347382 if removedDomain != "" && s.config.NetworkIsolation {
348383 networkName := s.getNetworkForApp(removedDomain)
@@ -78,17 +78,68 @@ func NewService(
78 // domainDeployMu returns a per-domain mutex for serializing deploy operations.78 // domainDeployMu returns a per-domain mutex for serializing deploy operations.
79 func (s *Service) domainDeployMu(domain string) *sync.Mutex {79 func (s *Service) domainDeployMu(domain string) *sync.Mutex {
80 v, _ := s.deployMu.LoadOrStore(domain, &sync.Mutex{})80 v, _ := s.deployMu.LoadOrStore(domain, &sync.Mutex{})
81+ // Type assertion is safe: LoadOrStore always stores *sync.Mutex values,
82+ // and any concurrent LoadOrStore calls will also store *sync.Mutex.
81 return v.(*sync.Mutex)83 return v.(*sync.Mutex)
82 }84 }
83 85
86+// acquireDomainDeployLock acquires the per-domain deploy lock with context cancellation support.
87+// Returns an unlock function that must be called to release the lock.
88+func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (func(), error) {
89+ mu := s.domainDeployMu(domain)
90+
91+ // Check if context is already cancelled before acquiring lock
92+ if err := ctx.Err(); err != nil {
93+ return nil, err
94+ }
95+
96+ mu.Lock()
97+
98+ // Check if context was cancelled while waiting for lock
99+ if err := ctx.Err(); err != nil {
100+ mu.Unlock()
101+ return nil, err
102+ }
103+
104+ return func() { mu.Unlock() }, nil
105+}
106+
107+// buildContainerConfig constructs the container configuration for deployment.
108+func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, hasExisting bool) *domain.ContainerConfig {
109+ // Determine container name (use temp suffix for zero-downtime if existing)
110+ containerName := fmt.Sprintf("gordon-%s", containerDomain)
111+ if hasExisting {
112+ containerName = fmt.Sprintf("gordon-%s-new", containerDomain)
113+ }
114+
115+ return &domain.ContainerConfig{
116+ Image: actualImageRef,
117+ Name: containerName,
118+ Ports: exposedPorts,
119+ Env: envVars,
120+ Volumes: volumes,
121+ NetworkMode: networkName,
122+ Hostname: containerDomain,
123+ Labels: map[string]string{
124+ "gordon.domain": containerDomain,
125+ "gordon.image": image,
126+ "gordon.managed": "true",
127+ "gordon.route": containerDomain,
128+ },
129+ AutoRemove: false,
130+ }
131+}
132+
84 // Deploy creates and starts a container for the given route.133 // Deploy creates and starts a container for the given route.
85 // Implements zero-downtime deployment: new container starts before old one stops.134 // Implements zero-downtime deployment: new container starts before old one stops.
86 func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {135 func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
87 // Serialize deploys for the same domain to prevent race conditions136 // Serialize deploys for the same domain to prevent race conditions
88 // (e.g. multiple image.pushed events + explicit deploy call from CLI).137 // (e.g. multiple image.pushed events + explicit deploy call from CLI).
89- mu := s.domainDeployMu(route.Domain)138+ unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)
90- mu.Lock()139+ if err != nil {
91- defer mu.Unlock()140+ return nil, err
141+ }
142+ defer unlock()
92 143
93 // Enrich context with use case fields for all downstream logs144 // Enrich context with use case fields for all downstream logs
94 ctx = zerowrap.CtxWithFields(ctx, map[string]any{145 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -151,29 +202,8 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
151 return nil, err202 return nil, err
152 }203 }
153 204
154- // Determine container name (use temp suffix for zero-downtime if existing)205+ // Build container configuration
155- containerName := fmt.Sprintf("gordon-%s", route.Domain)206+ containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, hasExisting)
156- if hasExisting {
157- containerName = fmt.Sprintf("gordon-%s-new", route.Domain)
158- }
159-
160- // Create container (use actual image ref from pull, but track original in labels)
161- containerConfig := &domain.ContainerConfig{
162- Image: actualImageRef,
163- Name: containerName,
164- Ports: exposedPorts,
165- Env: envVars,
166- Volumes: volumes,
167- NetworkMode: networkName,
168- Hostname: route.Domain,
169- Labels: map[string]string{
170- "gordon.domain": route.Domain,
171- "gordon.image": route.Image,
172- "gordon.managed": "true",
173- "gordon.route": route.Domain,
174- },
175- AutoRemove: false,
176- }
177 207
178 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)208 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
179 if err != nil {209 if err != nil {
@@ -343,6 +373,11 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
343 }373 }
344 s.mu.Unlock()374 s.mu.Unlock()
345 375
376+ // Cleanup per-domain deploy mutex to prevent unbounded growth
377+ if removedDomain != "" {
378+ s.deployMu.Delete(removedDomain)
379+ }
380+
346 // Cleanup network381 // Cleanup network
347 if removedDomain != "" && s.config.NetworkIsolation {382 if removedDomain != "" && s.config.NetworkIsolation {
348 networkName := s.getNetworkForApp(removedDomain)383 networkName := s.getNetworkForApp(removedDomain)
M internal/usecase/container/service_test.go +12 -5
@@ -1473,9 +1473,16 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
14731473
14741474 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
14751475 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
1476+ // Return correct container name based on ID
1477+ // container-1 is the first deploy (canonical name)
1478+ // container-2 is the second deploy (with -new suffix)
1479+ name := "gordon-test.example.com"
1480+ if id == "container-2" {
1481+ name = "gordon-test.example.com-new"
1482+ }
14761483 return &domain.Container{
14771484 ID: id,
1478- Name: "gordon-test.example.com",
1485+ Name: name,
14791486 Image: "myapp:latest",
14801487 Status: "running",
14811488 Ports: []int{8080},
@@ -1486,9 +1493,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
14861493
14871494 // The second deploy will see the first container and do zero-downtime swap,
14881495 // which means it will also stop+remove+rename the old container.
1489- runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Maybe()
1490- runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Maybe()
1491- runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Maybe()
1496+ runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
1497+ runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
1498+ runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
14921499
14931500 // Launch two concurrent deploys
14941501 var wg sync.WaitGroup
@@ -1511,7 +1518,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
15111518 callMu.Lock()
15121519 assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
15131520 // First deploy uses canonical name, second uses -new suffix (zero-downtime)
1514- assert.Contains(t, callOrder[0], "gordon-test.example.com", "first create should use canonical name")
1521+ assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
15151522 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
15161523 callMu.Unlock()
15171524 }
@@ -1473,9 +1473,16 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1473 1473
1474 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).1474 runtime.EXPECT().InspectContainer(mock.Anything, mock.AnythingOfType("string")).
1475 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {1475 RunAndReturn(func(_ context.Context, id string) (*domain.Container, error) {
1476+ // Return correct container name based on ID
1477+ // container-1 is the first deploy (canonical name)
1478+ // container-2 is the second deploy (with -new suffix)
1479+ name := "gordon-test.example.com"
1480+ if id == "container-2" {
1481+ name = "gordon-test.example.com-new"
1482+ }
1476 return &domain.Container{1483 return &domain.Container{
1477 ID: id,1484 ID: id,
1478- Name: "gordon-test.example.com",1485+ Name: name,
1479 Image: "myapp:latest",1486 Image: "myapp:latest",
1480 Status: "running",1487 Status: "running",
1481 Ports: []int{8080},1488 Ports: []int{8080},
@@ -1486,9 +1493,9 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1486 1493
1487 // The second deploy will see the first container and do zero-downtime swap,1494 // The second deploy will see the first container and do zero-downtime swap,
1488 // which means it will also stop+remove+rename the old container.1495 // which means it will also stop+remove+rename the old container.
1489- runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Maybe()1496+ runtime.EXPECT().StopContainer(mock.Anything, mock.AnythingOfType("string")).Return(nil).Once()
1490- runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Maybe()1497+ runtime.EXPECT().RemoveContainer(mock.Anything, mock.AnythingOfType("string"), true).Return(nil).Once()
1491- runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Maybe()1498+ runtime.EXPECT().RenameContainer(mock.Anything, mock.AnythingOfType("string"), mock.AnythingOfType("string")).Return(nil).Once()
1492 1499
1493 // Launch two concurrent deploys1500 // Launch two concurrent deploys
1494 var wg sync.WaitGroup1501 var wg sync.WaitGroup
@@ -1511,7 +1518,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
1511 callMu.Lock()1518 callMu.Lock()
1512 assert.Len(t, callOrder, 2, "should have exactly 2 create calls")1519 assert.Len(t, callOrder, 2, "should have exactly 2 create calls")
1513 // First deploy uses canonical name, second uses -new suffix (zero-downtime)1520 // First deploy uses canonical name, second uses -new suffix (zero-downtime)
1514- assert.Contains(t, callOrder[0], "gordon-test.example.com", "first create should use canonical name")1521+ assert.Equal(t, "create-1:gordon-test.example.com", callOrder[0], "first create should use canonical name")
1515 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")1522 assert.Contains(t, callOrder[1], "gordon-test.example.com-new", "second create should use -new suffix")
1516 callMu.Unlock()1523 callMu.Unlock()
1517 }1524 }

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 8

Tags 106

↑↓ moveenter openesc close