detached f40d482

fix(concurrency): protect config reads with RLock, add handler timeouts

- Proxy service: copy s.config under RLock in ServeHTTP/proxyToRegistry
- Container service: copy s.config under RLock in 11 reader methods
- Event bus: wrap handler invocations with 30s timeout to prevent blocking
commit
f40d482125daa666e8b39b5423b5bbfb659f1de8
author
brice · · 2026-02-06 05:42 UTC
committer
brice · · 2026-02-06 05:43 UTC
parent
8238857
signature
unsigned
3 files · +151 -68
M internal/adapters/out/eventbus/inmemory.go +29 -12
@@ -188,24 +188,41 @@ func (bus *InMemory) handleEvent(event domain.Event) {
188188 if handler.CanHandle(event.Type) {
189189 start := time.Now()
190190
191- if err := handler.Handle(event); err != nil {
192- bus.log.Error().
193- Str(zerowrap.FieldLayer, "adapter").
194- Str(zerowrap.FieldAdapter, "eventbus").
195- Err(err).
196- Str("event_id", event.ID).
197- Str(zerowrap.FieldEvent, string(event.Type)).
198- Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
199- Msg("error handling event")
200- } else {
201- bus.log.Debug().
191+ done := make(chan error, 1)
192+ go func() {
193+ done <- handler.Handle(event)
194+ }()
195+
196+ select {
197+ case err := <-done:
198+ if err != nil {
199+ bus.log.Error().
200+ Str(zerowrap.FieldLayer, "adapter").
201+ Str(zerowrap.FieldAdapter, "eventbus").
202+ Err(err).
203+ Str("event_id", event.ID).
204+ Str(zerowrap.FieldEvent, string(event.Type)).
205+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
206+ Msg("error handling event")
207+ } else {
208+ bus.log.Debug().
209+ Str(zerowrap.FieldLayer, "adapter").
210+ Str(zerowrap.FieldAdapter, "eventbus").
211+ Str("event_id", event.ID).
212+ Str(zerowrap.FieldEvent, string(event.Type)).
213+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
214+ Dur(zerowrap.FieldDuration, time.Since(start)).
215+ Msg("event handled successfully")
216+ }
217+ case <-time.After(30 * time.Second):
218+ bus.log.Warn().
202219 Str(zerowrap.FieldLayer, "adapter").
203220 Str(zerowrap.FieldAdapter, "eventbus").
204221 Str("event_id", event.ID).
205222 Str(zerowrap.FieldEvent, string(event.Type)).
206223 Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
207224 Dur(zerowrap.FieldDuration, time.Since(start)).
208- Msg("event handled successfully")
225+ Msg("handler timeout after 30s")
209226 }
210227 }
211228 }
@@ -188,24 +188,41 @@ func (bus *InMemory) handleEvent(event domain.Event) {
188 if handler.CanHandle(event.Type) {188 if handler.CanHandle(event.Type) {
189 start := time.Now()189 start := time.Now()
190 190
191- if err := handler.Handle(event); err != nil {191+ done := make(chan error, 1)
192- bus.log.Error().192+ go func() {
193- Str(zerowrap.FieldLayer, "adapter").193+ done <- handler.Handle(event)
194- Str(zerowrap.FieldAdapter, "eventbus").194+ }()
195- Err(err).195+
196- Str("event_id", event.ID).196+ select {
197- Str(zerowrap.FieldEvent, string(event.Type)).197+ case err := <-done:
198- Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).198+ if err != nil {
199- Msg("error handling event")199+ bus.log.Error().
200- } else {200+ Str(zerowrap.FieldLayer, "adapter").
201- bus.log.Debug().201+ Str(zerowrap.FieldAdapter, "eventbus").
202+ Err(err).
203+ Str("event_id", event.ID).
204+ Str(zerowrap.FieldEvent, string(event.Type)).
205+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
206+ Msg("error handling event")
207+ } else {
208+ bus.log.Debug().
209+ Str(zerowrap.FieldLayer, "adapter").
210+ Str(zerowrap.FieldAdapter, "eventbus").
211+ Str("event_id", event.ID).
212+ Str(zerowrap.FieldEvent, string(event.Type)).
213+ Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
214+ Dur(zerowrap.FieldDuration, time.Since(start)).
215+ Msg("event handled successfully")
216+ }
217+ case <-time.After(30 * time.Second):
218+ bus.log.Warn().
202 Str(zerowrap.FieldLayer, "adapter").219 Str(zerowrap.FieldLayer, "adapter").
203 Str(zerowrap.FieldAdapter, "eventbus").220 Str(zerowrap.FieldAdapter, "eventbus").
204 Str("event_id", event.ID).221 Str("event_id", event.ID).
205 Str(zerowrap.FieldEvent, string(event.Type)).222 Str(zerowrap.FieldEvent, string(event.Type)).
206 Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).223 Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)).
207 Dur(zerowrap.FieldDuration, time.Since(start)).224 Dur(zerowrap.FieldDuration, time.Since(start)).
208- Msg("event handled successfully")225+ Msg("handler timeout after 30s")
209 }226 }
210 }227 }
211 }228 }
M internal/usecase/container/service.go +106 -50
@@ -372,45 +372,21 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
372372 }
373373
374374 // Find domain and attachment IDs for cleanup
375- var containerDomain string
376- var attachmentIDs []string
377- s.mu.RLock()
378- for d, c := range s.containers {
379- if c.ID == containerID {
380- containerDomain = d
381- break
382- }
383- }
384- if containerDomain != "" {
385- attachmentIDs = append(attachmentIDs, s.attachments[containerDomain]...)
386- }
387- s.mu.RUnlock()
375+ containerDomain, attachmentIDs, cfg := s.findContainerContext(containerID)
388376
389377 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
390378 return log.WrapErr(err, "failed to remove container")
391379 }
392380
393381 // Cleanup volumes
394- if containerDomain != "" && !s.config.VolumePreserve {
382+ if containerDomain != "" && !cfg.VolumePreserve {
395383 if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
396384 log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
397385 }
398386 }
399387
400388 // Clean up attachment containers
401- for _, attachID := range attachmentIDs {
402- if s.logWriter != nil {
403- if err := s.logWriter.StopLogging(attachID); err != nil {
404- log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment log collection")
405- }
406- }
407- if err := s.runtime.StopContainer(ctx, attachID); err != nil {
408- log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment container")
409- }
410- if err := s.runtime.RemoveContainer(ctx, attachID, true); err != nil {
411- log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to remove attachment container")
412- }
413- }
389+ s.removeAttachments(ctx, attachmentIDs)
414390
415391 // Remove from tracking
416392 s.mu.Lock()
@@ -435,7 +411,7 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
435411 // domain ever deployed), and this is the safer choice for correctness.
436412
437413 // Cleanup network
438- if removedDomain != "" && s.config.NetworkIsolation {
414+ if removedDomain != "" && cfg.NetworkIsolation {
439415 networkName := s.getNetworkForApp(removedDomain)
440416 if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
441417 log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
@@ -445,6 +421,43 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
445421 return nil
446422 }
447423
424+// findContainerContext looks up the domain, attachment IDs, and config snapshot for a container.
425+func (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.
444+func (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+
448461 // Get retrieves a container by domain.
449462 func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
450463 s.mu.RLock()
@@ -514,10 +527,14 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
514527 // stripRegistryPrefix removes the configured registry domain prefix from an image reference.
515528 // For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
516529 func (s *Service) stripRegistryPrefix(image string) string {
517- if s.config.RegistryDomain == "" {
530+ s.mu.RLock()
531+ cfg := s.config
532+ s.mu.RUnlock()
533+
534+ if cfg.RegistryDomain == "" {
518535 return image
519536 }
520- prefix := strings.TrimSuffix(s.config.RegistryDomain, "/") + "/"
537+ prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/"
521538 if strings.HasPrefix(image, prefix) {
522539 return strings.TrimPrefix(image, prefix)
523540 }
@@ -531,6 +548,10 @@ func (s *Service) ListAttachments(ctx context.Context, domainName string) []doma
531548
532549 // ListNetworks returns Gordon-managed networks.
533550 func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
551+ s.mu.RLock()
552+ cfg := s.config
553+ s.mu.RUnlock()
554+
534555 networks, err := s.runtime.ListNetworks(ctx)
535556 if err != nil {
536557 return nil, err
@@ -538,7 +559,7 @@ func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, erro
538559
539560 var filtered []*domain.NetworkInfo
540561 for _, network := range networks {
541- if strings.HasPrefix(network.Name, s.config.NetworkPrefix+"-") {
562+ if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") {
542563 filtered = append(filtered, network)
543564 }
544565 }
@@ -744,12 +765,16 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
744765 }
745766
746767 func (s *Service) buildImageRef(image string) string {
747- if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {
768+ s.mu.RLock()
769+ cfg := s.config
770+ s.mu.RUnlock()
771+
772+ if !cfg.RegistryAuthEnabled || cfg.RegistryDomain == "" {
748773 return image
749774 }
750775
751776 // Normalize registry domain by trimming any trailing slash
752- reg := strings.TrimSuffix(s.config.RegistryDomain, "/")
777+ reg := strings.TrimSuffix(cfg.RegistryDomain, "/")
753778
754779 // Check if image already has the registry domain prefix
755780 prefix := reg + "/"
@@ -845,7 +870,10 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
845870 if !domain.IsInternalDeploy(ctx) {
846871 return imageRef, false
847872 }
848- return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true
873+ s.mu.RLock()
874+ cfg := s.config
875+ s.mu.RUnlock()
876+ return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true
849877 }
850878
851879 // ensureImage ensures the image is available locally, pulling if needed.
@@ -915,7 +943,11 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string
915943 return false, nil
916944 }
917945
918- pullPolicy := normalizePullPolicy(s.config.PullPolicy)
946+ s.mu.RLock()
947+ cfg := s.config
948+ s.mu.RUnlock()
949+
950+ pullPolicy := normalizePullPolicy(cfg.PullPolicy)
919951 switch pullPolicy {
920952 case PullPolicyAlways:
921953 log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
@@ -960,25 +992,29 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string
960992 }
961993
962994 func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
995+ s.mu.RLock()
996+ cfg := s.config
997+ s.mu.RUnlock()
998+
963999 log := zerowrap.FromCtx(ctx)
9641000
9651001 switch {
966- case isInternal && s.config.RegistryAuthEnabled:
967- if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {
1002+ case isInternal && cfg.RegistryAuthEnabled:
1003+ if cfg.InternalRegistryUsername == "" || cfg.InternalRegistryPassword == "" {
9681004 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
9691005 }
970- if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {
1006+ if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword); err != nil {
9711007 return log.WrapErr(err, "failed to pull image with internal auth")
9721008 }
9731009 case isInternal:
9741010 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
9751011 return log.WrapErr(err, "failed to pull image")
9761012 }
977- case s.config.RegistryAuthEnabled:
978- if s.config.ServiceTokenUsername == "" || s.config.ServiceToken == "" {
1013+ case cfg.RegistryAuthEnabled:
1014+ if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
9791015 return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
9801016 }
981- if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.ServiceTokenUsername, s.config.ServiceToken); err != nil {
1017+ if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {
9821018 return log.WrapErr(err, "failed to pull image with auth")
9831019 }
9841020 default:
@@ -1027,10 +1063,14 @@ func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef stri
10271063 }
10281064
10291065 func (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+
10301070 log := zerowrap.FromCtx(ctx)
10311071 volumes := make(map[string]string)
10321072
1033- if !s.config.VolumeAutoCreate {
1073+ if !cfg.VolumeAutoCreate {
10341074 return volumes, nil
10351075 }
10361076
@@ -1041,7 +1081,7 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
10411081 }
10421082
10431083 for _, path := range volumePaths {
1044- name := generateVolumeName(s.config.VolumePrefix, domainName, path)
1084+ name := generateVolumeName(cfg.VolumePrefix, domainName, path)
10451085
10461086 exists, err := s.runtime.VolumeExists(ctx, name)
10471087 if err != nil {
@@ -1064,11 +1104,15 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
10641104 }
10651105
10661106 func (s *Service) getNetworkForApp(domainName string) string {
1067- if !s.config.NetworkIsolation {
1107+ s.mu.RLock()
1108+ cfg := s.config
1109+ s.mu.RUnlock()
1110+
1111+ if !cfg.NetworkIsolation {
10681112 return "bridge"
10691113 }
10701114
1071- for groupName, domains := range s.config.NetworkGroups {
1115+ for groupName, domains := range cfg.NetworkGroups {
10721116 if slices.Contains(domains, domainName) {
10731117 return s.generateNetworkName(groupName)
10741118 }
@@ -1078,7 +1122,11 @@ func (s *Service) getNetworkForApp(domainName string) string {
10781122 }
10791123
10801124 func (s *Service) generateNetworkName(identifier string) string {
1081- return fmt.Sprintf("%s-%s", s.config.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
1125+ s.mu.RLock()
1126+ cfg := s.config
1127+ s.mu.RUnlock()
1128+
1129+ return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
10821130 }
10831131
10841132 func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
@@ -1186,11 +1234,15 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
11861234 // 1. Direct domain attachments (attachments[domain])
11871235 // 2. Network group attachments (attachments[group] where domain is in network_groups[group])
11881236 func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1237+ s.mu.RLock()
1238+ cfg := s.config
1239+ s.mu.RUnlock()
1240+
11891241 seen := make(map[string]bool)
11901242 var result []string
11911243
11921244 // First, add domain-specific attachments
1193- if domainAttachments, ok := s.config.Attachments[domainName]; ok {
1245+ if domainAttachments, ok := cfg.Attachments[domainName]; ok {
11941246 for _, img := range domainAttachments {
11951247 if !seen[img] {
11961248 seen[img] = true
@@ -1200,9 +1252,9 @@ func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
12001252 }
12011253
12021254 // Then, find which network group this domain belongs to and add group attachments
1203- for groupName, domains := range s.config.NetworkGroups {
1255+ for groupName, domains := range cfg.NetworkGroups {
12041256 if slices.Contains(domains, domainName) {
1205- if groupAttachments, ok := s.config.Attachments[groupName]; ok {
1257+ if groupAttachments, ok := cfg.Attachments[groupName]; ok {
12061258 for _, img := range groupAttachments {
12071259 if !seen[img] {
12081260 seen[img] = true
@@ -1599,8 +1651,12 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
15991651 }
16001652 }
16011653
1654+ s.mu.RLock()
1655+ cfg := s.config
1656+ s.mu.RUnlock()
1657+
16021658 // Additional readiness delay (configurable, default 5 seconds)
1603- delay := s.config.ReadinessDelay
1659+ delay := cfg.ReadinessDelay
16041660 if delay == 0 {
16051661 delay = 5 * time.Second
16061662 }
@@ -372,45 +372,21 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
372 }372 }
373 373
374 // Find domain and attachment IDs for cleanup374 // Find domain and attachment IDs for cleanup
375- var containerDomain string375+ containerDomain, attachmentIDs, cfg := s.findContainerContext(containerID)
376- var attachmentIDs []string
377- s.mu.RLock()
378- for d, c := range s.containers {
379- if c.ID == containerID {
380- containerDomain = d
381- break
382- }
383- }
384- if containerDomain != "" {
385- attachmentIDs = append(attachmentIDs, s.attachments[containerDomain]...)
386- }
387- s.mu.RUnlock()
388 376
389 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {377 if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil {
390 return log.WrapErr(err, "failed to remove container")378 return log.WrapErr(err, "failed to remove container")
391 }379 }
392 380
393 // Cleanup volumes381 // Cleanup volumes
394- if containerDomain != "" && !s.config.VolumePreserve {382+ if containerDomain != "" && !cfg.VolumePreserve {
395 if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {383 if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil {
396 log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})384 log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain})
397 }385 }
398 }386 }
399 387
400 // Clean up attachment containers388 // Clean up attachment containers
401- for _, attachID := range attachmentIDs {389+ s.removeAttachments(ctx, attachmentIDs)
402- if s.logWriter != nil {
403- if err := s.logWriter.StopLogging(attachID); err != nil {
404- log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment log collection")
405- }
406- }
407- if err := s.runtime.StopContainer(ctx, attachID); err != nil {
408- log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to stop attachment container")
409- }
410- if err := s.runtime.RemoveContainer(ctx, attachID, true); err != nil {
411- log.Warn().Err(err).Str("attachment_id", attachID).Msg("failed to remove attachment container")
412- }
413- }
414 390
415 // Remove from tracking391 // Remove from tracking
416 s.mu.Lock()392 s.mu.Lock()
@@ -435,7 +411,7 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
435 // domain ever deployed), and this is the safer choice for correctness.411 // domain ever deployed), and this is the safer choice for correctness.
436 412
437 // Cleanup network413 // Cleanup network
438- if removedDomain != "" && s.config.NetworkIsolation {414+ if removedDomain != "" && cfg.NetworkIsolation {
439 networkName := s.getNetworkForApp(removedDomain)415 networkName := s.getNetworkForApp(removedDomain)
440 if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {416 if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil {
441 log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})417 log.WrapErrWithFields(err, "failed to cleanup network", map[string]any{"network": networkName})
@@ -445,6 +421,43 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er
445 return nil421 return nil
446 }422 }
447 423
424+// findContainerContext looks up the domain, attachment IDs, and config snapshot for a container.
425+func (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.
444+func (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+
448 // Get retrieves a container by domain.461 // Get retrieves a container by domain.
449 func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {462 func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) {
450 s.mu.RLock()463 s.mu.RLock()
@@ -514,10 +527,14 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo
514 // stripRegistryPrefix removes the configured registry domain prefix from an image reference.527 // stripRegistryPrefix removes the configured registry domain prefix from an image reference.
515 // For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".528 // For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com".
516 func (s *Service) stripRegistryPrefix(image string) string {529 func (s *Service) stripRegistryPrefix(image string) string {
517- if s.config.RegistryDomain == "" {530+ s.mu.RLock()
531+ cfg := s.config
532+ s.mu.RUnlock()
533+
534+ if cfg.RegistryDomain == "" {
518 return image535 return image
519 }536 }
520- prefix := strings.TrimSuffix(s.config.RegistryDomain, "/") + "/"537+ prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/"
521 if strings.HasPrefix(image, prefix) {538 if strings.HasPrefix(image, prefix) {
522 return strings.TrimPrefix(image, prefix)539 return strings.TrimPrefix(image, prefix)
523 }540 }
@@ -531,6 +548,10 @@ func (s *Service) ListAttachments(ctx context.Context, domainName string) []doma
531 548
532 // ListNetworks returns Gordon-managed networks.549 // ListNetworks returns Gordon-managed networks.
533 func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {550 func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
551+ s.mu.RLock()
552+ cfg := s.config
553+ s.mu.RUnlock()
554+
534 networks, err := s.runtime.ListNetworks(ctx)555 networks, err := s.runtime.ListNetworks(ctx)
535 if err != nil {556 if err != nil {
536 return nil, err557 return nil, err
@@ -538,7 +559,7 @@ func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, erro
538 559
539 var filtered []*domain.NetworkInfo560 var filtered []*domain.NetworkInfo
540 for _, network := range networks {561 for _, network := range networks {
541- if strings.HasPrefix(network.Name, s.config.NetworkPrefix+"-") {562+ if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") {
542 filtered = append(filtered, network)563 filtered = append(filtered, network)
543 }564 }
544 }565 }
@@ -744,12 +765,16 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
744 }765 }
745 766
746 func (s *Service) buildImageRef(image string) string {767 func (s *Service) buildImageRef(image string) string {
747- if !s.config.RegistryAuthEnabled || s.config.RegistryDomain == "" {768+ s.mu.RLock()
769+ cfg := s.config
770+ s.mu.RUnlock()
771+
772+ if !cfg.RegistryAuthEnabled || cfg.RegistryDomain == "" {
748 return image773 return image
749 }774 }
750 775
751 // Normalize registry domain by trimming any trailing slash776 // Normalize registry domain by trimming any trailing slash
752- reg := strings.TrimSuffix(s.config.RegistryDomain, "/")777+ reg := strings.TrimSuffix(cfg.RegistryDomain, "/")
753 778
754 // Check if image already has the registry domain prefix779 // Check if image already has the registry domain prefix
755 prefix := reg + "/"780 prefix := reg + "/"
@@ -845,7 +870,10 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
845 if !domain.IsInternalDeploy(ctx) {870 if !domain.IsInternalDeploy(ctx) {
846 return imageRef, false871 return imageRef, false
847 }872 }
848- return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true873+ s.mu.RLock()
874+ cfg := s.config
875+ s.mu.RUnlock()
876+ return rewriteToLocalRegistry(imageRef, cfg.RegistryDomain, cfg.RegistryPort), true
849 }877 }
850 878
851 // ensureImage ensures the image is available locally, pulling if needed.879 // ensureImage ensures the image is available locally, pulling if needed.
@@ -915,7 +943,11 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string
915 return false, nil943 return false, nil
916 }944 }
917 945
918- pullPolicy := normalizePullPolicy(s.config.PullPolicy)946+ s.mu.RLock()
947+ cfg := s.config
948+ s.mu.RUnlock()
949+
950+ pullPolicy := normalizePullPolicy(cfg.PullPolicy)
919 switch pullPolicy {951 switch pullPolicy {
920 case PullPolicyAlways:952 case PullPolicyAlways:
921 log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")953 log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
@@ -960,25 +992,29 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string
960 }992 }
961 993
962 func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {994 func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error {
995+ s.mu.RLock()
996+ cfg := s.config
997+ s.mu.RUnlock()
998+
963 log := zerowrap.FromCtx(ctx)999 log := zerowrap.FromCtx(ctx)
964 1000
965 switch {1001 switch {
966- case isInternal && s.config.RegistryAuthEnabled:1002+ case isInternal && cfg.RegistryAuthEnabled:
967- if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" {1003+ if cfg.InternalRegistryUsername == "" || cfg.InternalRegistryPassword == "" {
968 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")1004 return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy")
969 }1005 }
970- if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil {1006+ if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword); err != nil {
971 return log.WrapErr(err, "failed to pull image with internal auth")1007 return log.WrapErr(err, "failed to pull image with internal auth")
972 }1008 }
973 case isInternal:1009 case isInternal:
974 if err := s.runtime.PullImage(ctx, pullRef); err != nil {1010 if err := s.runtime.PullImage(ctx, pullRef); err != nil {
975 return log.WrapErr(err, "failed to pull image")1011 return log.WrapErr(err, "failed to pull image")
976 }1012 }
977- case s.config.RegistryAuthEnabled:1013+ case cfg.RegistryAuthEnabled:
978- if s.config.ServiceTokenUsername == "" || s.config.ServiceToken == "" {1014+ if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
979 return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")1015 return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
980 }1016 }
981- if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.ServiceTokenUsername, s.config.ServiceToken); err != nil {1017+ if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil {
982 return log.WrapErr(err, "failed to pull image with auth")1018 return log.WrapErr(err, "failed to pull image with auth")
983 }1019 }
984 default:1020 default:
@@ -1027,10 +1063,14 @@ func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef stri
1027 }1063 }
1028 1064
1029 func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) (map[string]string, error) {1065 func (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+
1030 log := zerowrap.FromCtx(ctx)1070 log := zerowrap.FromCtx(ctx)
1031 volumes := make(map[string]string)1071 volumes := make(map[string]string)
1032 1072
1033- if !s.config.VolumeAutoCreate {1073+ if !cfg.VolumeAutoCreate {
1034 return volumes, nil1074 return volumes, nil
1035 }1075 }
1036 1076
@@ -1041,7 +1081,7 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
1041 }1081 }
1042 1082
1043 for _, path := range volumePaths {1083 for _, path := range volumePaths {
1044- name := generateVolumeName(s.config.VolumePrefix, domainName, path)1084+ name := generateVolumeName(cfg.VolumePrefix, domainName, path)
1045 1085
1046 exists, err := s.runtime.VolumeExists(ctx, name)1086 exists, err := s.runtime.VolumeExists(ctx, name)
1047 if err != nil {1087 if err != nil {
@@ -1064,11 +1104,15 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
1064 }1104 }
1065 1105
1066 func (s *Service) getNetworkForApp(domainName string) string {1106 func (s *Service) getNetworkForApp(domainName string) string {
1067- if !s.config.NetworkIsolation {1107+ s.mu.RLock()
1108+ cfg := s.config
1109+ s.mu.RUnlock()
1110+
1111+ if !cfg.NetworkIsolation {
1068 return "bridge"1112 return "bridge"
1069 }1113 }
1070 1114
1071- for groupName, domains := range s.config.NetworkGroups {1115+ for groupName, domains := range cfg.NetworkGroups {
1072 if slices.Contains(domains, domainName) {1116 if slices.Contains(domains, domainName) {
1073 return s.generateNetworkName(groupName)1117 return s.generateNetworkName(groupName)
1074 }1118 }
@@ -1078,7 +1122,11 @@ func (s *Service) getNetworkForApp(domainName string) string {
1078 }1122 }
1079 1123
1080 func (s *Service) generateNetworkName(identifier string) string {1124 func (s *Service) generateNetworkName(identifier string) string {
1081- return fmt.Sprintf("%s-%s", s.config.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))1125+ s.mu.RLock()
1126+ cfg := s.config
1127+ s.mu.RUnlock()
1128+
1129+ return fmt.Sprintf("%s-%s", cfg.NetworkPrefix, strings.ReplaceAll(identifier, ".", "-"))
1082 }1130 }
1083 1131
1084 func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {1132 func (s *Service) createNetworkIfNeeded(ctx context.Context, networkName string) error {
@@ -1186,11 +1234,15 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
1186 // 1. Direct domain attachments (attachments[domain])1234 // 1. Direct domain attachments (attachments[domain])
1187 // 2. Network group attachments (attachments[group] where domain is in network_groups[group])1235 // 2. Network group attachments (attachments[group] where domain is in network_groups[group])
1188 func (s *Service) resolveAttachmentsForDomain(domainName string) []string {1236 func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1237+ s.mu.RLock()
1238+ cfg := s.config
1239+ s.mu.RUnlock()
1240+
1189 seen := make(map[string]bool)1241 seen := make(map[string]bool)
1190 var result []string1242 var result []string
1191 1243
1192 // First, add domain-specific attachments1244 // First, add domain-specific attachments
1193- if domainAttachments, ok := s.config.Attachments[domainName]; ok {1245+ if domainAttachments, ok := cfg.Attachments[domainName]; ok {
1194 for _, img := range domainAttachments {1246 for _, img := range domainAttachments {
1195 if !seen[img] {1247 if !seen[img] {
1196 seen[img] = true1248 seen[img] = true
@@ -1200,9 +1252,9 @@ func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1200 }1252 }
1201 1253
1202 // Then, find which network group this domain belongs to and add group attachments1254 // Then, find which network group this domain belongs to and add group attachments
1203- for groupName, domains := range s.config.NetworkGroups {1255+ for groupName, domains := range cfg.NetworkGroups {
1204 if slices.Contains(domains, domainName) {1256 if slices.Contains(domains, domainName) {
1205- if groupAttachments, ok := s.config.Attachments[groupName]; ok {1257+ if groupAttachments, ok := cfg.Attachments[groupName]; ok {
1206 for _, img := range groupAttachments {1258 for _, img := range groupAttachments {
1207 if !seen[img] {1259 if !seen[img] {
1208 seen[img] = true1260 seen[img] = true
@@ -1599,8 +1651,12 @@ func (s *Service) waitForReady(ctx context.Context, containerID string) error {
1599 }1651 }
1600 }1652 }
1601 1653
1654+ s.mu.RLock()
1655+ cfg := s.config
1656+ s.mu.RUnlock()
1657+
1602 // Additional readiness delay (configurable, default 5 seconds)1658 // Additional readiness delay (configurable, default 5 seconds)
1603- delay := s.config.ReadinessDelay1659+ delay := cfg.ReadinessDelay
1604 if delay == 0 {1660 if delay == 0 {
1605 delay = 5 * time.Second1661 delay = 5 * time.Second
1606 }1662 }
M internal/usecase/proxy/service.go +16 -6
@@ -70,9 +70,14 @@ func NewService(
7070
7171 // ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
7272 func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
73+ // Copy config under RLock to avoid data race with UpdateConfig
74+ s.mu.RLock()
75+ cfg := s.config
76+ s.mu.RUnlock()
77+
7378 // SECURITY: Limit request body size to prevent resource exhaustion.
74- if s.config.MaxBodySize > 0 {
75- r.Body = http.MaxBytesReader(w, r.Body, s.config.MaxBodySize)
79+ if cfg.MaxBodySize > 0 {
80+ r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
7681 }
7782
7883 // Enrich request context with fields for downstream logging
@@ -88,7 +93,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
8893 log := zerowrap.FromCtx(ctx)
8994
9095 // Check if this is the registry domain
91- if s.config.RegistryDomain != "" && r.Host == s.config.RegistryDomain {
96+ if cfg.RegistryDomain != "" && r.Host == cfg.RegistryDomain {
9297 log.Info().Msg("routing request to registry")
9398 s.proxyToRegistry(w, r)
9499 return
@@ -348,9 +353,14 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
348353 }
349354
350355 func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
356+ // Copy config under RLock to avoid data race with UpdateConfig
357+ s.mu.RLock()
358+ cfg := s.config
359+ s.mu.RUnlock()
360+
351361 log := zerowrap.FromCtx(r.Context())
352362
353- targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", s.config.RegistryPort))
363+ targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
354364 if err != nil {
355365 log.WrapErr(err, "failed to parse registry target URL")
356366 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -361,11 +371,11 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
361371 func(w http.ResponseWriter, _ *http.Request, err error) {
362372 // Check if this is a MaxBytesError (request body too large)
363373 if err.Error() == "http: request body too large" {
364- log.Warn().Err(err).Int("registry_port", s.config.RegistryPort).Msg("registry proxy error: request body too large")
374+ log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
365375 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
366376 return
367377 }
368- log.Error().Err(err).Int("registry_port", s.config.RegistryPort).Msg("registry proxy error")
378+ log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
369379 http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
370380 },
371381 func(resp *http.Response) error {
@@ -70,9 +70,14 @@ func NewService(
70 70
71 // ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.71 // ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend.
72 func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {72 func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
73+ // Copy config under RLock to avoid data race with UpdateConfig
74+ s.mu.RLock()
75+ cfg := s.config
76+ s.mu.RUnlock()
77+
73 // SECURITY: Limit request body size to prevent resource exhaustion.78 // SECURITY: Limit request body size to prevent resource exhaustion.
74- if s.config.MaxBodySize > 0 {79+ if cfg.MaxBodySize > 0 {
75- r.Body = http.MaxBytesReader(w, r.Body, s.config.MaxBodySize)80+ r.Body = http.MaxBytesReader(w, r.Body, cfg.MaxBodySize)
76 }81 }
77 82
78 // Enrich request context with fields for downstream logging83 // Enrich request context with fields for downstream logging
@@ -88,7 +93,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) {
88 log := zerowrap.FromCtx(ctx)93 log := zerowrap.FromCtx(ctx)
89 94
90 // Check if this is the registry domain95 // Check if this is the registry domain
91- if s.config.RegistryDomain != "" && r.Host == s.config.RegistryDomain {96+ if cfg.RegistryDomain != "" && r.Host == cfg.RegistryDomain {
92 log.Info().Msg("routing request to registry")97 log.Info().Msg("routing request to registry")
93 s.proxyToRegistry(w, r)98 s.proxyToRegistry(w, r)
94 return99 return
@@ -348,9 +353,14 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
348 }353 }
349 354
350 func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {355 func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
356+ // Copy config under RLock to avoid data race with UpdateConfig
357+ s.mu.RLock()
358+ cfg := s.config
359+ s.mu.RUnlock()
360+
351 log := zerowrap.FromCtx(r.Context())361 log := zerowrap.FromCtx(r.Context())
352 362
353- targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", s.config.RegistryPort))363+ targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
354 if err != nil {364 if err != nil {
355 log.WrapErr(err, "failed to parse registry target URL")365 log.WrapErr(err, "failed to parse registry target URL")
356 http.Error(w, "Internal Server Error", http.StatusInternalServerError)366 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
@@ -361,11 +371,11 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
361 func(w http.ResponseWriter, _ *http.Request, err error) {371 func(w http.ResponseWriter, _ *http.Request, err error) {
362 // Check if this is a MaxBytesError (request body too large)372 // Check if this is a MaxBytesError (request body too large)
363 if err.Error() == "http: request body too large" {373 if err.Error() == "http: request body too large" {
364- log.Warn().Err(err).Int("registry_port", s.config.RegistryPort).Msg("registry proxy error: request body too large")374+ log.Warn().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error: request body too large")
365 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)375 http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge)
366 return376 return
367 }377 }
368- log.Error().Err(err).Int("registry_port", s.config.RegistryPort).Msg("registry proxy error")378+ log.Error().Err(err).Int("registry_port", cfg.RegistryPort).Msg("registry proxy error")
369 http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)379 http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable)
370 },380 },
371 func(resp *http.Response) error {381 func(resp *http.Response) error {

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