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) { | |||
| 188 | 188 | if handler.CanHandle(event.Type) { | |
| 189 | 189 | start := time.Now() | |
| 190 | 190 | ||
| 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(). | |
| 202 | 219 | Str(zerowrap.FieldLayer, "adapter"). | |
| 203 | 220 | Str(zerowrap.FieldAdapter, "eventbus"). | |
| 204 | 221 | Str("event_id", event.ID). | |
| 205 | 222 | Str(zerowrap.FieldEvent, string(event.Type)). | |
| 206 | 223 | Str(zerowrap.FieldHandler, fmt.Sprintf("%T", handler)). | |
| 207 | 224 | Dur(zerowrap.FieldDuration, time.Since(start)). | |
| 208 | - | Msg("event handled successfully") | |
| 225 | + | Msg("handler timeout after 30s") | |
| 209 | 226 | } | |
| 210 | 227 | } | |
| 211 | 228 | } | |
| @@ -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 | |||
| 372 | 372 | } | |
| 373 | 373 | ||
| 374 | 374 | // 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) | |
| 388 | 376 | ||
| 389 | 377 | if err := s.runtime.RemoveContainer(ctx, containerID, force); err != nil { | |
| 390 | 378 | return log.WrapErr(err, "failed to remove container") | |
| 391 | 379 | } | |
| 392 | 380 | ||
| 393 | 381 | // Cleanup volumes | |
| 394 | - | if containerDomain != "" && !s.config.VolumePreserve { | |
| 382 | + | if containerDomain != "" && !cfg.VolumePreserve { | |
| 395 | 383 | if err := s.cleanupVolumesForDomain(ctx, containerDomain); err != nil { | |
| 396 | 384 | log.WrapErrWithFields(err, "failed to cleanup volumes", map[string]any{"domain": containerDomain}) | |
| 397 | 385 | } | |
| 398 | 386 | } | |
| 399 | 387 | ||
| 400 | 388 | // 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) | |
| 414 | 390 | ||
| 415 | 391 | // Remove from tracking | |
| 416 | 392 | s.mu.Lock() | |
| @@ -435,7 +411,7 @@ func (s *Service) Remove(ctx context.Context, containerID string, force bool) er | |||
| 435 | 411 | // domain ever deployed), and this is the safer choice for correctness. | |
| 436 | 412 | ||
| 437 | 413 | // Cleanup network | |
| 438 | - | if removedDomain != "" && s.config.NetworkIsolation { | |
| 414 | + | if removedDomain != "" && cfg.NetworkIsolation { | |
| 439 | 415 | networkName := s.getNetworkForApp(removedDomain) | |
| 440 | 416 | if err := s.cleanupNetworkIfEmpty(ctx, networkName); err != nil { | |
| 441 | 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 | 421 | 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 | 461 | // Get retrieves a container by domain. | |
| 449 | 462 | func (s *Service) Get(_ context.Context, domainName string) (*domain.Container, bool) { | |
| 450 | 463 | s.mu.RLock() | |
| @@ -514,10 +527,14 @@ func (s *Service) ListRoutesWithDetails(ctx context.Context) []domain.RouteInfo | |||
| 514 | 527 | // stripRegistryPrefix removes the configured registry domain prefix from an image reference. | |
| 515 | 528 | // For example, "reg.example.com/myapp:latest" becomes "myapp:latest" if registry domain is "reg.example.com". | |
| 516 | 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 | 535 | return image | |
| 519 | 536 | } | |
| 520 | - | prefix := strings.TrimSuffix(s.config.RegistryDomain, "/") + "/" | |
| 537 | + | prefix := strings.TrimSuffix(cfg.RegistryDomain, "/") + "/" | |
| 521 | 538 | if strings.HasPrefix(image, prefix) { | |
| 522 | 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 | 549 | // ListNetworks returns Gordon-managed networks. | |
| 533 | 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 | 555 | networks, err := s.runtime.ListNetworks(ctx) | |
| 535 | 556 | if err != nil { | |
| 536 | 557 | return nil, err | |
| @@ -538,7 +559,7 @@ func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, erro | |||
| 538 | 559 | ||
| 539 | 560 | var filtered []*domain.NetworkInfo | |
| 540 | 561 | for _, network := range networks { | |
| 541 | - | if strings.HasPrefix(network.Name, s.config.NetworkPrefix+"-") { | |
| 562 | + | if strings.HasPrefix(network.Name, cfg.NetworkPrefix+"-") { | |
| 542 | 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 | 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 | 773 | return image | |
| 749 | 774 | } | |
| 750 | 775 | ||
| 751 | 776 | // Normalize registry domain by trimming any trailing slash | |
| 752 | - | reg := strings.TrimSuffix(s.config.RegistryDomain, "/") | |
| 777 | + | reg := strings.TrimSuffix(cfg.RegistryDomain, "/") | |
| 753 | 778 | ||
| 754 | 779 | // Check if image already has the registry domain prefix | |
| 755 | 780 | prefix := reg + "/" | |
| @@ -845,7 +870,10 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string | |||
| 845 | 870 | if !domain.IsInternalDeploy(ctx) { | |
| 846 | 871 | return imageRef, false | |
| 847 | 872 | } | |
| 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 | |
| 849 | 877 | } | |
| 850 | 878 | ||
| 851 | 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 | 943 | 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 | 951 | switch pullPolicy { | |
| 920 | 952 | case PullPolicyAlways: | |
| 921 | 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 | 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 | 999 | log := zerowrap.FromCtx(ctx) | |
| 964 | 1000 | ||
| 965 | 1001 | 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 == "" { | |
| 968 | 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 | 1007 | return log.WrapErr(err, "failed to pull image with internal auth") | |
| 972 | 1008 | } | |
| 973 | 1009 | case isInternal: | |
| 974 | 1010 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | |
| 975 | 1011 | return log.WrapErr(err, "failed to pull image") | |
| 976 | 1012 | } | |
| 977 | - | case s.config.RegistryAuthEnabled: | |
| 978 | - | if s.config.ServiceTokenUsername == "" || s.config.ServiceToken == "" { | |
| 1013 | + | case cfg.RegistryAuthEnabled: | |
| 1014 | + | if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" { | |
| 979 | 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 | 1018 | return log.WrapErr(err, "failed to pull image with auth") | |
| 983 | 1019 | } | |
| 984 | 1020 | default: | |
| @@ -1027,10 +1063,14 @@ func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef stri | |||
| 1027 | 1063 | } | |
| 1028 | 1064 | ||
| 1029 | 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 | 1070 | log := zerowrap.FromCtx(ctx) | |
| 1031 | 1071 | volumes := make(map[string]string) | |
| 1032 | 1072 | ||
| 1033 | - | if !s.config.VolumeAutoCreate { | |
| 1073 | + | if !cfg.VolumeAutoCreate { | |
| 1034 | 1074 | 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 | 1083 | for _, path := range volumePaths { | |
| 1044 | - | name := generateVolumeName(s.config.VolumePrefix, domainName, path) | |
| 1084 | + | name := generateVolumeName(cfg.VolumePrefix, domainName, path) | |
| 1045 | 1085 | ||
| 1046 | 1086 | exists, err := s.runtime.VolumeExists(ctx, name) | |
| 1047 | 1087 | if err != nil { | |
| @@ -1064,11 +1104,15 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string) | |||
| 1064 | 1104 | } | |
| 1065 | 1105 | ||
| 1066 | 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 | 1112 | return "bridge" | |
| 1069 | 1113 | } | |
| 1070 | 1114 | ||
| 1071 | - | for groupName, domains := range s.config.NetworkGroups { | |
| 1115 | + | for groupName, domains := range cfg.NetworkGroups { | |
| 1072 | 1116 | if slices.Contains(domains, domainName) { | |
| 1073 | 1117 | return s.generateNetworkName(groupName) | |
| 1074 | 1118 | } | |
| @@ -1078,7 +1122,11 @@ func (s *Service) getNetworkForApp(domainName string) string { | |||
| 1078 | 1122 | } | |
| 1079 | 1123 | ||
| 1080 | 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 | 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 | 1234 | // 1. Direct domain attachments (attachments[domain]) | |
| 1187 | 1235 | // 2. Network group attachments (attachments[group] where domain is in network_groups[group]) | |
| 1188 | 1236 | func (s *Service) resolveAttachmentsForDomain(domainName string) []string { | |
| 1237 | + | s.mu.RLock() | |
| 1238 | + | cfg := s.config | |
| 1239 | + | s.mu.RUnlock() | |
| 1240 | + | ||
| 1189 | 1241 | seen := make(map[string]bool) | |
| 1190 | 1242 | var result []string | |
| 1191 | 1243 | ||
| 1192 | 1244 | // First, add domain-specific attachments | |
| 1193 | - | if domainAttachments, ok := s.config.Attachments[domainName]; ok { | |
| 1245 | + | if domainAttachments, ok := cfg.Attachments[domainName]; ok { | |
| 1194 | 1246 | for _, img := range domainAttachments { | |
| 1195 | 1247 | if !seen[img] { | |
| 1196 | 1248 | seen[img] = true | |
| @@ -1200,9 +1252,9 @@ func (s *Service) resolveAttachmentsForDomain(domainName string) []string { | |||
| 1200 | 1252 | } | |
| 1201 | 1253 | ||
| 1202 | 1254 | // 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 | 1256 | if slices.Contains(domains, domainName) { | |
| 1205 | - | if groupAttachments, ok := s.config.Attachments[groupName]; ok { | |
| 1257 | + | if groupAttachments, ok := cfg.Attachments[groupName]; ok { | |
| 1206 | 1258 | for _, img := range groupAttachments { | |
| 1207 | 1259 | if !seen[img] { | |
| 1208 | 1260 | 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 | 1658 | // Additional readiness delay (configurable, default 5 seconds) | |
| 1603 | - | delay := s.config.ReadinessDelay | |
| 1659 | + | delay := cfg.ReadinessDelay | |
| 1604 | 1660 | if delay == 0 { | |
| 1605 | 1661 | delay = 5 * time.Second | |
| 1606 | 1662 | } | |
| @@ -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 cleanup | 374 | // Find domain and attachment IDs for cleanup | ||
| 375 | - | var containerDomain string | 375 | + | 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 volumes | 381 | // 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 containers | 388 | // 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 tracking | 391 | // 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 network | 413 | // 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 nil | 421 | 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 image | 535 | 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, err | 557 | return nil, err | ||
| @@ -538,7 +559,7 @@ func (s *Service) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, erro | |||||
| 538 | 559 | ||||
| 539 | var filtered []*domain.NetworkInfo | 560 | 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 image | 773 | return image | ||
| 749 | } | 774 | } | ||
| 750 | 775 | ||||
| 751 | // Normalize registry domain by trimming any trailing slash | 776 | // 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 prefix | 779 | // 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, false | 871 | return imageRef, false | ||
| 847 | } | 872 | } | ||
| 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 | |||
| 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, nil | 943 | 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, nil | 1074 | 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 []string | 1242 | var result []string | ||
| 1191 | 1243 | ||||
| 1192 | // First, add domain-specific attachments | 1244 | // 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] = true | 1248 | 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 attachments | 1254 | // 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] = true | 1260 | 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.ReadinessDelay | 1659 | + | delay := cfg.ReadinessDelay |
| 1604 | if delay == 0 { | 1660 | if delay == 0 { | ||
| 1605 | delay = 5 * time.Second | 1661 | delay = 5 * time.Second | ||
| 1606 | } | 1662 | } | ||
M internal/usecase/proxy/service.go +16 -6
| @@ -70,9 +70,14 @@ func NewService( | |||
| 70 | 70 | ||
| 71 | 71 | // ServeHTTP handles incoming HTTP requests and proxies them to the appropriate backend. | |
| 72 | 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 | 78 | // 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) | |
| 76 | 81 | } | |
| 77 | 82 | ||
| 78 | 83 | // Enrich request context with fields for downstream logging | |
| @@ -88,7 +93,7 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { | |||
| 88 | 93 | log := zerowrap.FromCtx(ctx) | |
| 89 | 94 | ||
| 90 | 95 | // 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 | 97 | log.Info().Msg("routing request to registry") | |
| 93 | 98 | s.proxyToRegistry(w, r) | |
| 94 | 99 | return | |
| @@ -348,9 +353,14 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target * | |||
| 348 | 353 | } | |
| 349 | 354 | ||
| 350 | 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 | 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 | 364 | if err != nil { | |
| 355 | 365 | log.WrapErr(err, "failed to parse registry target URL") | |
| 356 | 366 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| @@ -361,11 +371,11 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 361 | 371 | func(w http.ResponseWriter, _ *http.Request, err error) { | |
| 362 | 372 | // Check if this is a MaxBytesError (request body too large) | |
| 363 | 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 | 375 | http.Error(w, "Request Entity Too Large", http.StatusRequestEntityTooLarge) | |
| 366 | 376 | 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 | 379 | http.Error(w, "Registry Unavailable", http.StatusServiceUnavailable) | |
| 370 | 380 | }, | |
| 371 | 381 | 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 logging | 83 | // 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 domain | 95 | // 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 | return | 99 | 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 | return | 376 | 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 { | ||