detached 777a0a7

fix(container): extract deploy helpers to satisfy gocyclo

commit
777a0a7fce299a7845d63ff99947405d1767fa06
author
brice · · 2026-02-07 19:35 UTC
committer
brice · · 2026-02-07 19:59 UTC
parent
1838ab2
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +87 -54
M internal/usecase/container/service.go +87 -54
@@ -217,132 +217,165 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
217217 })
218218 log := zerowrap.FromCtx(ctx)
219219
220- // Get existing container (if any) without holding lock
220+ existing, hasExisting := s.getTrackedContainer(route.Domain)
221+
222+ resources, err := s.prepareDeployResources(ctx, route, existing)
223+ if err != nil {
224+ return nil, err
225+ }
226+
227+ newContainer, err := s.createStartedContainer(ctx, route, existing, resources)
228+ if err != nil {
229+ return nil, err
230+ }
231+
232+ s.activateDeployedContainer(ctx, route.Domain, newContainer)
233+ s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, newContainer.ID)
234+
235+ // Start container log collection (non-blocking, errors don't fail deployment)
236+ s.startLogCollection(ctx, newContainer.ID, route.Domain)
237+
238+ log.Info().
239+ Str("image", route.Image).
240+ Str(zerowrap.FieldEntityID, newContainer.ID).
241+ Ints("ports", newContainer.Ports).
242+ Str("network", resources.networkName).
243+ Bool("zero_downtime", hasExisting).
244+ Msg("container deployed successfully")
245+
246+ return newContainer, nil
247+}
248+
249+type deployResources struct {
250+ networkName string
251+ actualImageRef string
252+ exposedPorts []int
253+ envVars []string
254+ volumes map[string]string
255+}
256+
257+func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) {
221258 s.mu.RLock()
222- existing, hasExisting := s.containers[route.Domain]
259+ container, ok := s.containers[domainName]
223260 s.mu.RUnlock()
261+ return container, ok
262+}
263+
264+func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) {
265+ log := zerowrap.FromCtx(ctx)
224266
225- // Clean up orphaned containers (containers with same name but not tracked)
226- // Skip the currently tracked container to preserve zero-downtime deployment
227267 existingID := ""
228- if hasExisting {
268+ if existing != nil {
229269 existingID = existing.ID
230270 }
231271 if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {
232272 log.WrapErr(err, "failed to cleanup orphaned containers")
233273 }
234274
235- // Build image reference and ensure it's available.
236- // ensureImage returns the canonical reference; internal pulls may use the local registry.
237275 imageRef := s.buildImageRef(route.Image)
238276 actualImageRef, err := s.ensureImage(ctx, imageRef)
239277 if err != nil {
240278 return nil, err
241279 }
242280
243- // Setup network FIRST (attachments need it)
244281 networkName := s.getNetworkForApp(route.Domain)
245282 if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
246283 return nil, log.WrapErr(err, "failed to create network")
247284 }
248-
249- // Deploy attachments BEFORE main container (dependencies first)
250285 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
251286 return nil, log.WrapErr(err, "failed to deploy attachments")
252287 }
253288
254- // Get exposed ports (use actual image ref from pull)
255289 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
256290 if err != nil {
257291 log.WrapErr(err, "failed to get exposed ports, using defaults")
258292 exposedPorts = []int{80, 8080, 3000}
259293 }
260294
261- // Load environment
262295 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
263296 if err != nil {
264297 return nil, err
265298 }
266299
267- // Setup volumes (use actual image ref from pull)
268300 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
269301 if err != nil {
270302 return nil, err
271303 }
272304
273- // Build container configuration
274- containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, existing)
305+ return &deployResources{
306+ networkName: networkName,
307+ actualImageRef: actualImageRef,
308+ exposedPorts: exposedPorts,
309+ envVars: envVars,
310+ volumes: volumes,
311+ }, nil
312+}
313+
314+func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) {
315+ log := zerowrap.FromCtx(ctx)
316+
317+ containerConfig := s.buildContainerConfig(
318+ route.Domain,
319+ route.Image,
320+ resources.actualImageRef,
321+ resources.exposedPorts,
322+ resources.envVars,
323+ resources.volumes,
324+ resources.networkName,
325+ existing,
326+ )
275327
276328 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
277329 if err != nil {
278330 return nil, log.WrapErr(err, "failed to create container")
279331 }
280-
281- // Start new container
282332 if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
283333 s.runtime.RemoveContainer(ctx, newContainer.ID, true)
284334 return nil, log.WrapErr(err, "failed to start container")
285335 }
286-
287- // Wait for new container to be ready
288336 if err := s.waitForReady(ctx, newContainer.ID); err != nil {
289337 s.cleanupFailedContainer(ctx, newContainer.ID)
290338 return nil, log.WrapErr(err, "container failed readiness check")
291339 }
292340
293- // Re-inspect for updated info (ports, etc.)
294- newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID)
341+ inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
295342 if err != nil {
296343 return nil, log.WrapErr(err, "failed to inspect started container")
297344 }
298345
299- // ATOMIC SWITCH: Update tracking first (proxy will now route to new container)
346+ return inspected, nil
347+}
348+
349+func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) {
300350 s.mu.Lock()
301- s.containers[route.Domain] = newContainer
351+ s.containers[domainName] = container
302352 s.mu.Unlock()
303353
304- // Publish container deployed event (other subscribers)
305- s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
354+ s.publishContainerDeployed(ctx, domainName, container.ID)
306355
307- // Synchronous cache invalidation: ensure the proxy stops routing to the
308- // old container immediately, before we stop it. This fixes the race where
309- // the async event bus invalidation arrives after the old container is killed.
310356 s.mu.RLock()
311357 inv := s.cacheInvalidator
312358 s.mu.RUnlock()
313359 if inv != nil {
314- inv.InvalidateTarget(ctx, route.Domain)
360+ inv.InvalidateTarget(ctx, domainName)
315361 }
362+}
316363
317- // Grace period: let in-flight requests to the old container finish draining.
318- if hasExisting {
319- drainDelay := s.config.DrainDelay
320- if drainDelay == 0 {
321- drainDelay = 2 * time.Second
322- }
323- select {
324- case <-time.After(drainDelay):
325- case <-ctx.Done():
326- }
364+func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting bool, newContainerID string) {
365+ if !hasExisting {
366+ return
327367 }
328368
329- // NOW safe to stop and remove old container
330- if hasExisting {
331- s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)
369+ drainDelay := s.config.DrainDelay
370+ if drainDelay == 0 {
371+ drainDelay = 2 * time.Second
372+ }
373+ select {
374+ case <-time.After(drainDelay):
375+ case <-ctx.Done():
332376 }
333377
334- // Start container log collection (non-blocking, errors don't fail deployment)
335- s.startLogCollection(ctx, newContainer.ID, route.Domain)
336-
337- log.Info().
338- Str("image", route.Image).
339- Str(zerowrap.FieldEntityID, newContainer.ID).
340- Ints("ports", newContainer.Ports).
341- Str("network", networkName).
342- Bool("zero_downtime", hasExisting).
343- Msg("container deployed successfully")
344-
345- return newContainer, nil
378+ s.cleanupOldContainer(ctx, existing, newContainerID, domainName)
346379 }
347380
348381 // Restart restarts a running container for the given domain.
@@ -217,132 +217,165 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
217 })217 })
218 log := zerowrap.FromCtx(ctx)218 log := zerowrap.FromCtx(ctx)
219 219
220- // Get existing container (if any) without holding lock220+ existing, hasExisting := s.getTrackedContainer(route.Domain)
221+
222+ resources, err := s.prepareDeployResources(ctx, route, existing)
223+ if err != nil {
224+ return nil, err
225+ }
226+
227+ newContainer, err := s.createStartedContainer(ctx, route, existing, resources)
228+ if err != nil {
229+ return nil, err
230+ }
231+
232+ s.activateDeployedContainer(ctx, route.Domain, newContainer)
233+ s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, newContainer.ID)
234+
235+ // Start container log collection (non-blocking, errors don't fail deployment)
236+ s.startLogCollection(ctx, newContainer.ID, route.Domain)
237+
238+ log.Info().
239+ Str("image", route.Image).
240+ Str(zerowrap.FieldEntityID, newContainer.ID).
241+ Ints("ports", newContainer.Ports).
242+ Str("network", resources.networkName).
243+ Bool("zero_downtime", hasExisting).
244+ Msg("container deployed successfully")
245+
246+ return newContainer, nil
247+}
248+
249+type deployResources struct {
250+ networkName string
251+ actualImageRef string
252+ exposedPorts []int
253+ envVars []string
254+ volumes map[string]string
255+}
256+
257+func (s *Service) getTrackedContainer(domainName string) (*domain.Container, bool) {
221 s.mu.RLock()258 s.mu.RLock()
222- existing, hasExisting := s.containers[route.Domain]259+ container, ok := s.containers[domainName]
223 s.mu.RUnlock()260 s.mu.RUnlock()
261+ return container, ok
262+}
263+
264+func (s *Service) prepareDeployResources(ctx context.Context, route domain.Route, existing *domain.Container) (*deployResources, error) {
265+ log := zerowrap.FromCtx(ctx)
224 266
225- // Clean up orphaned containers (containers with same name but not tracked)
226- // Skip the currently tracked container to preserve zero-downtime deployment
227 existingID := ""267 existingID := ""
228- if hasExisting {268+ if existing != nil {
229 existingID = existing.ID269 existingID = existing.ID
230 }270 }
231 if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {271 if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {
232 log.WrapErr(err, "failed to cleanup orphaned containers")272 log.WrapErr(err, "failed to cleanup orphaned containers")
233 }273 }
234 274
235- // Build image reference and ensure it's available.
236- // ensureImage returns the canonical reference; internal pulls may use the local registry.
237 imageRef := s.buildImageRef(route.Image)275 imageRef := s.buildImageRef(route.Image)
238 actualImageRef, err := s.ensureImage(ctx, imageRef)276 actualImageRef, err := s.ensureImage(ctx, imageRef)
239 if err != nil {277 if err != nil {
240 return nil, err278 return nil, err
241 }279 }
242 280
243- // Setup network FIRST (attachments need it)
244 networkName := s.getNetworkForApp(route.Domain)281 networkName := s.getNetworkForApp(route.Domain)
245 if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {282 if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
246 return nil, log.WrapErr(err, "failed to create network")283 return nil, log.WrapErr(err, "failed to create network")
247 }284 }
248-
249- // Deploy attachments BEFORE main container (dependencies first)
250 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {285 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
251 return nil, log.WrapErr(err, "failed to deploy attachments")286 return nil, log.WrapErr(err, "failed to deploy attachments")
252 }287 }
253 288
254- // Get exposed ports (use actual image ref from pull)
255 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)289 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
256 if err != nil {290 if err != nil {
257 log.WrapErr(err, "failed to get exposed ports, using defaults")291 log.WrapErr(err, "failed to get exposed ports, using defaults")
258 exposedPorts = []int{80, 8080, 3000}292 exposedPorts = []int{80, 8080, 3000}
259 }293 }
260 294
261- // Load environment
262 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)295 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
263 if err != nil {296 if err != nil {
264 return nil, err297 return nil, err
265 }298 }
266 299
267- // Setup volumes (use actual image ref from pull)
268 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)300 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
269 if err != nil {301 if err != nil {
270 return nil, err302 return nil, err
271 }303 }
272 304
273- // Build container configuration305+ return &deployResources{
274- containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, existing)306+ networkName: networkName,
307+ actualImageRef: actualImageRef,
308+ exposedPorts: exposedPorts,
309+ envVars: envVars,
310+ volumes: volumes,
311+ }, nil
312+}
313+
314+func (s *Service) createStartedContainer(ctx context.Context, route domain.Route, existing *domain.Container, resources *deployResources) (*domain.Container, error) {
315+ log := zerowrap.FromCtx(ctx)
316+
317+ containerConfig := s.buildContainerConfig(
318+ route.Domain,
319+ route.Image,
320+ resources.actualImageRef,
321+ resources.exposedPorts,
322+ resources.envVars,
323+ resources.volumes,
324+ resources.networkName,
325+ existing,
326+ )
275 327
276 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)328 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
277 if err != nil {329 if err != nil {
278 return nil, log.WrapErr(err, "failed to create container")330 return nil, log.WrapErr(err, "failed to create container")
279 }331 }
280-
281- // Start new container
282 if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {332 if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
283 s.runtime.RemoveContainer(ctx, newContainer.ID, true)333 s.runtime.RemoveContainer(ctx, newContainer.ID, true)
284 return nil, log.WrapErr(err, "failed to start container")334 return nil, log.WrapErr(err, "failed to start container")
285 }335 }
286-
287- // Wait for new container to be ready
288 if err := s.waitForReady(ctx, newContainer.ID); err != nil {336 if err := s.waitForReady(ctx, newContainer.ID); err != nil {
289 s.cleanupFailedContainer(ctx, newContainer.ID)337 s.cleanupFailedContainer(ctx, newContainer.ID)
290 return nil, log.WrapErr(err, "container failed readiness check")338 return nil, log.WrapErr(err, "container failed readiness check")
291 }339 }
292 340
293- // Re-inspect for updated info (ports, etc.)341+ inspected, err := s.runtime.InspectContainer(ctx, newContainer.ID)
294- newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID)
295 if err != nil {342 if err != nil {
296 return nil, log.WrapErr(err, "failed to inspect started container")343 return nil, log.WrapErr(err, "failed to inspect started container")
297 }344 }
298 345
299- // ATOMIC SWITCH: Update tracking first (proxy will now route to new container)346+ return inspected, nil
347+}
348+
349+func (s *Service) activateDeployedContainer(ctx context.Context, domainName string, container *domain.Container) {
300 s.mu.Lock()350 s.mu.Lock()
301- s.containers[route.Domain] = newContainer351+ s.containers[domainName] = container
302 s.mu.Unlock()352 s.mu.Unlock()
303 353
304- // Publish container deployed event (other subscribers)354+ s.publishContainerDeployed(ctx, domainName, container.ID)
305- s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
306 355
307- // Synchronous cache invalidation: ensure the proxy stops routing to the
308- // old container immediately, before we stop it. This fixes the race where
309- // the async event bus invalidation arrives after the old container is killed.
310 s.mu.RLock()356 s.mu.RLock()
311 inv := s.cacheInvalidator357 inv := s.cacheInvalidator
312 s.mu.RUnlock()358 s.mu.RUnlock()
313 if inv != nil {359 if inv != nil {
314- inv.InvalidateTarget(ctx, route.Domain)360+ inv.InvalidateTarget(ctx, domainName)
315 }361 }
362+}
316 363
317- // Grace period: let in-flight requests to the old container finish draining.364+func (s *Service) finalizePreviousContainer(ctx context.Context, domainName string, existing *domain.Container, hasExisting bool, newContainerID string) {
318- if hasExisting {365+ if !hasExisting {
319- drainDelay := s.config.DrainDelay366+ return
320- if drainDelay == 0 {
321- drainDelay = 2 * time.Second
322- }
323- select {
324- case <-time.After(drainDelay):
325- case <-ctx.Done():
326- }
327 }367 }
328 368
329- // NOW safe to stop and remove old container369+ drainDelay := s.config.DrainDelay
330- if hasExisting {370+ if drainDelay == 0 {
331- s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)371+ drainDelay = 2 * time.Second
372+ }
373+ select {
374+ case <-time.After(drainDelay):
375+ case <-ctx.Done():
332 }376 }
333 377
334- // Start container log collection (non-blocking, errors don't fail deployment)378+ s.cleanupOldContainer(ctx, existing, newContainerID, domainName)
335- s.startLogCollection(ctx, newContainer.ID, route.Domain)
336-
337- log.Info().
338- Str("image", route.Image).
339- Str(zerowrap.FieldEntityID, newContainer.ID).
340- Ints("ports", newContainer.Ports).
341- Str("network", networkName).
342- Bool("zero_downtime", hasExisting).
343- Msg("container deployed successfully")
344-
345- return newContainer, nil
346 }379 }
347 380
348 // Restart restarts a running container for the given domain.381 // Restart restarts a running container for the given domain.

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