1// Package container implements the container management use case.
2package container
3
4import (
5 "context"
6 "fmt"
7 "maps"
8 "slices"
9 "strings"
10 "sync"
11 "time"
12
13 "github.com/bnema/zerowrap"
14
15 "github.com/bnema/gordon/internal/boundaries/out"
16 "github.com/bnema/gordon/internal/domain"
17)
18
19// Config holds configuration needed by the container service.
20type Config struct {
21 RegistryAuthEnabled bool
22 RegistryDomain string
23 RegistryPort int
24 ServiceTokenUsername string
25 ServiceToken string
26 InternalRegistryUsername string
27 InternalRegistryPassword string
28 PullPolicy string
29 VolumeAutoCreate bool
30 VolumePrefix string
31 VolumePreserve bool
32 NetworkIsolation bool
33 NetworkPrefix string
34 DNSSuffix string
35 NetworkGroups map[string][]string
36 Attachments map[string][]string
37 ReadinessDelay time.Duration // Delay after container starts before considering it ready
38}
39
40const (
41 PullPolicyAlways = "always"
42 PullPolicyIfNotPresent = "if-not-present"
43 PullPolicyIfTagChanged = "if-tag-changed"
44)
45
46// Service implements the ContainerService interface.
47type Service struct {
48 runtime out.ContainerRuntime
49 envLoader out.EnvLoader
50 eventBus out.EventPublisher
51 logWriter out.ContainerLogWriter
52 config Config
53 containers map[string]*domain.Container
54 attachments map[string][]string // ownerDomain → []containerIDs
55 mu sync.RWMutex
56 deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
57}
58
59// domainDeployLock is a context-aware mutex using a buffered channel.
60// It allows Lock() to be interrupted by context cancellation.
61type domainDeployLock struct {
62 ch chan struct{}
63}
64
65// newDomainDeployLock creates a new lock that is initially unlocked.
66func newDomainDeployLock() *domainDeployLock {
67 l := &domainDeployLock{ch: make(chan struct{}, 1)}
68 l.ch <- struct{}{}
69 return l
70}
71
72// Lock acquires the lock or returns an error if the context is cancelled.
73func (l *domainDeployLock) Lock(ctx context.Context) error {
74 // Fast path: honor already-canceled contexts before blocking.
75 if err := ctx.Err(); err != nil {
76 return err
77 }
78 select {
79 case <-ctx.Done():
80 return ctx.Err()
81 case <-l.ch:
82 // Re-check context after acquisition: if canceled while we were
83 // in the select, release the token so the lock isn't consumed.
84 if err := ctx.Err(); err != nil {
85 l.ch <- struct{}{}
86 return err
87 }
88 return nil
89 }
90}
91
92// Unlock releases the lock.
93func (l *domainDeployLock) Unlock() {
94 select {
95 case l.ch <- struct{}{}:
96 default:
97 // This should never happen if Lock/Unlock are used correctly.
98 // Panic to detect double-unlocks or other misuse early.
99 panic("domainDeployLock: double unlock detected")
100 }
101}
102
103// NewService creates a new container service.
104func NewService(
105 runtime out.ContainerRuntime,
106 envLoader out.EnvLoader,
107 eventBus out.EventPublisher,
108 logWriter out.ContainerLogWriter,
109 config Config,
110) *Service {
111 return &Service{
112 runtime: runtime,
113 envLoader: envLoader,
114 eventBus: eventBus,
115 logWriter: logWriter,
116 config: config,
117 containers: make(map[string]*domain.Container),
118 attachments: make(map[string][]string),
119 }
120}
121
122// domainDeployMu returns a per-domain lock for serializing deploy operations.
123func (s *Service) domainDeployMu(domain string) *domainDeployLock {
124 v, _ := s.deployMu.LoadOrStore(domain, newDomainDeployLock())
125 // Type assertion is safe: LoadOrStore always stores *domainDeployLock values,
126 // and any concurrent LoadOrStore calls will also store *domainDeployLock.
127 return v.(*domainDeployLock)
128}
129
130// acquireDomainDeployLock acquires the per-domain deploy lock with context cancellation support.
131// Returns an unlock function that must be called to release the lock.
132func (s *Service) acquireDomainDeployLock(ctx context.Context, domain string) (func(), error) {
133 lock := s.domainDeployMu(domain)
134 if err := lock.Lock(ctx); err != nil {
135 return nil, err
136 }
137 return func() { lock.Unlock() }, nil
138}
139
140// buildContainerConfig constructs the container configuration for deployment.
141func (s *Service) buildContainerConfig(containerDomain, image, actualImageRef string, exposedPorts []int, envVars []string, volumes map[string]string, networkName string, hasExisting bool) *domain.ContainerConfig {
142 // Determine container name (use temp suffix for zero-downtime if existing)
143 containerName := fmt.Sprintf("gordon-%s", containerDomain)
144 if hasExisting {
145 containerName = fmt.Sprintf("gordon-%s-new", containerDomain)
146 }
147
148 return &domain.ContainerConfig{
149 Image: actualImageRef,
150 Name: containerName,
151 Ports: exposedPorts,
152 Env: envVars,
153 Volumes: volumes,
154 NetworkMode: networkName,
155 Hostname: containerDomain,
156 Labels: map[string]string{
157 "gordon.domain": containerDomain,
158 "gordon.image": image,
159 "gordon.managed": "true",
160 "gordon.route": containerDomain,
161 },
162 AutoRemove: false,
163 }
164}
165
166// Deploy creates and starts a container for the given route.
167// Implements zero-downtime deployment: new container starts before old one stops.
168func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Container, error) {
169 // Serialize deploys for the same domain to prevent race conditions
170 // (e.g. multiple image.pushed events + explicit deploy call from CLI).
171 unlock, err := s.acquireDomainDeployLock(ctx, route.Domain)
172 if err != nil {
173 return nil, err
174 }
175 defer unlock()
176
177 // Enrich context with use case fields for all downstream logs
178 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
179 zerowrap.FieldLayer: "usecase",
180 zerowrap.FieldUseCase: "Deploy",
181 "domain": route.Domain,
182 })
183 log := zerowrap.FromCtx(ctx)
184
185 // Get existing container (if any) without holding lock
186 s.mu.RLock()
187 existing, hasExisting := s.containers[route.Domain]
188 s.mu.RUnlock()
189
190 // Clean up orphaned containers (containers with same name but not tracked)
191 // Skip the currently tracked container to preserve zero-downtime deployment
192 existingID := ""
193 if hasExisting {
194 existingID = existing.ID
195 }
196 if err := s.cleanupOrphanedContainers(ctx, route.Domain, existingID); err != nil {
197 log.WrapErr(err, "failed to cleanup orphaned containers")
198 }
199
200 // Build image reference and ensure it's available.
201 // ensureImage returns the canonical reference; internal pulls may use the local registry.
202 imageRef := s.buildImageRef(route.Image)
203 actualImageRef, err := s.ensureImage(ctx, imageRef)
204 if err != nil {
205 return nil, err
206 }
207
208 // Setup network FIRST (attachments need it)
209 networkName := s.getNetworkForApp(route.Domain)
210 if err := s.createNetworkIfNeeded(ctx, networkName); err != nil {
211 return nil, log.WrapErr(err, "failed to create network")
212 }
213
214 // Deploy attachments BEFORE main container (dependencies first)
215 if err := s.deployAttachments(ctx, route.Domain, networkName); err != nil {
216 return nil, log.WrapErr(err, "failed to deploy attachments")
217 }
218
219 // Get exposed ports (use actual image ref from pull)
220 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
221 if err != nil {
222 log.WrapErr(err, "failed to get exposed ports, using defaults")
223 exposedPorts = []int{80, 8080, 3000}
224 }
225
226 // Load environment
227 envVars, err := s.loadEnvironment(ctx, route.Domain, actualImageRef)
228 if err != nil {
229 return nil, err
230 }
231
232 // Setup volumes (use actual image ref from pull)
233 volumes, err := s.setupVolumes(ctx, route.Domain, actualImageRef)
234 if err != nil {
235 return nil, err
236 }
237
238 // Build container configuration
239 containerConfig := s.buildContainerConfig(route.Domain, route.Image, actualImageRef, exposedPorts, envVars, volumes, networkName, hasExisting)
240
241 newContainer, err := s.runtime.CreateContainer(ctx, containerConfig)
242 if err != nil {
243 return nil, log.WrapErr(err, "failed to create container")
244 }
245
246 // Start new container
247 if err := s.runtime.StartContainer(ctx, newContainer.ID); err != nil {
248 s.runtime.RemoveContainer(ctx, newContainer.ID, true)
249 return nil, log.WrapErr(err, "failed to start container")
250 }
251
252 // Wait for new container to be ready
253 if err := s.waitForReady(ctx, newContainer.ID); err != nil {
254 s.cleanupFailedContainer(ctx, newContainer.ID)
255 return nil, log.WrapErr(err, "container failed readiness check")
256 }
257
258 // Re-inspect for updated info (ports, etc.)
259 newContainer, err = s.runtime.InspectContainer(ctx, newContainer.ID)
260 if err != nil {
261 return nil, log.WrapErr(err, "failed to inspect started container")
262 }
263
264 // ATOMIC SWITCH: Update tracking first (proxy will now route to new container)
265 s.mu.Lock()
266 s.containers[route.Domain] = newContainer
267 s.mu.Unlock()
268
269 // Publish container deployed event (proxy will invalidate cache)
270 s.publishContainerDeployed(ctx, route.Domain, newContainer.ID)
271
272 // NOW stop and remove old container (traffic already going to new one)
273 if hasExisting {
274 s.cleanupOldContainer(ctx, existing, newContainer.ID, route.Domain)
275 }
276
277 // Start container log collection (non-blocking, errors don't fail deployment)
278 s.startLogCollection(ctx, newContainer.ID, route.Domain)
279
280 log.Info().
281 Str("image", route.Image).
282 Str(zerowrap.FieldEntityID, newContainer.ID).
283 Ints("ports", newContainer.Ports).
284 Str("network", networkName).
285 Bool("zero_downtime", hasExisting).
286 Msg("container deployed successfully")
287
288 return newContainer, nil
289}
290
291// Restart restarts a running container for the given domain.
292func (s *Service) Restart(ctx context.Context, domainName string, withAttachments bool) error {
293 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
294 zerowrap.FieldLayer: "usecase",
295 zerowrap.FieldUseCase: "Restart",
296 "domain": domainName,
297 })
298 log := zerowrap.FromCtx(ctx)
299
300 s.mu.RLock()
301 container, exists := s.containers[domainName]
302 attachments := s.attachments[domainName]
303 var attachmentIDs []string
304 if len(attachments) > 0 {
305 attachmentIDs = make([]string, len(attachments))
306 copy(attachmentIDs, attachments)
307 }
308 s.mu.RUnlock()
309
310 if !exists || container == nil {
311 return domain.ErrContainerNotFound
312 }
313
314 // Restart the main container
315 if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
316 return log.WrapErr(err, "failed to restart container")
317 }
318 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
319
320 // Restart attachments if requested
321 if withAttachments && len(attachmentIDs) > 0 {
322 for _, attachID := range attachmentIDs {
323 if err := s.runtime.RestartContainer(ctx, attachID); err != nil {
324 log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to restart attachment")
325 continue // Don't fail the whole operation for one attachment
326 }
327 log.Info().Str("attachment_id", attachID).Msg("attachment restarted")
328 }
329 }
330
331 return nil
332}
333
334// Stop stops a running container.
335func (s *Service) Stop(ctx context.Context, containerID string) error {
336 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
337 zerowrap.FieldLayer: "usecase",
338 zerowrap.FieldUseCase: "Stop",
339 zerowrap.FieldEntityID: containerID,
340 })
341 log := zerowrap.FromCtx(ctx)
342
343 // Stop log collection before stopping container
344 if s.logWriter != nil {
345 if err := s.logWriter.StopLogging(containerID); err != nil {
346 log.Warn().Err(err).Msg("failed to stop container log collection")
347 }
348 }
349
350 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
351 return log.WrapErr(err, "failed to stop container")
352 }
353
354 log.Info().Msg("container stopped")
355 return nil
356}
357
358// Remove removes a container.
359func (s *Service) Remove(ctx context.Context, containerID string, force bool) error {
360 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
361 zerowrap.FieldLayer: "usecase",
362 zerowrap.FieldUseCase: "Remove",
363 zerowrap.FieldEntityID: containerID,
364 })
365 log := zerowrap.FromCtx(ctx)
366
367 // Stop log collection before removing container
368 if s.logWriter != nil {
369 if err := s.logWriter.StopLogging(containerID); err != nil {
370 log.Warn().Err(err).Msg("failed to stop container log collection")
371 }
372 }
373
374 // Find domain and attachment IDs for cleanup
375 containerDomain, attachmentIDs, cfg := s.findContainerContext(containerID)
376
377 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
378 return log.WrapErr(err, "failed to remove container")
379 }
380
381 // Cleanup volumes
382 if containerDomain != "" && !cfg.VolumePreserve {
383 if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
384 log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
385 }
386 }
387
388 // Clean up attachment containers
389 s.removeAttachments(ctx, attachmentIDs)
390
391 // Remove from tracking
392 s.mu.Lock()
393 var removedDomain string
394 for d, c := range s.containers {
395 if c.ID == containerID {
396 delete(s.containers, d)
397 removedDomain = d
398 log.Info().Str("domain", d).Msg("container removed")
399 break
400 }
401 }
402 if removedDomain != "" {
403 delete(s.attachments, removedDomain)
404 }
405 s.mu.Unlock()
406
407 // Note: We intentionally do NOT clean up deployMu entries to avoid a race condition:
408 // If Remove deletes the mutex entry while a concurrent Deploy is acquiring the lock,
409 // the Deploy would create a fresh mutex, breaking serialization and allowing concurrent
410 // deploys to the same domain. The memory footprint is acceptable (one small struct per
411 // domain ever deployed), and this is the safer choice for correctness.
412
413 // Cleanup network
414 if removedDomain != "" && cfg.NetworkIsolation {
415 networkName := s.getNetworkForApp(removedDomain)
416 if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
417 log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
418 }
419 }
420
421 return nil
422}
423
424// findContainerContext looks up the domain, attachment IDs, and config snapshot for a container.
425func (s *Service) findContainerContext(containerID string) (string, []string, Config) {
426 s.mu.RLock()
427 defer s.mu.RUnlock()
428 cfg := s.config
429 var containerDomain string
430 for d, c := range s.containers {
431 if c.ID == containerID {
432 containerDomain = d
433 break
434 }
435 }
436 var attachmentIDs []string
437 if containerDomain != "" {
438 attachmentIDs = append(attachmentIDs, s.attachments[containerDomain]...)
439 }
440 return containerDomain, attachmentIDs, cfg
441}
442
443// removeAttachments stops and removes attachment containers best-effort.
444func (s *Service) removeAttachments(ctx context.Context, attachmentIDs []string) {
445 log := zerowrap.FromCtx(ctx)
446 for _, attachID := range attachmentIDs {
447 if s.logWriter != nil {
448 if err := s.logWriter.StopLogging(attachID); err != nil {
449 log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment log collection")
450 }
451 }
452 if err := s.runtime.StopContainer(ctx, attachID); err != nil {
453 log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment container")
454 }
455 if err := s.runtime.RemoveContainer(ctx, attachID, true); err != nil {
456 log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to remove attachment container")
457 }
458 }
459}
460
461// Get retrieves a container by domain.
462func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
463 s.mu.RLock()
464 defer s.mu.RUnlock()
465 container, exists := s.containers[domainName]
466 return container, exists
467}
468
469// List returns all managed containers.
470func (s *Service) List(_ context.Context) map[string]*domain.Container {
471 s.mu.RLock()
472 defer s.mu.RUnlock()
473
474 result := make(map[string]*domain.Container, len(s.containers))
475 maps.Copy(result, s.containers)
476 return result
477}
478
479// ListRoutesWithDetails returns routes with network and attachment info.
480// Note: Container map is copied under lock, then external calls are made without lock.
481// If containers are removed between copy and runtime calls, errors are handled gracefully
482// (network becomes empty string). This trade-off avoids holding locks during I/O.
483func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo {
484 s.mu.RLock()
485 containers := make(map[string]*domain.Container, len(s.containers))
486 maps.Copy(containers, s.containers)
487 s.mu.RUnlock()
488
489 // Fetch all attachments once to avoid N+1 queries
490 attachmentsByDomain := s.getAllAttachments(ctx)
491
492 results := make([]domain.RouteInfo, 0, len(containers))
493 for domainName, container := range containers {
494 network := ""
495 image := ""
496 containerID := ""
497 status := ""
498 if container != nil {
499 containerID = container.ID
500 status = container.Status
501 image = container.Image
502 if container.Labels != nil {
503 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
504 image = labelImage
505 }
506 }
507 // Strip registry domain prefix from image for cleaner display
508 image = s.stripRegistryPrefix(image)
509 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
510 network = networkName
511 }
512 }
513
514 results = append(results, domain.RouteInfo{
515 Domain: domainName,
516 Image: image,
517 ContainerID: containerID,
518 ContainerStatus: status,
519 Network: network,
520 Attachments: attachmentsByDomain[domainName],
521 })
522 }
523
524 return results
525}
526
527// stripRegistryPrefix removes the configured registry domain prefix from an image reference.
528// For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
529func (s *Service) stripRegistryPrefix(image string) string {
530 s.mu.RLock()
531 cfg := s.config
532 s.mu.RUnlock()
533
534 if cfg.RegistryDomain == "" {
535 return image
536 }
537 prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/"
538 if strings.HasPrefix(image, prefix) {
539 return strings.TrimPrefix(image, prefix)
540 }
541 return image
542}
543
544// ListAttachments returns attachments for a domain.
545func (s *Service) ListAttachments(ctx context.Context, domainName string) []domain.Attachment {
546 return s.getAttachmentsForDomain(ctx, domainName)
547}
548
549// ListNetworks returns Gordon-managed networks.
550func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
551 s.mu.RLock()
552 cfg := s.config
553 s.mu.RUnlock()
554
555 networks, err := s.runtime.ListNetworks(ctx)
556 if err != nil {
557 return nil, err
558 }
559
560 var filtered []*domain.NetworkInfo
561 for _, network := range networks {
562 if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") {
563 filtered = append(filtered, network)
564 }
565 }
566
567 return filtered, nil
568}
569
570// HealthCheck performs health checks on all containers.
571func (s *Service) HealthCheck(ctx context.Context) map[string]bool {
572 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
573 zerowrap.FieldLayer: "usecase",
574 zerowrap.FieldUseCase: "HealthCheck",
575 })
576 log := zerowrap.FromCtx(ctx)
577
578 // Copy container IDs under lock, then release before making Docker API calls
579 s.mu.RLock()
580 snapshot := make(map[string]string, len(s.containers))
581 for d, c := range s.containers {
582 snapshot[d] = c.ID
583 }
584 s.mu.RUnlock()
585
586 health := make(map[string]bool, len(snapshot))
587 for d, id := range snapshot {
588 running, err := s.runtime.IsContainerRunning(ctx, id)
589 if err != nil {
590 log.WrapErrWithFields(err, "health check failed", map[string]any{"domain": d, zerowrap.FieldEntityID: id})
591 health[d] = false
592 } else {
593 health[d] = running
594 }
595 }
596 return health
597}
598
599// SyncContainers synchronizes containers with runtime state.
600func (s *Service) SyncContainers(ctx context.Context) error {
601 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
602 zerowrap.FieldLayer: "usecase",
603 zerowrap.FieldUseCase: "SyncContainers",
604 })
605 log := zerowrap.FromCtx(ctx)
606
607 // List containers without holding lock to avoid blocking during Docker API call
608 allContainers, err := s.runtime.ListContainers(ctx, false)
609 if err != nil {
610 return log.WrapErr(err, "failed to list containers")
611 }
612
613 managed := make(map[string]*domain.Container)
614 for _, c := range allContainers {
615 if c.Labels != nil {
616 if d, ok := c.Labels["gordon.domain"]; ok && c.Labels["gordon.managed"] == "true" {
617 managed[d] = c
618 }
619 }
620 }
621
622 s.mu.Lock()
623 s.containers = managed
624 s.mu.Unlock()
625
626 log.Info().Int(zerowrap.FieldCount, len(managed)).Msg("container state synchronized")
627 return nil
628}
629
630// AutoStart starts containers for the provided routes that aren't running.
631func (s *Service) AutoStart(ctx context.Context, routes []domain.Route) error {
632 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
633 zerowrap.FieldLayer: "usecase",
634 zerowrap.FieldUseCase: "AutoStart",
635 })
636 log := zerowrap.FromCtx(ctx)
637
638 log.Info().Int("route_count", len(routes)).Msg("auto-starting containers for configured routes")
639
640 var started, skipped, errors int
641 for _, route := range routes {
642 // Check if container already exists and is running
643 if _, exists := s.Get(ctx, route.Domain); exists {
644 log.Debug().Str("domain", route.Domain).Msg("container already running, skipping")
645 skipped++
646 continue
647 }
648
649 log.Info().
650 Str("domain", route.Domain).
651 Str("image", route.Image).
652 Msg("auto-starting container for route")
653
654 if _, err := s.Deploy(ctx, route); err != nil {
655 log.Warn().Err(err).Str("domain", route.Domain).Msg("failed to auto-start container")
656 errors++
657 continue
658 }
659
660 started++
661 }
662
663 log.Info().
664 Int("started", started).
665 Int("skipped", skipped).
666 Int("errors", errors).
667 Msg("auto-start completed")
668
669 if errors > 0 {
670 return fmt.Errorf("auto-start completed with %d errors", errors)
671 }
672 return nil
673}
674
675// Shutdown gracefully shuts down all managed containers.
676func (s *Service) Shutdown(ctx context.Context) error {
677 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
678 zerowrap.FieldLayer: "usecase",
679 zerowrap.FieldUseCase: "Shutdown",
680 })
681 log := zerowrap.FromCtx(ctx)
682 log.Info().Msg("shutting down container manager...")
683
684 if err := s.stopAllManagedContainers(ctx); err != nil {
685 log.WrapErr(err, "failed to stop all containers during shutdown")
686 }
687
688 // Close log writer to stop all log collection
689 if s.logWriter != nil {
690 if err := s.logWriter.Close(); err != nil {
691 log.Warn().Err(err).Msg("failed to close container log writer")
692 }
693 }
694
695 log.Info().Msg("container manager shutdown complete")
696 return nil
697}
698
699// UpdateConfig updates the service configuration.
700func (s *Service) UpdateConfig(config Config) {
701 s.mu.Lock()
702 s.config = config
703 s.mu.Unlock()
704}
705
706// Helper methods
707
708// cleanupFailedContainer stops and removes a container that failed to start properly.
709func (s *Service) cleanupFailedContainer(ctx context.Context, containerID string) {
710 log := zerowrap.FromCtx(ctx)
711 if err := s.runtime.StopContainer(ctx, containerID); err != nil {
712 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to stop container after failure")
713 }
714 if err := s.runtime.RemoveContainer(ctx, containerID, true); err != nil {
715 log.Warn().Err(err).Str(zerowrap.FieldEntityID, containerID).Msg("failed to remove container after failure")
716 }
717}
718
719// cleanupOldContainer stops and removes an old container after zero-downtime switch.
720// It also renames the new container to the canonical name.
721func (s *Service) cleanupOldContainer(ctx context.Context, old *domain.Container, newContainerID, domainName string) {
722 log := zerowrap.FromCtx(ctx)
723 log.Info().Str(zerowrap.FieldEntityID, old.ID).Msg("stopping old container after zero-downtime switch")
724
725 if s.logWriter != nil {
726 if err := s.logWriter.StopLogging(old.ID); err != nil {
727 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop logging for old container")
728 }
729 }
730
731 if err := s.runtime.StopContainer(ctx, old.ID); err != nil {
732 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to stop old container")
733 }
734
735 if err := s.runtime.RemoveContainer(ctx, old.ID, true); err != nil {
736 log.Warn().Err(err).Str(zerowrap.FieldEntityID, old.ID).Msg("failed to remove old container")
737 }
738
739 // Rename new container to canonical name
740 canonicalName := fmt.Sprintf("gordon-%s", domainName)
741 if err := s.runtime.RenameContainer(ctx, newContainerID, canonicalName); err != nil {
742 log.Warn().Err(err).Str("canonical_name", canonicalName).Msg("failed to rename container to canonical name")
743 }
744}
745
746// startLogCollection starts log collection for a container in the background.
747// Errors are logged but don't fail the calling operation.
748func (s *Service) startLogCollection(ctx context.Context, containerID, domainName string) {
749 if s.logWriter == nil {
750 return
751 }
752
753 log := zerowrap.FromCtx(ctx)
754
755 logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
756 if err != nil {
757 log.Warn().Err(err).Msg("failed to get container logs for collection")
758 return
759 }
760
761 if err := s.logWriter.StartLogging(ctx, containerID, domainName, logStream); err != nil {
762 log.Warn().Err(err).Msg("failed to start container log collection")
763 logStream.Close()
764 }
765}
766
767func (s *Service) buildImageRef(image string) string {
768 s.mu.RLock()
769 cfg := s.config
770 s.mu.RUnlock()
771
772 if !cfg.RegistryAuthEnabled || cfg.RegistryDomain == "" {
773 return image
774 }
775
776 // Normalize registry domain by trimming any trailing slash
777 reg := strings.TrimSuffix(cfg.RegistryDomain, "/")
778
779 // Check if image already has the registry domain prefix
780 prefix := reg + "/"
781 if strings.HasPrefix(image, prefix) {
782 return image
783 }
784
785 // Check if image already has an explicit registry (host:port/ or host.domain/).
786 // We need to detect patterns like:
787 // - "docker.io/library/nginx:latest" (has dot and slash)
788 // - "localhost:5001/myapp:latest" (has host:port/ pattern)
789 // - "myregistry:5000/app:v1" (has host:port/ pattern)
790 // - "[fd00::1]:5000/app:v1" (IPv6 with port)
791 if hasExplicitRegistry(image) {
792 return image
793 }
794
795 return fmt.Sprintf("%s/%s", reg, image)
796}
797
798// hasExplicitRegistry checks if an image reference already includes an explicit registry.
799// Returns true for patterns like "docker.io/image", "localhost:5000/image", "localhost/image",
800// "registry:8080/image", "[fd00::1]/image", "[fd00::1]:5000/image".
801func hasExplicitRegistry(image string) bool {
802 // Find the first slash which separates registry from image name
803 slashIdx := strings.Index(image, "/")
804 if slashIdx == -1 {
805 return false // No slash means no registry prefix (e.g., "myapp:latest")
806 }
807
808 // Extract the part before the first slash (potential registry)
809 registryPart := image[:slashIdx]
810
811 // Check for bracketed IPv6 address (e.g., "[fd00::1]" or "[fd00::1]:5000")
812 if strings.HasPrefix(registryPart, "[") {
813 // Look for closing bracket
814 if closeBracket := strings.Index(registryPart, "]"); closeBracket != -1 {
815 // Valid bracketed IPv6: either ends at ] or has ]:port
816 return true
817 }
818 }
819
820 // Check for localhost (with or without port)
821 if registryPart == "localhost" {
822 return true
823 }
824
825 // Check for host:port pattern (e.g., "localhost:5000", "registry:8080")
826 if colonIdx := strings.LastIndex(registryPart, ":"); colonIdx != -1 {
827 port := registryPart[colonIdx+1:]
828 // If everything after colon is digits, it's a port
829 if len(port) > 0 && isNumeric(port) {
830 return true
831 }
832 }
833
834 // Check for domain pattern (has a dot, e.g., "docker.io", "gcr.io")
835 if strings.Contains(registryPart, ".") {
836 return true
837 }
838
839 return false
840}
841
842// isNumeric checks if a string contains only digits.
843func isNumeric(s string) bool {
844 for _, c := range s {
845 if c < '0' || c > '9' {
846 return false
847 }
848 }
849 return true
850}
851
852func normalizePullPolicy(policy string) string {
853 switch strings.ToLower(strings.TrimSpace(policy)) {
854 case PullPolicyAlways:
855 return PullPolicyAlways
856 case PullPolicyIfTagChanged:
857 return PullPolicyIfTagChanged
858 case PullPolicyIfNotPresent:
859 return PullPolicyIfNotPresent
860 default:
861 return PullPolicyIfNotPresent
862 }
863}
864
865func isDigestRef(imageRef string) bool {
866 return strings.Contains(imageRef, "@sha256:")
867}
868
869func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
870 if !domain.IsInternalDeploy(ctx) {
871 return imageRef, false
872 }
873 s.mu.RLock()
874 cfg := s.config
875 s.mu.RUnlock()
876 return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true
877}
878
879// ensureImage ensures the image is available locally, pulling if needed.
880// Returns the image reference to use for container operations:
881// - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
882// - For tagged images, returns the original imageRef after tagging the pulled image
883func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
884 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
885 log := zerowrap.FromCtx(ctx)
886
887 // Determine if this is an internal deploy and what reference to use for pulls.
888 pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef)
889 if pullRef != imageRef {
890 log.Info().
891 Str("original_ref", imageRef).
892 Str("pull_ref", pullRef).
893 Msg("internal deploy: using localhost registry for pull")
894 }
895
896 found, err := s.ensureLocalImage(ctx, imageRef, pullRef)
897 if err != nil {
898 return "", err
899 }
900 if found {
901 return imageRef, nil
902 }
903
904 // Pull image
905 log.Info().Msg("pulling image from registry")
906
907 if err := s.pullImage(ctx, pullRef, isInternal); err != nil {
908 return "", err
909 }
910
911 // For digest references, we can't create a tag (Docker doesn't allow it).
912 // In this case, use the pullRef directly since the image is available under that reference.
913 if strings.Contains(imageRef, "@sha256:") {
914 log.Info().
915 Str("pull_ref", pullRef).
916 Msg("digest reference: using pull reference for container operations")
917 return pullRef, nil
918 }
919
920 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
921 return "", err
922 }
923
924 // Clean up the temporary pull reference tag to avoid duplicate entries
925 if pullRef != imageRef {
926 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
927 // Log but don't fail - the canonical tag is already applied
928 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
929 }
930 }
931
932 log.Info().Msg("image pulled successfully")
933 return imageRef, nil
934}
935
936func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) {
937 log := zerowrap.FromCtx(ctx)
938
939 // For internal deploys (image push events), always pull fresh image
940 // because the same tag may reference new content (e.g., latest tag updated)
941 if domain.IsInternalDeploy(ctx) {
942 log.Info().Msg("internal deploy detected, forcing image pull to ensure latest content")
943 return false, nil
944 }
945
946 s.mu.RLock()
947 cfg := s.config
948 s.mu.RUnlock()
949
950 pullPolicy := normalizePullPolicy(cfg.PullPolicy)
951 switch pullPolicy {
952 case PullPolicyAlways:
953 log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
954 return false, nil
955 case PullPolicyIfTagChanged:
956 if !isDigestRef(imageRef) {
957 log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
958 return false, nil
959 }
960 }
961
962 localImages, err := s.runtime.ListImages(ctx)
963 if err != nil {
964 log.WrapErr(err, "failed to list local images, will attempt pull")
965 return false, nil
966 }
967
968 normalizedRef := normalizeImageRef(imageRef)
969 normalizedPullRef := normalizeImageRef(pullRef)
970 for _, img := range localImages {
971 normalizedImage := normalizeImageRef(img)
972 if normalizedImage == normalizedRef {
973 log.Info().Msg("image found locally, skipping pull")
974 return true, nil
975 }
976 if normalizedImage == normalizedPullRef {
977 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
978 return false, err
979 }
980 // Clean up the temporary pull reference tag
981 if pullRef != imageRef {
982 if err := s.runtime.UntagImage(ctx, pullRef); err != nil {
983 log.Debug().Err(err).Str("pull_ref", pullRef).Msg("failed to remove temporary pull tag")
984 }
985 }
986 log.Info().Msg("image found locally, skipping pull")
987 return true, nil
988 }
989 }
990
991 return false, nil
992}
993
994func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
995 s.mu.RLock()
996 cfg := s.config
997 s.mu.RUnlock()
998
999 log := zerowrap.FromCtx(ctx)
1000
1001 switch {
1002 case isInternal && cfg.RegistryAuthEnabled:
1003 if cfg.InternalRegistryUsername == "" || cfg.InternalRegistryPassword == "" {
1004 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
1005 }
1006 if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword); err != nil {
1007 return log.WrapErr(err, "failed to pull image with internal auth")
1008 }
1009 case isInternal:
1010 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
1011 return log.WrapErr(err, "failed to pull image")
1012 }
1013 case cfg.RegistryAuthEnabled:
1014 if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
1015 return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
1016 }
1017 if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {
1018 return log.WrapErr(err, "failed to pull image with auth")
1019 }
1020 default:
1021 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
1022 return log.WrapErr(err, "failed to pull image")
1023 }
1024 }
1025
1026 return nil
1027}
1028
1029func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error {
1030 if sourceRef == targetRef {
1031 return nil
1032 }
1033
1034 // Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
1035 // When using digest references (image@sha256:...), skip tagging as the image
1036 // is already available by its digest.
1037 if strings.Contains(targetRef, "@sha256:") {
1038 return nil
1039 }
1040
1041 log := zerowrap.FromCtx(ctx)
1042 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
1043 return log.WrapErr(err, "failed to tag image from pull reference")
1044 }
1045 return nil
1046}
1047
1048func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) {
1049 log := zerowrap.FromCtx(ctx)
1050
1051 userEnvVars, err := s.envLoader.LoadEnv(ctx, domainName)
1052 if err != nil {
1053 return nil, log.WrapErr(err, "failed to load environment variables")
1054 }
1055
1056 dockerfileEnvVars, err := s.runtime.InspectImageEnv(ctx, imageRef)
1057 if err != nil {
1058 log.WrapErr(err, "failed to inspect image environment")
1059 dockerfileEnvVars = []string{}
1060 }
1061
1062 return mergeEnvironmentVariables(dockerfileEnvVars, userEnvVars), nil
1063}
1064
1065func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {
1066 s.mu.RLock()
1067 cfg := s.config
1068 s.mu.RUnlock()
1069
1070 log := zerowrap.FromCtx(ctx)
1071 volumes := make(map[string]string)
1072
1073 if !cfg.VolumeAutoCreate {
1074 return volumes, nil
1075 }
1076
1077 volumePaths, err := s.runtime.InspectImageVolumes(ctx, imageRef)
1078 if err != nil {
1079 log.WrapErr(err, "failed to inspect image volumes")
1080 return volumes, nil
1081 }
1082
1083 for _, path := range volumePaths {
1084 name := generateVolumeName(cfg.VolumePrefix, domainName, path)
1085
1086 exists, err := s.runtime.VolumeExists(ctx, name)
1087 if err != nil {
1088 log.WrapErrWithFields(err, "failed to check volume", map[string]any{"volume": name})
1089 continue
1090 }
1091
1092 if !exists {
1093 if err := s.runtime.CreateVolume(ctx, name); err != nil {
1094 log.WrapErrWithFields(err, "failed to create volume", map[string]any{"volume": name})
1095 continue
1096 }
1097 log.Info().Str("volume", name).Str(zerowrap.FieldPath, path).Msg("created volume")
1098 }
1099
1100 volumes[path] = name
1101 }
1102
1103 return volumes, nil
1104}
1105
1106func (s *Service) getNetworkForApp(domainName string) string {
1107 s.mu.RLock()
1108 cfg := s.config
1109 s.mu.RUnlock()
1110
1111 if !cfg.NetworkIsolation {
1112 return "bridge"
1113 }
1114
1115 for groupName, domains := range cfg.NetworkGroups {
1116 if slices.Contains(domains, domainName) {
1117 return s.generateNetworkName(groupName)
1118 }
1119 }
1120
1121 return s.generateNetworkName(domainName)
1122}
1123
1124func (s *Service) generateNetworkName(identifier string) string {
1125 s.mu.RLock()
1126 cfg := s.config
1127 s.mu.RUnlock()
1128
1129 return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
1130}
1131
1132func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
1133 if networkName == "bridge" || networkName == "default" {
1134 return nil
1135 }
1136
1137 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
1138 log := zerowrap.FromCtx(ctx)
1139
1140 exists, err := s.runtime.NetworkExists(ctx, networkName)
1141 if err != nil {
1142 return log.WrapErr(err, "failed to check network existence")
1143 }
1144
1145 if !exists {
1146 if err := s.runtime.CreateNetwork(ctx, networkName, map[string]string{"driver": "bridge"}); err != nil {
1147 return log.WrapErr(err, "failed to create network")
1148 }
1149 log.Info().Msg("created network for app isolation")
1150 }
1151
1152 return nil
1153}
1154
1155func (s *Service) cleanupOrphanedContainers(ctx context.Context, domainName string, skipContainerID string) error {
1156 log := zerowrap.FromCtx(ctx)
1157 expectedName := fmt.Sprintf("gordon-%s", domainName)
1158
1159 allContainers, err := s.runtime.ListContainers(ctx, true)
1160 if err != nil {
1161 return err
1162 }
1163
1164 for _, c := range allContainers {
1165 if c.Name == expectedName && c.ID != skipContainerID {
1166 log.Info().Str(zerowrap.FieldEntityID, c.ID).Str(zerowrap.FieldStatus, c.Status).Msg("found orphaned container, removing")
1167
1168 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1169 log.WrapErrWithFields(err, "failed to stop orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
1170 }
1171
1172 if err := s.runtime.RemoveContainer(ctx, c.ID, true); err != nil {
1173 log.WrapErrWithFields(err, "failed to remove orphaned container", map[string]any{zerowrap.FieldEntityID: c.ID})
1174 }
1175 }
1176 }
1177
1178 return nil
1179}
1180
1181func (s *Service) cleanupVolumesForDomain(_ context.Context, _ string) error {
1182 return nil
1183}
1184
1185func (s *Service) cleanupNetworkIfEmpty(ctx context.Context, networkName string) error {
1186 if networkName == "bridge" || networkName == "default" {
1187 return nil
1188 }
1189
1190 ctx = zerowrap.CtxWithField(ctx, "network", networkName)
1191 log := zerowrap.FromCtx(ctx)
1192
1193 networks, err := s.runtime.ListNetworks(ctx)
1194 if err != nil {
1195 return log.WrapErr(err, "failed to list networks")
1196 }
1197
1198 for _, n := range networks {
1199 if n.Name == networkName && len(n.Containers) == 0 {
1200 if err := s.runtime.RemoveNetwork(ctx, networkName); err != nil {
1201 return err
1202 }
1203 log.Info().Msg("cleaned up empty network")
1204 break
1205 }
1206 }
1207
1208 return nil
1209}
1210
1211func (s *Service) deployAttachments(ctx context.Context, domainName, networkName string) error {
1212 // Collect attachments from both domain-specific and network group configs
1213 attachments := s.resolveAttachmentsForDomain(domainName)
1214 if len(attachments) == 0 {
1215 return nil
1216 }
1217
1218 log := zerowrap.FromCtx(ctx)
1219 var deployed []string // track successfully deployed attachment IDs for rollback
1220 for _, svc := range attachments {
1221 if err := s.deployAttachedService(ctx, domainName, svc, networkName); err != nil {
1222 log.WrapErrWithFields(err, "failed to deploy attachment", map[string]any{zerowrap.FieldService: svc, "domain": domainName})
1223
1224 // Rollback: clean up already-deployed attachments
1225 for _, id := range deployed {
1226 if stopErr := s.runtime.StopContainer(ctx, id); stopErr != nil {
1227 log.Warn().Err(stopErr).Str("attachment_id", id).Msg("rollback: failed to stop attachment")
1228 }
1229 if rmErr := s.runtime.RemoveContainer(ctx, id, true); rmErr != nil {
1230 log.Warn().Err(rmErr).Str("attachment_id", id).Msg("rollback: failed to remove attachment")
1231 }
1232 }
1233 // Deregister rolled-back attachments
1234 if len(deployed) > 0 {
1235 s.mu.Lock()
1236 remaining := s.attachments[domainName]
1237 filtered := remaining[:0]
1238 rollbackSet := make(map[string]bool, len(deployed))
1239 for _, id := range deployed {
1240 rollbackSet[id] = true
1241 }
1242 for _, id := range remaining {
1243 if !rollbackSet[id] {
1244 filtered = append(filtered, id)
1245 }
1246 }
1247 s.attachments[domainName] = filtered
1248 s.mu.Unlock()
1249 }
1250
1251 return fmt.Errorf("failed to deploy attachment %q (rolled back %d already-deployed)", svc, len(deployed))
1252 }
1253 // Collect IDs of attachments tracked under this domain after successful deploy
1254 s.mu.RLock()
1255 ids := s.attachments[domainName]
1256 if len(ids) > 0 {
1257 latest := ids[len(ids)-1]
1258 deployed = append(deployed, latest)
1259 }
1260 s.mu.RUnlock()
1261 }
1262 return nil
1263}
1264
1265// resolveAttachmentsForDomain returns attachments for a domain by checking:
1266// 1. Direct domain attachments (attachments[domain])
1267// 2. Network group attachments (attachments[group] where domain is in network_groups[group])
1268func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1269 s.mu.RLock()
1270 cfg := s.config
1271 s.mu.RUnlock()
1272
1273 seen := make(map[string]bool)
1274 var result []string
1275
1276 // First, add domain-specific attachments
1277 if domainAttachments, ok := cfg.Attachments[domainName]; ok {
1278 for _, img := range domainAttachments {
1279 if !seen[img] {
1280 seen[img] = true
1281 result = append(result, img)
1282 }
1283 }
1284 }
1285
1286 // Then, find which network group this domain belongs to and add group attachments
1287 for groupName, domains := range cfg.NetworkGroups {
1288 if slices.Contains(domains, domainName) {
1289 if groupAttachments, ok := cfg.Attachments[groupName]; ok {
1290 for _, img := range groupAttachments {
1291 if !seen[img] {
1292 seen[img] = true
1293 result = append(result, img)
1294 }
1295 }
1296 }
1297 break // Domain can only be in one network group
1298 }
1299 }
1300
1301 return result
1302}
1303
1304func (s *Service) getAttachmentsForDomain(ctx context.Context, domainName string) []domain.Attachment {
1305 containers, err := s.runtime.ListContainers(ctx, true)
1306 if err != nil {
1307 return nil
1308 }
1309
1310 return s.filterAttachments(ctx, containers, domainName)
1311}
1312
1313// getAllAttachments fetches all containers once and returns attachments grouped by domain.
1314// This avoids N+1 queries when listing multiple routes.
1315func (s *Service) getAllAttachments(ctx context.Context) map[string][]domain.Attachment {
1316 containers, err := s.runtime.ListContainers(ctx, true)
1317 if err != nil {
1318 return nil
1319 }
1320
1321 result := make(map[string][]domain.Attachment)
1322 for _, container := range containers {
1323 if container.Labels == nil {
1324 continue
1325 }
1326 if container.Labels[domain.LabelAttachment] != "true" {
1327 continue
1328 }
1329 ownerDomain := container.Labels[domain.LabelAttachedTo]
1330 if ownerDomain == "" {
1331 continue
1332 }
1333 image := container.Image
1334 serviceName := container.Name
1335 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1336 image = labelImage
1337 serviceName = extractServiceName(labelImage)
1338 }
1339 // Get the container's network for display in the routes table
1340 network := ""
1341 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1342 network = networkName
1343 }
1344 attachment := domain.Attachment{
1345 Name: serviceName,
1346 Image: s.stripRegistryPrefix(image),
1347 ContainerID: container.ID,
1348 Status: container.Status,
1349 Network: network,
1350 }
1351 result[ownerDomain] = append(result[ownerDomain], attachment)
1352 }
1353
1354 return result
1355}
1356
1357// filterAttachments extracts attachments for a specific domain from a container list.
1358func (s *Service) filterAttachments(ctx context.Context, containers []*domain.Container, domainName string) []domain.Attachment {
1359 attachments := make([]domain.Attachment, 0)
1360 for _, container := range containers {
1361 if container.Labels == nil {
1362 continue
1363 }
1364 if container.Labels[domain.LabelAttachment] != "true" {
1365 continue
1366 }
1367 if container.Labels[domain.LabelAttachedTo] != domainName {
1368 continue
1369 }
1370 image := container.Image
1371 serviceName := container.Name
1372 if labelImage, ok := container.Labels[domain.LabelImage]; ok && labelImage != "" {
1373 image = labelImage
1374 serviceName = extractServiceName(labelImage)
1375 }
1376 // Get the container's network for display in the routes table
1377 network := ""
1378 if networkName, err := s.runtime.GetContainerNetwork(ctx, container.ID); err == nil {
1379 network = networkName
1380 }
1381 attachment := domain.Attachment{
1382 Name: serviceName,
1383 Image: s.stripRegistryPrefix(image),
1384 ContainerID: container.ID,
1385 Status: container.Status,
1386 Network: network,
1387 }
1388 attachments = append(attachments, attachment)
1389 }
1390
1391 return attachments
1392}
1393
1394func (s *Service) deployAttachedService(ctx context.Context, ownerDomain, serviceImage, networkName string) error {
1395 log := zerowrap.FromCtx(ctx)
1396
1397 // Parse service name from image (e.g., "my-postgres:latest" → "postgres")
1398 serviceName := extractServiceName(serviceImage)
1399 containerName := fmt.Sprintf("gordon-%s-%s", sanitizeName(ownerDomain), serviceName)
1400
1401 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
1402 "attachment": serviceName,
1403 "container_name": containerName,
1404 "owner_domain": ownerDomain,
1405 })
1406
1407 // Check if already running (idempotent)
1408 // Try new name first, then fall back to legacy name for backwards compatibility
1409 existingContainer := s.findContainerByName(ctx, containerName)
1410 if existingContainer == nil {
1411 // Try legacy naming scheme for backwards compatibility during upgrades
1412 containerNameLegacy := fmt.Sprintf("gordon-%s-%s", sanitizeNameLegacy(ownerDomain), serviceName)
1413 existingContainer = s.findContainerByName(ctx, containerNameLegacy)
1414 if existingContainer != nil {
1415 log.Info().Str("container_name", containerNameLegacy).Msg("found attachment with legacy naming, will be replaced")
1416 // Remove the old container and recreate with new name
1417 if err := s.runtime.StopContainer(ctx, existingContainer.ID); err != nil {
1418 return log.WrapErr(err, "failed to stop legacy attachment container")
1419 }
1420 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1421 return log.WrapErr(err, "failed to remove legacy attachment container")
1422 }
1423 existingContainer = nil // Clear so we create new one below
1424 }
1425 }
1426
1427 if existingContainer != nil && existingContainer.Status == string(domain.ContainerStatusRunning) {
1428 log.Debug().Str("container_name", containerName).Msg("attachment already running, skipping")
1429 return nil
1430 }
1431
1432 // Remove existing stopped container if present
1433 if existingContainer != nil {
1434 log.Info().Str("container_name", containerName).Msg("removing stopped attachment container")
1435 if err := s.runtime.RemoveContainer(ctx, existingContainer.ID, true); err != nil {
1436 return log.WrapErr(err, "failed to remove existing attachment container")
1437 }
1438 }
1439
1440 log.Info().Str(zerowrap.FieldService, serviceImage).Msg("deploying attached service")
1441
1442 // Ensure image (canonical ref is returned; internal pulls may use the local registry).
1443 imageRef := s.buildImageRef(serviceImage)
1444 actualImageRef, err := s.ensureImage(ctx, imageRef)
1445 if err != nil {
1446 return err
1447 }
1448
1449 // Get image metadata
1450 exposedPorts, err := s.runtime.GetImageExposedPorts(ctx, actualImageRef)
1451 if err != nil {
1452 log.WrapErr(err, "failed to get exposed ports for attachment, using defaults")
1453 exposedPorts = []int{}
1454 }
1455
1456 // Setup volumes (attachments need persistent data)
1457 volumes, err := s.setupVolumes(ctx, containerName, actualImageRef)
1458 if err != nil {
1459 log.WrapErr(err, "failed to setup volumes for attachment")
1460 volumes = make(map[string]string)
1461 }
1462
1463 // Load environment (attachment-specific env file)
1464 envVars, err := s.loadEnvironment(ctx, containerName, actualImageRef)
1465 if err != nil {
1466 log.WrapErr(err, "failed to load environment for attachment")
1467 envVars = []string{}
1468 }
1469
1470 // Create container with attachment labels (use actual ref, track original in labels)
1471 config := &domain.ContainerConfig{
1472 Image: actualImageRef,
1473 Name: containerName,
1474 Hostname: serviceName, // Internal DNS: postgres, redis, etc.
1475 Ports: exposedPorts,
1476 Env: envVars,
1477 Volumes: volumes,
1478 NetworkMode: networkName, // Same network as main app
1479 Labels: map[string]string{
1480 "gordon.managed": "true",
1481 "gordon.attachment": "true",
1482 "gordon.attached-to": ownerDomain,
1483 "gordon.image": serviceImage,
1484 },
1485 }
1486
1487 container, err := s.runtime.CreateContainer(ctx, config)
1488 if err != nil {
1489 return log.WrapErr(err, "failed to create attachment container")
1490 }
1491
1492 // Start container
1493 if err := s.runtime.StartContainer(ctx, container.ID); err != nil {
1494 s.runtime.RemoveContainer(ctx, container.ID, true)
1495 return log.WrapErr(err, "failed to start attachment container")
1496 }
1497
1498 // Track attachment
1499 s.mu.Lock()
1500 s.attachments[ownerDomain] = append(s.attachments[ownerDomain], container.ID)
1501 s.mu.Unlock()
1502
1503 // Start log collection for attachment
1504 s.startLogCollection(ctx, container.ID, containerName)
1505
1506 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("attachment deployed successfully")
1507 return nil
1508}
1509
1510func (s *Service) stopAllManagedContainers(ctx context.Context) error {
1511 log := zerowrap.FromCtx(ctx)
1512
1513 s.mu.RLock()
1514 containers := make(map[string]*domain.Container, len(s.containers))
1515 maps.Copy(containers, s.containers)
1516 s.mu.RUnlock()
1517
1518 if len(containers) == 0 {
1519 log.Info().Msg("no managed containers to stop")
1520 return nil
1521 }
1522
1523 log.Info().Int(zerowrap.FieldCount, len(containers)).Msg("stopping all managed containers...")
1524
1525 errorCount := 0
1526 for d, c := range containers {
1527 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("stopping managed container")
1528
1529 if err := s.runtime.StopContainer(ctx, c.ID); err != nil {
1530 log.WrapErrWithFields(err, "failed to stop container", map[string]any{"domain": d, zerowrap.FieldEntityID: c.ID})
1531 errorCount++
1532 continue
1533 }
1534
1535 s.mu.Lock()
1536 delete(s.containers, d)
1537 s.mu.Unlock()
1538
1539 log.Info().Str("domain", d).Str(zerowrap.FieldEntityID, c.ID).Msg("container stopped successfully")
1540 }
1541
1542 if errorCount > 0 {
1543 return fmt.Errorf("failed to stop %d containers", errorCount)
1544 }
1545
1546 log.Info().Msg("all managed containers stopped successfully")
1547 return nil
1548}
1549
1550// Utility functions
1551
1552func normalizeImageRef(image string) string {
1553 // Extract repository from image reference, handling port numbers correctly.
1554 // Examples:
1555 // nginx:latest -> docker.io/library/nginx
1556 // user/repo:tag -> docker.io/user/repo
1557 // localhost:5000/image:tag -> localhost:5000/image
1558 // registry.example.com/image:tag -> registry.example.com/image
1559 //
1560 // Note: This function assumes valid Docker image references. Edge cases like
1561 // "registry.com:5000" (registry with port but no image name) are not valid
1562 // image references per Docker's naming conventions, which require at least
1563 // one path component after the registry (e.g., "registry.com:5000/image").
1564
1565 // Find the tag separator: the last colon that isn't part of a port number.
1566 // A colon is part of a port if there's no slash after it until the next colon.
1567 repo := image
1568 lastColon := strings.LastIndex(image, ":")
1569 if lastColon != -1 {
1570 afterColon := image[lastColon+1:]
1571 // If there's no slash after the colon, it's the tag separator
1572 if !strings.Contains(afterColon, "/") {
1573 repo = image[:lastColon]
1574 }
1575 }
1576
1577 if !strings.Contains(repo, "/") {
1578 return "docker.io/library/" + repo
1579 }
1580
1581 if strings.Count(repo, "/") == 1 && !strings.Contains(strings.Split(repo, "/")[0], ".") && !strings.Contains(strings.Split(repo, "/")[0], ":") {
1582 return "docker.io/" + repo
1583 }
1584
1585 return repo
1586}
1587
1588func generateVolumeName(prefix, domainName, volumePath string) string {
1589 return fmt.Sprintf("%s-%s-%s",
1590 prefix,
1591 strings.ReplaceAll(domainName, ".", "-"),
1592 strings.ReplaceAll(strings.Trim(volumePath, "/"), "/", "-"))
1593}
1594
1595func mergeEnvironmentVariables(dockerfileEnv, userEnv []string) []string {
1596 envMap := make(map[string]string)
1597
1598 for _, env := range dockerfileEnv {
1599 if k, v, ok := strings.Cut(env, "="); ok {
1600 envMap[k] = v
1601 }
1602 }
1603
1604 for _, env := range userEnv {
1605 if k, v, ok := strings.Cut(env, "="); ok {
1606 envMap[k] = v
1607 }
1608 }
1609
1610 result := make([]string, 0, len(envMap))
1611 for k, v := range envMap {
1612 result = append(result, k+"="+v)
1613 }
1614
1615 return result
1616}
1617
1618// extractServiceName gets service name from image reference.
1619// "my-postgres:latest" → "postgres", "redis:7" → "redis"
1620func extractServiceName(image string) string {
1621 // Remove tag
1622 parts := strings.Split(image, ":")
1623 name := parts[0]
1624
1625 // Remove registry prefix if present
1626 if strings.Contains(name, "/") {
1627 nameParts := strings.Split(name, "/")
1628 name = nameParts[len(nameParts)-1]
1629 }
1630
1631 // Remove common prefixes like "my-"
1632 name = strings.TrimPrefix(name, "my-")
1633
1634 return name
1635}
1636
1637// sanitizeName is a convenience wrapper around domain.SanitizeDomainForContainer.
1638func sanitizeName(d string) string {
1639 return domain.SanitizeDomainForContainer(d)
1640}
1641
1642// sanitizeNameLegacy is a convenience wrapper around domain.SanitizeDomainForContainerLegacy.
1643func sanitizeNameLegacy(d string) string {
1644 return domain.SanitizeDomainForContainerLegacy(d)
1645}
1646
1647// findContainerByName finds a container by its name.
1648func (s *Service) findContainerByName(ctx context.Context, name string) *domain.Container {
1649 containers, err := s.runtime.ListContainers(ctx, true)
1650 if err != nil {
1651 return nil
1652 }
1653
1654 for _, c := range containers {
1655 if c.Name == name {
1656 return c
1657 }
1658 }
1659 return nil
1660}
1661
1662// waitForReady waits for a container to be ready.
1663// Uses simple "running + delay" approach for universal compatibility.
1664func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1665 log := zerowrap.FromCtx(ctx)
1666
1667 // Poll for container to be running (max 30 seconds)
1668 for i := 0; i < 30; i++ {
1669 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1670 if err != nil {
1671 return err
1672 }
1673 if running {
1674 break
1675 }
1676 if i == 29 {
1677 return fmt.Errorf("container did not start within 30 seconds")
1678 }
1679 select {
1680 case <-time.After(time.Second):
1681 case <-ctx.Done():
1682 return ctx.Err()
1683 }
1684 }
1685
1686 s.mu.RLock()
1687 cfg := s.config
1688 s.mu.RUnlock()
1689
1690 // Additional readiness delay (configurable, default 5 seconds)
1691 delay := cfg.ReadinessDelay
1692 if delay == 0 {
1693 delay = 5 * time.Second
1694 }
1695
1696 log.Debug().Dur("delay", delay).Msg("waiting for container readiness")
1697 select {
1698 case <-time.After(delay):
1699 case <-ctx.Done():
1700 return ctx.Err()
1701 }
1702
1703 // Verify still running after delay
1704 running, err := s.runtime.IsContainerRunning(ctx, containerID)
1705 if err != nil {
1706 return err
1707 }
1708 if !running {
1709 return fmt.Errorf("container stopped during readiness delay")
1710 }
1711
1712 return nil
1713}
1714
1715// publishContainerDeployed publishes a container.deployed event.
1716func (s *Service) publishContainerDeployed(ctx context.Context, domainName, containerID string) {
1717 payload := &domain.ContainerEventPayload{
1718 ContainerID: containerID,
1719 Domain: domainName,
1720 Action: "deployed",
1721 }
1722
1723 if err := s.eventBus.Publish(domain.EventContainerDeployed, payload); err != nil {
1724 log := zerowrap.FromCtx(ctx)
1725 log.Warn().Err(err).Msg("failed to publish container deployed event")
1726 }
1727}
1728
1729// rewriteToRegistryDomain rewrites an image reference to use the configured registry domain.
1730// e.g., "myapp:latest" -> "registry.example.com/myapp:latest"
1731func rewriteToRegistryDomain(imageRef, registryDomain string) string {
1732 if registryDomain == "" {
1733 return imageRef
1734 }
1735
1736 prefix := registryDomain + "/"
1737 if strings.HasPrefix(imageRef, prefix) {
1738 return imageRef
1739 }
1740
1741 return prefix + imageRef
1742}
1743
1744// rewriteToLocalRegistry rewrites an image reference to use the local registry address.
1745// e.g., "registry.example.com/myapp:latest" -> "localhost:5000/myapp:latest"
1746func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) string {
1747 if imageRef == "" {
1748 return imageRef
1749 }
1750
1751 localRegistry := fmt.Sprintf("localhost:%d", registryPort)
1752 localPrefix := localRegistry + "/"
1753 imageRef = strings.TrimPrefix(imageRef, localPrefix)
1754
1755 registryDomain = strings.TrimSuffix(registryDomain, "/")
1756 if registryDomain != "" {
1757 prefix := registryDomain + "/"
1758 imageRef = strings.TrimPrefix(imageRef, prefix)
1759 }
1760
1761 return localPrefix + imageRef
1762}