fix(container): atomic config snapshot and sync before restart attachment check
- commit
- a315454db296e08d4373bebfdcfdb5cf105292bc
- author
- brice · · 2026-03-15 18:07 UTC
- parent
- d64d62f
- signature
- unsigned
6 files · +246 -32
M internal/boundaries/out/attachment_config.go +11 -0
| @@ -1,5 +1,12 @@ | |||
| 1 | 1 | package out | |
| 2 | 2 | ||
| 3 | + | // AttachmentConfigSnapshot holds a consistent point-in-time snapshot of | |
| 4 | + | // attachment and network group configuration. | |
| 5 | + | type AttachmentConfigSnapshot struct { | |
| 6 | + | Attachments map[string][]string // domain/group -> []image | |
| 7 | + | NetworkGroups map[string][]string // group -> []domain | |
| 8 | + | } | |
| 9 | + | ||
| 3 | 10 | // AttachmentConfigProvider is a read-only view into the live config service | |
| 4 | 11 | // for attachment and network group resolution. It decouples the container | |
| 5 | 12 | // service from the full ConfigService interface while ensuring fresh reads | |
| @@ -9,6 +16,10 @@ package out | |||
| 9 | 16 | // callers cannot mutate provider state. Each call must produce an independent | |
| 10 | 17 | // map with independently allocated slices. | |
| 11 | 18 | type AttachmentConfigProvider interface { | |
| 19 | + | // GetAttachmentConfig returns a consistent snapshot of attachments and | |
| 20 | + | // network groups, read under a single lock to prevent cross-field races. | |
| 21 | + | GetAttachmentConfig() AttachmentConfigSnapshot | |
| 22 | + | ||
| 12 | 23 | // GetAttachments returns a deep copy of the current attachment config: domain/group -> []image. | |
| 13 | 24 | GetAttachments() map[string][]string | |
| 14 | 25 | ||
| @@ -1,5 +1,12 @@ | |||||
| 1 | package out | 1 | package out | ||
| 2 | 2 | ||||
| 3 | + | // AttachmentConfigSnapshot holds a consistent point-in-time snapshot of | |||
| 4 | + | // attachment and network group configuration. | |||
| 5 | + | type AttachmentConfigSnapshot struct { | |||
| 6 | + | Attachments map[string][]string // domain/group -> []image | |||
| 7 | + | NetworkGroups map[string][]string // group -> []domain | |||
| 8 | + | } | |||
| 9 | + | ||||
| 3 | // AttachmentConfigProvider is a read-only view into the live config service | 10 | // AttachmentConfigProvider is a read-only view into the live config service | ||
| 4 | // for attachment and network group resolution. It decouples the container | 11 | // for attachment and network group resolution. It decouples the container | ||
| 5 | // service from the full ConfigService interface while ensuring fresh reads | 12 | // service from the full ConfigService interface while ensuring fresh reads | ||
| @@ -9,6 +16,10 @@ package out | |||||
| 9 | // callers cannot mutate provider state. Each call must produce an independent | 16 | // callers cannot mutate provider state. Each call must produce an independent | ||
| 10 | // map with independently allocated slices. | 17 | // map with independently allocated slices. | ||
| 11 | type AttachmentConfigProvider interface { | 18 | type AttachmentConfigProvider interface { | ||
| 19 | + | // GetAttachmentConfig returns a consistent snapshot of attachments and | |||
| 20 | + | // network groups, read under a single lock to prevent cross-field races. | |||
| 21 | + | GetAttachmentConfig() AttachmentConfigSnapshot | |||
| 22 | + | ||||
| 12 | // GetAttachments returns a deep copy of the current attachment config: domain/group -> []image. | 23 | // GetAttachments returns a deep copy of the current attachment config: domain/group -> []image. | ||
| 13 | GetAttachments() map[string][]string | 24 | GetAttachments() map[string][]string | ||
| 14 | 25 | ||||
M internal/boundaries/out/mocks/mock_attachment_config_provider.go +45 -0
| @@ -5,6 +5,7 @@ | |||
| 5 | 5 | package mocks | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 8 | 9 | mock "github.com/stretchr/testify/mock" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| @@ -35,6 +36,50 @@ func (_m *MockAttachmentConfigProvider) EXPECT() *MockAttachmentConfigProvider_E | |||
| 35 | 36 | return &MockAttachmentConfigProvider_Expecter{mock: &_m.Mock} | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 39 | + | // GetAttachmentConfig provides a mock function for the type MockAttachmentConfigProvider | |
| 40 | + | func (_mock *MockAttachmentConfigProvider) GetAttachmentConfig() out.AttachmentConfigSnapshot { | |
| 41 | + | ret := _mock.Called() | |
| 42 | + | ||
| 43 | + | if len(ret) == 0 { | |
| 44 | + | panic("no return value specified for GetAttachmentConfig") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | var r0 out.AttachmentConfigSnapshot | |
| 48 | + | if returnFunc, ok := ret.Get(0).(func() out.AttachmentConfigSnapshot); ok { | |
| 49 | + | r0 = returnFunc() | |
| 50 | + | } else { | |
| 51 | + | r0 = ret.Get(0).(out.AttachmentConfigSnapshot) | |
| 52 | + | } | |
| 53 | + | return r0 | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // MockAttachmentConfigProvider_GetAttachmentConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachmentConfig' | |
| 57 | + | type MockAttachmentConfigProvider_GetAttachmentConfig_Call struct { | |
| 58 | + | *mock.Call | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // GetAttachmentConfig is a helper method to define mock.On call | |
| 62 | + | func (_e *MockAttachmentConfigProvider_Expecter) GetAttachmentConfig() *MockAttachmentConfigProvider_GetAttachmentConfig_Call { | |
| 63 | + | return &MockAttachmentConfigProvider_GetAttachmentConfig_Call{Call: _e.mock.On("GetAttachmentConfig")} | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | func (_c *MockAttachmentConfigProvider_GetAttachmentConfig_Call) Run(run func()) *MockAttachmentConfigProvider_GetAttachmentConfig_Call { | |
| 67 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 68 | + | run() | |
| 69 | + | }) | |
| 70 | + | return _c | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (_c *MockAttachmentConfigProvider_GetAttachmentConfig_Call) Return(attachmentConfigSnapshot out.AttachmentConfigSnapshot) *MockAttachmentConfigProvider_GetAttachmentConfig_Call { | |
| 74 | + | _c.Call.Return(attachmentConfigSnapshot) | |
| 75 | + | return _c | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func (_c *MockAttachmentConfigProvider_GetAttachmentConfig_Call) RunAndReturn(run func() out.AttachmentConfigSnapshot) *MockAttachmentConfigProvider_GetAttachmentConfig_Call { | |
| 79 | + | _c.Call.Return(run) | |
| 80 | + | return _c | |
| 81 | + | } | |
| 82 | + | ||
| 38 | 83 | // GetAttachments provides a mock function for the type MockAttachmentConfigProvider | |
| 39 | 84 | func (_mock *MockAttachmentConfigProvider) GetAttachments() map[string][]string { | |
| 40 | 85 | ret := _mock.Called() | |
| @@ -5,6 +5,7 @@ | |||||
| 5 | package mocks | 5 | package mocks | ||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 8 | mock "github.com/stretchr/testify/mock" | 9 | mock "github.com/stretchr/testify/mock" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| @@ -35,6 +36,50 @@ func (_m *MockAttachmentConfigProvider) EXPECT() *MockAttachmentConfigProvider_E | |||||
| 35 | return &MockAttachmentConfigProvider_Expecter{mock: &_m.Mock} | 36 | return &MockAttachmentConfigProvider_Expecter{mock: &_m.Mock} | ||
| 36 | } | 37 | } | ||
| 37 | 38 | ||||
| 39 | + | // GetAttachmentConfig provides a mock function for the type MockAttachmentConfigProvider | |||
| 40 | + | func (_mock *MockAttachmentConfigProvider) GetAttachmentConfig() out.AttachmentConfigSnapshot { | |||
| 41 | + | ret := _mock.Called() | |||
| 42 | + | ||||
| 43 | + | if len(ret) == 0 { | |||
| 44 | + | panic("no return value specified for GetAttachmentConfig") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | var r0 out.AttachmentConfigSnapshot | |||
| 48 | + | if returnFunc, ok := ret.Get(0).(func() out.AttachmentConfigSnapshot); ok { | |||
| 49 | + | r0 = returnFunc() | |||
| 50 | + | } else { | |||
| 51 | + | r0 = ret.Get(0).(out.AttachmentConfigSnapshot) | |||
| 52 | + | } | |||
| 53 | + | return r0 | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | // MockAttachmentConfigProvider_GetAttachmentConfig_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachmentConfig' | |||
| 57 | + | type MockAttachmentConfigProvider_GetAttachmentConfig_Call struct { | |||
| 58 | + | *mock.Call | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // GetAttachmentConfig is a helper method to define mock.On call | |||
| 62 | + | func (_e *MockAttachmentConfigProvider_Expecter) GetAttachmentConfig() *MockAttachmentConfigProvider_GetAttachmentConfig_Call { | |||
| 63 | + | return &MockAttachmentConfigProvider_GetAttachmentConfig_Call{Call: _e.mock.On("GetAttachmentConfig")} | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | func (_c *MockAttachmentConfigProvider_GetAttachmentConfig_Call) Run(run func()) *MockAttachmentConfigProvider_GetAttachmentConfig_Call { | |||
| 67 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 68 | + | run() | |||
| 69 | + | }) | |||
| 70 | + | return _c | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (_c *MockAttachmentConfigProvider_GetAttachmentConfig_Call) Return(attachmentConfigSnapshot out.AttachmentConfigSnapshot) *MockAttachmentConfigProvider_GetAttachmentConfig_Call { | |||
| 74 | + | _c.Call.Return(attachmentConfigSnapshot) | |||
| 75 | + | return _c | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func (_c *MockAttachmentConfigProvider_GetAttachmentConfig_Call) RunAndReturn(run func() out.AttachmentConfigSnapshot) *MockAttachmentConfigProvider_GetAttachmentConfig_Call { | |||
| 79 | + | _c.Call.Return(run) | |||
| 80 | + | return _c | |||
| 81 | + | } | |||
| 82 | + | ||||
| 38 | // GetAttachments provides a mock function for the type MockAttachmentConfigProvider | 83 | // GetAttachments provides a mock function for the type MockAttachmentConfigProvider | ||
| 39 | func (_mock *MockAttachmentConfigProvider) GetAttachments() map[string][]string { | 84 | func (_mock *MockAttachmentConfigProvider) GetAttachments() map[string][]string { | ||
| 40 | ret := _mock.Called() | 85 | ret := _mock.Called() | ||
M internal/usecase/config/service.go +22 -0
| @@ -860,6 +860,28 @@ func (s *Service) GetNetworkGroups() map[string][]string { | |||
| 860 | 860 | return result | |
| 861 | 861 | } | |
| 862 | 862 | ||
| 863 | + | // GetAttachmentConfig returns a consistent snapshot of attachments and network | |
| 864 | + | // groups under a single lock, preventing cross-field races during config reloads. | |
| 865 | + | func (s *Service) GetAttachmentConfig() out.AttachmentConfigSnapshot { | |
| 866 | + | s.mu.RLock() | |
| 867 | + | defer s.mu.RUnlock() | |
| 868 | + | ||
| 869 | + | attachments := make(map[string][]string, len(s.config.Attachments)) | |
| 870 | + | for k, v := range s.config.Attachments { | |
| 871 | + | attachments[k] = append([]string{}, v...) | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | networkGroups := make(map[string][]string, len(s.config.NetworkGroups)) | |
| 875 | + | for k, v := range s.config.NetworkGroups { | |
| 876 | + | networkGroups[k] = append([]string{}, v...) | |
| 877 | + | } | |
| 878 | + | ||
| 879 | + | return out.AttachmentConfigSnapshot{ | |
| 880 | + | Attachments: attachments, | |
| 881 | + | NetworkGroups: networkGroups, | |
| 882 | + | } | |
| 883 | + | } | |
| 884 | + | ||
| 863 | 885 | // GetAttachments returns attachment configuration. | |
| 864 | 886 | // Deprecated: Use GetAllAttachments instead. | |
| 865 | 887 | func (s *Service) GetAttachments() map[string][]string { | |
| @@ -860,6 +860,28 @@ func (s *Service) GetNetworkGroups() map[string][]string { | |||||
| 860 | return result | 860 | return result | ||
| 861 | } | 861 | } | ||
| 862 | 862 | ||||
| 863 | + | // GetAttachmentConfig returns a consistent snapshot of attachments and network | |||
| 864 | + | // groups under a single lock, preventing cross-field races during config reloads. | |||
| 865 | + | func (s *Service) GetAttachmentConfig() out.AttachmentConfigSnapshot { | |||
| 866 | + | s.mu.RLock() | |||
| 867 | + | defer s.mu.RUnlock() | |||
| 868 | + | ||||
| 869 | + | attachments := make(map[string][]string, len(s.config.Attachments)) | |||
| 870 | + | for k, v := range s.config.Attachments { | |||
| 871 | + | attachments[k] = append([]string{}, v...) | |||
| 872 | + | } | |||
| 873 | + | ||||
| 874 | + | networkGroups := make(map[string][]string, len(s.config.NetworkGroups)) | |||
| 875 | + | for k, v := range s.config.NetworkGroups { | |||
| 876 | + | networkGroups[k] = append([]string{}, v...) | |||
| 877 | + | } | |||
| 878 | + | ||||
| 879 | + | return out.AttachmentConfigSnapshot{ | |||
| 880 | + | Attachments: attachments, | |||
| 881 | + | NetworkGroups: networkGroups, | |||
| 882 | + | } | |||
| 883 | + | } | |||
| 884 | + | ||||
| 863 | // GetAttachments returns attachment configuration. | 885 | // GetAttachments returns attachment configuration. | ||
| 864 | // Deprecated: Use GetAllAttachments instead. | 886 | // Deprecated: Use GetAllAttachments instead. | ||
| 865 | func (s *Service) GetAttachments() map[string][]string { | 887 | func (s *Service) GetAttachments() map[string][]string { | ||
M internal/usecase/config/service_test.go +30 -0
| @@ -487,6 +487,36 @@ func TestService_GetAttachments(t *testing.T) { | |||
| 487 | 487 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"]) | |
| 488 | 488 | } | |
| 489 | 489 | ||
| 490 | + | func TestService_GetAttachmentConfig(t *testing.T) { | |
| 491 | + | v := viper.New() | |
| 492 | + | v.Set("attachments", map[string]interface{}{ | |
| 493 | + | "app.example.com": []interface{}{"redis:latest", "postgres:18"}, | |
| 494 | + | }) | |
| 495 | + | v.Set("network_groups", map[string]interface{}{ | |
| 496 | + | "shared": []interface{}{"app.example.com", "api.example.com"}, | |
| 497 | + | }) | |
| 498 | + | ||
| 499 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 500 | + | svc := NewService(v, eventBus) | |
| 501 | + | ctx := testContext() | |
| 502 | + | ||
| 503 | + | _ = svc.Load(ctx) | |
| 504 | + | ||
| 505 | + | snapshot := svc.GetAttachmentConfig() | |
| 506 | + | ||
| 507 | + | assert.Len(t, snapshot.Attachments, 1) | |
| 508 | + | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, snapshot.Attachments["app.example.com"]) | |
| 509 | + | assert.Len(t, snapshot.NetworkGroups, 1) | |
| 510 | + | assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, snapshot.NetworkGroups["shared"]) | |
| 511 | + | ||
| 512 | + | snapshot.Attachments["app.example.com"][0] = "mutated" | |
| 513 | + | snapshot.NetworkGroups["shared"][0] = "mutated.example.com" | |
| 514 | + | ||
| 515 | + | freshSnapshot := svc.GetAttachmentConfig() | |
| 516 | + | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, freshSnapshot.Attachments["app.example.com"]) | |
| 517 | + | assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, freshSnapshot.NetworkGroups["shared"]) | |
| 518 | + | } | |
| 519 | + | ||
| 490 | 520 | func TestService_GetAllAttachments(t *testing.T) { | |
| 491 | 521 | t.Run("returns all attachments", func(t *testing.T) { | |
| 492 | 522 | v := viper.New() | |
| @@ -487,6 +487,36 @@ func TestService_GetAttachments(t *testing.T) { | |||||
| 487 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"]) | 487 | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"]) | ||
| 488 | } | 488 | } | ||
| 489 | 489 | ||||
| 490 | + | func TestService_GetAttachmentConfig(t *testing.T) { | |||
| 491 | + | v := viper.New() | |||
| 492 | + | v.Set("attachments", map[string]interface{}{ | |||
| 493 | + | "app.example.com": []interface{}{"redis:latest", "postgres:18"}, | |||
| 494 | + | }) | |||
| 495 | + | v.Set("network_groups", map[string]interface{}{ | |||
| 496 | + | "shared": []interface{}{"app.example.com", "api.example.com"}, | |||
| 497 | + | }) | |||
| 498 | + | ||||
| 499 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 500 | + | svc := NewService(v, eventBus) | |||
| 501 | + | ctx := testContext() | |||
| 502 | + | ||||
| 503 | + | _ = svc.Load(ctx) | |||
| 504 | + | ||||
| 505 | + | snapshot := svc.GetAttachmentConfig() | |||
| 506 | + | ||||
| 507 | + | assert.Len(t, snapshot.Attachments, 1) | |||
| 508 | + | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, snapshot.Attachments["app.example.com"]) | |||
| 509 | + | assert.Len(t, snapshot.NetworkGroups, 1) | |||
| 510 | + | assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, snapshot.NetworkGroups["shared"]) | |||
| 511 | + | ||||
| 512 | + | snapshot.Attachments["app.example.com"][0] = "mutated" | |||
| 513 | + | snapshot.NetworkGroups["shared"][0] = "mutated.example.com" | |||
| 514 | + | ||||
| 515 | + | freshSnapshot := svc.GetAttachmentConfig() | |||
| 516 | + | assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, freshSnapshot.Attachments["app.example.com"]) | |||
| 517 | + | assert.ElementsMatch(t, []string{"app.example.com", "api.example.com"}, freshSnapshot.NetworkGroups["shared"]) | |||
| 518 | + | } | |||
| 519 | + | ||||
| 490 | func TestService_GetAllAttachments(t *testing.T) { | 520 | func TestService_GetAllAttachments(t *testing.T) { | ||
| 491 | t.Run("returns all attachments", func(t *testing.T) { | 521 | t.Run("returns all attachments", func(t *testing.T) { | ||
| 492 | v := viper.New() | 522 | v := viper.New() | ||
M internal/usecase/container/service.go +21 -4
| @@ -851,11 +851,26 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment | |||
| 851 | 851 | return domain.ErrContainerNotFound | |
| 852 | 852 | } | |
| 853 | 853 | ||
| 854 | - | // When attachments are requested, check if configured attachments are deployed. | |
| 854 | + | // When attachments are requested, sync runtime state first to get accurate | |
| 855 | + | // attachment tracking, then check if configured attachments are deployed. | |
| 855 | 856 | // If fewer attachment containers are tracked than configured, fail early with | |
| 856 | 857 | // guidance rather than restarting the main container into a broken state | |
| 857 | 858 | // (e.g., missing database hostname). | |
| 858 | 859 | if withAttachments { | |
| 860 | + | if syncErr := s.SyncContainers(ctx); syncErr != nil { | |
| 861 | + | log.Warn().Err(syncErr).Msg("failed to sync container state before attachment check") | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | // Re-read attachment IDs after sync | |
| 865 | + | s.mu.RLock() | |
| 866 | + | if freshAttachments := s.attachments[domainName]; len(freshAttachments) > 0 { | |
| 867 | + | attachmentIDs = make([]string, len(freshAttachments)) | |
| 868 | + | copy(attachmentIDs, freshAttachments) | |
| 869 | + | } else { | |
| 870 | + | attachmentIDs = nil | |
| 871 | + | } | |
| 872 | + | s.mu.RUnlock() | |
| 873 | + | ||
| 859 | 874 | configuredAttachments := s.resolveAttachmentsForDomain(domainName) | |
| 860 | 875 | if len(configuredAttachments) > 0 && len(attachmentIDs) < len(configuredAttachments) { | |
| 861 | 876 | missing := len(configuredAttachments) - len(attachmentIDs) | |
| @@ -1806,7 +1821,8 @@ func (s *Service) getNetworkForApp(domainName string) string { | |||
| 1806 | 1821 | var networkGroups map[string][]string | |
| 1807 | 1822 | ||
| 1808 | 1823 | if s.configProvider != nil { | |
| 1809 | - | networkGroups = s.configProvider.GetNetworkGroups() | |
| 1824 | + | snap := s.configProvider.GetAttachmentConfig() | |
| 1825 | + | networkGroups = snap.NetworkGroups | |
| 1810 | 1826 | s.mu.RLock() | |
| 1811 | 1827 | networkIsolation = s.config.NetworkIsolation | |
| 1812 | 1828 | s.mu.RUnlock() | |
| @@ -1993,8 +2009,9 @@ func (s *Service) resolveAttachmentsForDomain(domainName string) []string { | |||
| 1993 | 2009 | var networkGroups map[string][]string | |
| 1994 | 2010 | ||
| 1995 | 2011 | if s.configProvider != nil { | |
| 1996 | - | attachments = s.configProvider.GetAttachments() | |
| 1997 | - | networkGroups = s.configProvider.GetNetworkGroups() | |
| 2012 | + | snap := s.configProvider.GetAttachmentConfig() | |
| 2013 | + | attachments = snap.Attachments | |
| 2014 | + | networkGroups = snap.NetworkGroups | |
| 1998 | 2015 | } else { | |
| 1999 | 2016 | s.mu.RLock() | |
| 2000 | 2017 | attachments = s.config.Attachments | |
| @@ -851,11 +851,26 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment | |||||
| 851 | return domain.ErrContainerNotFound | 851 | return domain.ErrContainerNotFound | ||
| 852 | } | 852 | } | ||
| 853 | 853 | ||||
| 854 | - | // When attachments are requested, check if configured attachments are deployed. | 854 | + | // When attachments are requested, sync runtime state first to get accurate |
| 855 | + | // attachment tracking, then check if configured attachments are deployed. | |||
| 855 | // If fewer attachment containers are tracked than configured, fail early with | 856 | // If fewer attachment containers are tracked than configured, fail early with | ||
| 856 | // guidance rather than restarting the main container into a broken state | 857 | // guidance rather than restarting the main container into a broken state | ||
| 857 | // (e.g., missing database hostname). | 858 | // (e.g., missing database hostname). | ||
| 858 | if withAttachments { | 859 | if withAttachments { | ||
| 860 | + | if syncErr := s.SyncContainers(ctx); syncErr != nil { | |||
| 861 | + | log.Warn().Err(syncErr).Msg("failed to sync container state before attachment check") | |||
| 862 | + | } | |||
| 863 | + | ||||
| 864 | + | // Re-read attachment IDs after sync | |||
| 865 | + | s.mu.RLock() | |||
| 866 | + | if freshAttachments := s.attachments[domainName]; len(freshAttachments) > 0 { | |||
| 867 | + | attachmentIDs = make([]string, len(freshAttachments)) | |||
| 868 | + | copy(attachmentIDs, freshAttachments) | |||
| 869 | + | } else { | |||
| 870 | + | attachmentIDs = nil | |||
| 871 | + | } | |||
| 872 | + | s.mu.RUnlock() | |||
| 873 | + | ||||
| 859 | configuredAttachments := s.resolveAttachmentsForDomain(domainName) | 874 | configuredAttachments := s.resolveAttachmentsForDomain(domainName) | ||
| 860 | if len(configuredAttachments) > 0 && len(attachmentIDs) < len(configuredAttachments) { | 875 | if len(configuredAttachments) > 0 && len(attachmentIDs) < len(configuredAttachments) { | ||
| 861 | missing := len(configuredAttachments) - len(attachmentIDs) | 876 | missing := len(configuredAttachments) - len(attachmentIDs) | ||
| @@ -1806,7 +1821,8 @@ func (s *Service) getNetworkForApp(domainName string) string { | |||||
| 1806 | var networkGroups map[string][]string | 1821 | var networkGroups map[string][]string | ||
| 1807 | 1822 | ||||
| 1808 | if s.configProvider != nil { | 1823 | if s.configProvider != nil { | ||
| 1809 | - | networkGroups = s.configProvider.GetNetworkGroups() | 1824 | + | snap := s.configProvider.GetAttachmentConfig() |
| 1825 | + | networkGroups = snap.NetworkGroups | |||
| 1810 | s.mu.RLock() | 1826 | s.mu.RLock() | ||
| 1811 | networkIsolation = s.config.NetworkIsolation | 1827 | networkIsolation = s.config.NetworkIsolation | ||
| 1812 | s.mu.RUnlock() | 1828 | s.mu.RUnlock() | ||
| @@ -1993,8 +2009,9 @@ func (s *Service) resolveAttachmentsForDomain(domainName string) []string { | |||||
| 1993 | var networkGroups map[string][]string | 2009 | var networkGroups map[string][]string | ||
| 1994 | 2010 | ||||
| 1995 | if s.configProvider != nil { | 2011 | if s.configProvider != nil { | ||
| 1996 | - | attachments = s.configProvider.GetAttachments() | 2012 | + | snap := s.configProvider.GetAttachmentConfig() |
| 1997 | - | networkGroups = s.configProvider.GetNetworkGroups() | 2013 | + | attachments = snap.Attachments |
| 2014 | + | networkGroups = snap.NetworkGroups | |||
| 1998 | } else { | 2015 | } else { | ||
| 1999 | s.mu.RLock() | 2016 | s.mu.RLock() | ||
| 2000 | attachments = s.config.Attachments | 2017 | attachments = s.config.Attachments | ||
M internal/usecase/container/service_test.go +117 -28
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | "go.opentelemetry.io/otel/sdk/metric/metricdata" | |
| 18 | 18 | ||
| 19 | 19 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | |
| 20 | + | "github.com/bnema/gordon/internal/boundaries/out" | |
| 20 | 21 | mocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | |
| 21 | 22 | "github.com/bnema/gordon/internal/domain" | |
| 22 | 23 | ) | |
| @@ -86,8 +87,10 @@ func TestService_NewService_WithConfigProvider(t *testing.T) { | |||
| 86 | 87 | eventBus := mocks.NewMockEventPublisher(t) | |
| 87 | 88 | ||
| 88 | 89 | provider := mocks.NewMockAttachmentConfigProvider(t) | |
| 89 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"test.com": {"postgres:16"}}).Maybe() | |
| 90 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{"group1": {"test.com"}}).Maybe() | |
| 90 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 91 | + | Attachments: map[string][]string{"test.com": {"postgres:16"}}, | |
| 92 | + | NetworkGroups: map[string][]string{"group1": {"test.com"}}, | |
| 93 | + | }).Maybe() | |
| 91 | 94 | ||
| 92 | 95 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | |
| 93 | 96 | assert.NotNil(t, svc) | |
| @@ -99,9 +102,14 @@ func TestService_ResolveAttachments_UsesLiveConfigProvider(t *testing.T) { | |||
| 99 | 102 | eventBus := mocks.NewMockEventPublisher(t) | |
| 100 | 103 | ||
| 101 | 104 | provider := mocks.NewMockAttachmentConfigProvider(t) | |
| 102 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"app.example.com": {"postgres:16"}}).Once() | |
| 103 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | |
| 104 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"app.example.com": {"postgres:16", "redis:7"}}).Once() | |
| 105 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 106 | + | Attachments: map[string][]string{"app.example.com": {"postgres:16"}}, | |
| 107 | + | NetworkGroups: map[string][]string{}, | |
| 108 | + | }).Once() | |
| 109 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 110 | + | Attachments: map[string][]string{"app.example.com": {"postgres:16", "redis:7"}}, | |
| 111 | + | NetworkGroups: map[string][]string{}, | |
| 112 | + | }).Once() | |
| 105 | 113 | ||
| 106 | 114 | // Create service with EMPTY static config but a live provider | |
| 107 | 115 | svc := NewService(runtime, envLoader, eventBus, nil, Config{ | |
| @@ -119,12 +127,14 @@ func TestService_ResolveAttachments_UsesLiveConfigProvider(t *testing.T) { | |||
| 119 | 127 | ||
| 120 | 128 | func TestService_ResolveAttachments_GroupBasedViaProvider(t *testing.T) { | |
| 121 | 129 | provider := mocks.NewMockAttachmentConfigProvider(t) | |
| 122 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{ | |
| 123 | - | "shared-group": {"postgres:16"}, | |
| 124 | - | }).Maybe() | |
| 125 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{ | |
| 126 | - | "shared-group": {"app1.example.com", "app2.example.com"}, | |
| 127 | - | }).Maybe() | |
| 130 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 131 | + | Attachments: map[string][]string{ | |
| 132 | + | "shared-group": {"postgres:16"}, | |
| 133 | + | }, | |
| 134 | + | NetworkGroups: map[string][]string{ | |
| 135 | + | "shared-group": {"app1.example.com", "app2.example.com"}, | |
| 136 | + | }, | |
| 137 | + | }).Twice() | |
| 128 | 138 | ||
| 129 | 139 | svc := NewService( | |
| 130 | 140 | mocks.NewMockContainerRuntime(t), | |
| @@ -144,15 +154,23 @@ func TestService_ResolveAttachments_GroupBasedViaProvider(t *testing.T) { | |||
| 144 | 154 | ||
| 145 | 155 | func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) { | |
| 146 | 156 | provider := mocks.NewMockAttachmentConfigProvider(t) | |
| 147 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{}).Maybe() | |
| 148 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{ | |
| 149 | - | "shared-group": {"app1.example.com", "app2.example.com"}, | |
| 157 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 158 | + | Attachments: map[string][]string{}, | |
| 159 | + | NetworkGroups: map[string][]string{ | |
| 160 | + | "shared-group": {"app1.example.com", "app2.example.com"}, | |
| 161 | + | }, | |
| 150 | 162 | }).Once() | |
| 151 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{ | |
| 152 | - | "shared-group": {"app1.example.com", "app2.example.com", "app3.example.com"}, | |
| 163 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 164 | + | Attachments: map[string][]string{}, | |
| 165 | + | NetworkGroups: map[string][]string{ | |
| 166 | + | "shared-group": {"app1.example.com", "app2.example.com", "app3.example.com"}, | |
| 167 | + | }, | |
| 153 | 168 | }).Once() | |
| 154 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{ | |
| 155 | - | "shared-group": {"app1.example.com", "app2.example.com", "app3.example.com"}, | |
| 169 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 170 | + | Attachments: map[string][]string{}, | |
| 171 | + | NetworkGroups: map[string][]string{ | |
| 172 | + | "shared-group": {"app1.example.com", "app2.example.com", "app3.example.com"}, | |
| 173 | + | }, | |
| 156 | 174 | }).Once() | |
| 157 | 175 | ||
| 158 | 176 | svc := NewService( | |
| @@ -1216,6 +1234,31 @@ func TestService_Restart_WithAttachments(t *testing.T) { | |||
| 1216 | 1234 | Status: "running", | |
| 1217 | 1235 | } | |
| 1218 | 1236 | svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"} | |
| 1237 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |
| 1238 | + | { | |
| 1239 | + | ID: "container-123", | |
| 1240 | + | Labels: map[string]string{ | |
| 1241 | + | domain.LabelDomain: "test.example.com", | |
| 1242 | + | domain.LabelManaged: "true", | |
| 1243 | + | }, | |
| 1244 | + | }, | |
| 1245 | + | { | |
| 1246 | + | ID: "container-attach-1", | |
| 1247 | + | Labels: map[string]string{ | |
| 1248 | + | domain.LabelManaged: "true", | |
| 1249 | + | domain.LabelAttachment: "true", | |
| 1250 | + | domain.LabelAttachedTo: "test.example.com", | |
| 1251 | + | }, | |
| 1252 | + | }, | |
| 1253 | + | { | |
| 1254 | + | ID: "container-attach-2", | |
| 1255 | + | Labels: map[string]string{ | |
| 1256 | + | domain.LabelManaged: "true", | |
| 1257 | + | domain.LabelAttachment: "true", | |
| 1258 | + | domain.LabelAttachedTo: "test.example.com", | |
| 1259 | + | }, | |
| 1260 | + | }, | |
| 1261 | + | }, nil).Maybe() | |
| 1219 | 1262 | ||
| 1220 | 1263 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | |
| 1221 | 1264 | runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil) | |
| @@ -1232,8 +1275,10 @@ func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *t | |||
| 1232 | 1275 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1233 | 1276 | ||
| 1234 | 1277 | provider := mocks.NewMockAttachmentConfigProvider(t) | |
| 1235 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"test.example.com": {"postgres:16"}}).Maybe() | |
| 1236 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | |
| 1278 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 1279 | + | Attachments: map[string][]string{"test.example.com": {"postgres:16"}}, | |
| 1280 | + | NetworkGroups: map[string][]string{}, | |
| 1281 | + | }).Maybe() | |
| 1237 | 1282 | ||
| 1238 | 1283 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | |
| 1239 | 1284 | ||
| @@ -1243,6 +1288,7 @@ func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *t | |||
| 1243 | 1288 | Status: "running", | |
| 1244 | 1289 | } | |
| 1245 | 1290 | // No attachment containers tracked — s.attachments is empty | |
| 1291 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe() | |
| 1246 | 1292 | ||
| 1247 | 1293 | err := svc.Restart(testContext(), "test.example.com", true) | |
| 1248 | 1294 | ||
| @@ -1258,8 +1304,10 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing. | |||
| 1258 | 1304 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1259 | 1305 | ||
| 1260 | 1306 | provider := mocks.NewMockAttachmentConfigProvider(t) | |
| 1261 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"test.example.com": {"postgres:16", "redis:7"}}).Maybe() | |
| 1262 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | |
| 1307 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 1308 | + | Attachments: map[string][]string{"test.example.com": {"postgres:16", "redis:7"}}, | |
| 1309 | + | NetworkGroups: map[string][]string{}, | |
| 1310 | + | }).Maybe() | |
| 1263 | 1311 | ||
| 1264 | 1312 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | |
| 1265 | 1313 | ||
| @@ -1270,6 +1318,23 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing. | |||
| 1270 | 1318 | } | |
| 1271 | 1319 | // Only 1 of 2 attachments deployed | |
| 1272 | 1320 | svc.attachments["test.example.com"] = []string{"attach-postgres"} | |
| 1321 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |
| 1322 | + | { | |
| 1323 | + | ID: "container-123", | |
| 1324 | + | Labels: map[string]string{ | |
| 1325 | + | domain.LabelDomain: "test.example.com", | |
| 1326 | + | domain.LabelManaged: "true", | |
| 1327 | + | }, | |
| 1328 | + | }, | |
| 1329 | + | { | |
| 1330 | + | ID: "attach-postgres", | |
| 1331 | + | Labels: map[string]string{ | |
| 1332 | + | domain.LabelManaged: "true", | |
| 1333 | + | domain.LabelAttachment: "true", | |
| 1334 | + | domain.LabelAttachedTo: "test.example.com", | |
| 1335 | + | }, | |
| 1336 | + | }, | |
| 1337 | + | }, nil).Maybe() | |
| 1273 | 1338 | ||
| 1274 | 1339 | err := svc.Restart(testContext(), "test.example.com", true) | |
| 1275 | 1340 | ||
| @@ -1286,8 +1351,10 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) { | |||
| 1286 | 1351 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1287 | 1352 | ||
| 1288 | 1353 | provider := mocks.NewMockAttachmentConfigProvider(t) | |
| 1289 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"test.example.com": {"postgres:16"}}).Maybe() | |
| 1290 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | |
| 1354 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 1355 | + | Attachments: map[string][]string{"test.example.com": {"postgres:16"}}, | |
| 1356 | + | NetworkGroups: map[string][]string{}, | |
| 1357 | + | }).Maybe() | |
| 1291 | 1358 | ||
| 1292 | 1359 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | |
| 1293 | 1360 | ||
| @@ -1297,6 +1364,23 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) { | |||
| 1297 | 1364 | Status: "running", | |
| 1298 | 1365 | } | |
| 1299 | 1366 | svc.attachments["test.example.com"] = []string{"attach-456"} | |
| 1367 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |
| 1368 | + | { | |
| 1369 | + | ID: "container-123", | |
| 1370 | + | Labels: map[string]string{ | |
| 1371 | + | domain.LabelDomain: "test.example.com", | |
| 1372 | + | domain.LabelManaged: "true", | |
| 1373 | + | }, | |
| 1374 | + | }, | |
| 1375 | + | { | |
| 1376 | + | ID: "attach-456", | |
| 1377 | + | Labels: map[string]string{ | |
| 1378 | + | domain.LabelManaged: "true", | |
| 1379 | + | domain.LabelAttachment: "true", | |
| 1380 | + | domain.LabelAttachedTo: "test.example.com", | |
| 1381 | + | }, | |
| 1382 | + | }, | |
| 1383 | + | }, nil).Maybe() | |
| 1300 | 1384 | ||
| 1301 | 1385 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | |
| 1302 | 1386 | runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil) | |
| @@ -1311,8 +1395,10 @@ func TestService_Restart_WithoutAttachmentFlag_IgnoresMissingAttachments(t *test | |||
| 1311 | 1395 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1312 | 1396 | ||
| 1313 | 1397 | provider := mocks.NewMockAttachmentConfigProvider(t) | |
| 1314 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"test.example.com": {"postgres:16"}}).Maybe() | |
| 1315 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | |
| 1398 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 1399 | + | Attachments: map[string][]string{"test.example.com": {"postgres:16"}}, | |
| 1400 | + | NetworkGroups: map[string][]string{}, | |
| 1401 | + | }).Maybe() | |
| 1316 | 1402 | ||
| 1317 | 1403 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | |
| 1318 | 1404 | ||
| @@ -1334,8 +1420,10 @@ func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *test | |||
| 1334 | 1420 | eventBus := mocks.NewMockEventPublisher(t) | |
| 1335 | 1421 | ||
| 1336 | 1422 | provider := mocks.NewMockAttachmentConfigProvider(t) | |
| 1337 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{}).Maybe() | |
| 1338 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | |
| 1423 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |
| 1424 | + | Attachments: map[string][]string{}, | |
| 1425 | + | NetworkGroups: map[string][]string{}, | |
| 1426 | + | }).Maybe() | |
| 1339 | 1427 | ||
| 1340 | 1428 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | |
| 1341 | 1429 | ||
| @@ -1344,6 +1432,7 @@ func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *test | |||
| 1344 | 1432 | Name: "gordon-test.example.com", | |
| 1345 | 1433 | Status: "running", | |
| 1346 | 1434 | } | |
| 1435 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe() | |
| 1347 | 1436 | ||
| 1348 | 1437 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | |
| 1349 | 1438 | ||
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | "go.opentelemetry.io/otel/sdk/metric/metricdata" | 17 | "go.opentelemetry.io/otel/sdk/metric/metricdata" | ||
| 18 | 18 | ||||
| 19 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | 19 | "github.com/bnema/gordon/internal/adapters/out/telemetry" | ||
| 20 | + | "github.com/bnema/gordon/internal/boundaries/out" | |||
| 20 | mocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | 21 | mocks "github.com/bnema/gordon/internal/boundaries/out/mocks" | ||
| 21 | "github.com/bnema/gordon/internal/domain" | 22 | "github.com/bnema/gordon/internal/domain" | ||
| 22 | ) | 23 | ) | ||
| @@ -86,8 +87,10 @@ func TestService_NewService_WithConfigProvider(t *testing.T) { | |||||
| 86 | eventBus := mocks.NewMockEventPublisher(t) | 87 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 87 | 88 | ||||
| 88 | provider := mocks.NewMockAttachmentConfigProvider(t) | 89 | provider := mocks.NewMockAttachmentConfigProvider(t) | ||
| 89 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"test.com": {"postgres:16"}}).Maybe() | 90 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 90 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{"group1": {"test.com"}}).Maybe() | 91 | + | Attachments: map[string][]string{"test.com": {"postgres:16"}}, |
| 92 | + | NetworkGroups: map[string][]string{"group1": {"test.com"}}, | |||
| 93 | + | }).Maybe() | |||
| 91 | 94 | ||||
| 92 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | 95 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | ||
| 93 | assert.NotNil(t, svc) | 96 | assert.NotNil(t, svc) | ||
| @@ -99,9 +102,14 @@ func TestService_ResolveAttachments_UsesLiveConfigProvider(t *testing.T) { | |||||
| 99 | eventBus := mocks.NewMockEventPublisher(t) | 102 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 100 | 103 | ||||
| 101 | provider := mocks.NewMockAttachmentConfigProvider(t) | 104 | provider := mocks.NewMockAttachmentConfigProvider(t) | ||
| 102 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"app.example.com": {"postgres:16"}}).Once() | 105 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 103 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | 106 | + | Attachments: map[string][]string{"app.example.com": {"postgres:16"}}, |
| 104 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"app.example.com": {"postgres:16", "redis:7"}}).Once() | 107 | + | NetworkGroups: map[string][]string{}, |
| 108 | + | }).Once() | |||
| 109 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ | |||
| 110 | + | Attachments: map[string][]string{"app.example.com": {"postgres:16", "redis:7"}}, | |||
| 111 | + | NetworkGroups: map[string][]string{}, | |||
| 112 | + | }).Once() | |||
| 105 | 113 | ||||
| 106 | // Create service with EMPTY static config but a live provider | 114 | // Create service with EMPTY static config but a live provider | ||
| 107 | svc := NewService(runtime, envLoader, eventBus, nil, Config{ | 115 | svc := NewService(runtime, envLoader, eventBus, nil, Config{ | ||
| @@ -119,12 +127,14 @@ func TestService_ResolveAttachments_UsesLiveConfigProvider(t *testing.T) { | |||||
| 119 | 127 | ||||
| 120 | func TestService_ResolveAttachments_GroupBasedViaProvider(t *testing.T) { | 128 | func TestService_ResolveAttachments_GroupBasedViaProvider(t *testing.T) { | ||
| 121 | provider := mocks.NewMockAttachmentConfigProvider(t) | 129 | provider := mocks.NewMockAttachmentConfigProvider(t) | ||
| 122 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{ | 130 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 123 | - | "shared-group": {"postgres:16"}, | 131 | + | Attachments: map[string][]string{ |
| 124 | - | }).Maybe() | 132 | + | "shared-group": {"postgres:16"}, |
| 125 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{ | 133 | + | }, |
| 126 | - | "shared-group": {"app1.example.com", "app2.example.com"}, | 134 | + | NetworkGroups: map[string][]string{ |
| 127 | - | }).Maybe() | 135 | + | "shared-group": {"app1.example.com", "app2.example.com"}, |
| 136 | + | }, | |||
| 137 | + | }).Twice() | |||
| 128 | 138 | ||||
| 129 | svc := NewService( | 139 | svc := NewService( | ||
| 130 | mocks.NewMockContainerRuntime(t), | 140 | mocks.NewMockContainerRuntime(t), | ||
| @@ -144,15 +154,23 @@ func TestService_ResolveAttachments_GroupBasedViaProvider(t *testing.T) { | |||||
| 144 | 154 | ||||
| 145 | func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) { | 155 | func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) { | ||
| 146 | provider := mocks.NewMockAttachmentConfigProvider(t) | 156 | provider := mocks.NewMockAttachmentConfigProvider(t) | ||
| 147 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{}).Maybe() | 157 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 148 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{ | 158 | + | Attachments: map[string][]string{}, |
| 149 | - | "shared-group": {"app1.example.com", "app2.example.com"}, | 159 | + | NetworkGroups: map[string][]string{ |
| 160 | + | "shared-group": {"app1.example.com", "app2.example.com"}, | |||
| 161 | + | }, | |||
| 150 | }).Once() | 162 | }).Once() | ||
| 151 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{ | 163 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 152 | - | "shared-group": {"app1.example.com", "app2.example.com", "app3.example.com"}, | 164 | + | Attachments: map[string][]string{}, |
| 165 | + | NetworkGroups: map[string][]string{ | |||
| 166 | + | "shared-group": {"app1.example.com", "app2.example.com", "app3.example.com"}, | |||
| 167 | + | }, | |||
| 153 | }).Once() | 168 | }).Once() | ||
| 154 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{ | 169 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 155 | - | "shared-group": {"app1.example.com", "app2.example.com", "app3.example.com"}, | 170 | + | Attachments: map[string][]string{}, |
| 171 | + | NetworkGroups: map[string][]string{ | |||
| 172 | + | "shared-group": {"app1.example.com", "app2.example.com", "app3.example.com"}, | |||
| 173 | + | }, | |||
| 156 | }).Once() | 174 | }).Once() | ||
| 157 | 175 | ||||
| 158 | svc := NewService( | 176 | svc := NewService( | ||
| @@ -1216,6 +1234,31 @@ func TestService_Restart_WithAttachments(t *testing.T) { | |||||
| 1216 | Status: "running", | 1234 | Status: "running", | ||
| 1217 | } | 1235 | } | ||
| 1218 | svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"} | 1236 | svc.attachments["test.example.com"] = []string{"container-attach-1", "container-attach-2"} | ||
| 1237 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |||
| 1238 | + | { | |||
| 1239 | + | ID: "container-123", | |||
| 1240 | + | Labels: map[string]string{ | |||
| 1241 | + | domain.LabelDomain: "test.example.com", | |||
| 1242 | + | domain.LabelManaged: "true", | |||
| 1243 | + | }, | |||
| 1244 | + | }, | |||
| 1245 | + | { | |||
| 1246 | + | ID: "container-attach-1", | |||
| 1247 | + | Labels: map[string]string{ | |||
| 1248 | + | domain.LabelManaged: "true", | |||
| 1249 | + | domain.LabelAttachment: "true", | |||
| 1250 | + | domain.LabelAttachedTo: "test.example.com", | |||
| 1251 | + | }, | |||
| 1252 | + | }, | |||
| 1253 | + | { | |||
| 1254 | + | ID: "container-attach-2", | |||
| 1255 | + | Labels: map[string]string{ | |||
| 1256 | + | domain.LabelManaged: "true", | |||
| 1257 | + | domain.LabelAttachment: "true", | |||
| 1258 | + | domain.LabelAttachedTo: "test.example.com", | |||
| 1259 | + | }, | |||
| 1260 | + | }, | |||
| 1261 | + | }, nil).Maybe() | |||
| 1219 | 1262 | ||||
| 1220 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | 1263 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | ||
| 1221 | runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil) | 1264 | runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil) | ||
| @@ -1232,8 +1275,10 @@ func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *t | |||||
| 1232 | eventBus := mocks.NewMockEventPublisher(t) | 1275 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1233 | 1276 | ||||
| 1234 | provider := mocks.NewMockAttachmentConfigProvider(t) | 1277 | provider := mocks.NewMockAttachmentConfigProvider(t) | ||
| 1235 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"test.example.com": {"postgres:16"}}).Maybe() | 1278 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 1236 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | 1279 | + | Attachments: map[string][]string{"test.example.com": {"postgres:16"}}, |
| 1280 | + | NetworkGroups: map[string][]string{}, | |||
| 1281 | + | }).Maybe() | |||
| 1237 | 1282 | ||||
| 1238 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | 1283 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | ||
| 1239 | 1284 | ||||
| @@ -1243,6 +1288,7 @@ func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *t | |||||
| 1243 | Status: "running", | 1288 | Status: "running", | ||
| 1244 | } | 1289 | } | ||
| 1245 | // No attachment containers tracked — s.attachments is empty | 1290 | // No attachment containers tracked — s.attachments is empty | ||
| 1291 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe() | |||
| 1246 | 1292 | ||||
| 1247 | err := svc.Restart(testContext(), "test.example.com", true) | 1293 | err := svc.Restart(testContext(), "test.example.com", true) | ||
| 1248 | 1294 | ||||
| @@ -1258,8 +1304,10 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing. | |||||
| 1258 | eventBus := mocks.NewMockEventPublisher(t) | 1304 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1259 | 1305 | ||||
| 1260 | provider := mocks.NewMockAttachmentConfigProvider(t) | 1306 | provider := mocks.NewMockAttachmentConfigProvider(t) | ||
| 1261 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"test.example.com": {"postgres:16", "redis:7"}}).Maybe() | 1307 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 1262 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | 1308 | + | Attachments: map[string][]string{"test.example.com": {"postgres:16", "redis:7"}}, |
| 1309 | + | NetworkGroups: map[string][]string{}, | |||
| 1310 | + | }).Maybe() | |||
| 1263 | 1311 | ||||
| 1264 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | 1312 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | ||
| 1265 | 1313 | ||||
| @@ -1270,6 +1318,23 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing. | |||||
| 1270 | } | 1318 | } | ||
| 1271 | // Only 1 of 2 attachments deployed | 1319 | // Only 1 of 2 attachments deployed | ||
| 1272 | svc.attachments["test.example.com"] = []string{"attach-postgres"} | 1320 | svc.attachments["test.example.com"] = []string{"attach-postgres"} | ||
| 1321 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |||
| 1322 | + | { | |||
| 1323 | + | ID: "container-123", | |||
| 1324 | + | Labels: map[string]string{ | |||
| 1325 | + | domain.LabelDomain: "test.example.com", | |||
| 1326 | + | domain.LabelManaged: "true", | |||
| 1327 | + | }, | |||
| 1328 | + | }, | |||
| 1329 | + | { | |||
| 1330 | + | ID: "attach-postgres", | |||
| 1331 | + | Labels: map[string]string{ | |||
| 1332 | + | domain.LabelManaged: "true", | |||
| 1333 | + | domain.LabelAttachment: "true", | |||
| 1334 | + | domain.LabelAttachedTo: "test.example.com", | |||
| 1335 | + | }, | |||
| 1336 | + | }, | |||
| 1337 | + | }, nil).Maybe() | |||
| 1273 | 1338 | ||||
| 1274 | err := svc.Restart(testContext(), "test.example.com", true) | 1339 | err := svc.Restart(testContext(), "test.example.com", true) | ||
| 1275 | 1340 | ||||
| @@ -1286,8 +1351,10 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) { | |||||
| 1286 | eventBus := mocks.NewMockEventPublisher(t) | 1351 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1287 | 1352 | ||||
| 1288 | provider := mocks.NewMockAttachmentConfigProvider(t) | 1353 | provider := mocks.NewMockAttachmentConfigProvider(t) | ||
| 1289 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"test.example.com": {"postgres:16"}}).Maybe() | 1354 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 1290 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | 1355 | + | Attachments: map[string][]string{"test.example.com": {"postgres:16"}}, |
| 1356 | + | NetworkGroups: map[string][]string{}, | |||
| 1357 | + | }).Maybe() | |||
| 1291 | 1358 | ||||
| 1292 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | 1359 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | ||
| 1293 | 1360 | ||||
| @@ -1297,6 +1364,23 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) { | |||||
| 1297 | Status: "running", | 1364 | Status: "running", | ||
| 1298 | } | 1365 | } | ||
| 1299 | svc.attachments["test.example.com"] = []string{"attach-456"} | 1366 | svc.attachments["test.example.com"] = []string{"attach-456"} | ||
| 1367 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{ | |||
| 1368 | + | { | |||
| 1369 | + | ID: "container-123", | |||
| 1370 | + | Labels: map[string]string{ | |||
| 1371 | + | domain.LabelDomain: "test.example.com", | |||
| 1372 | + | domain.LabelManaged: "true", | |||
| 1373 | + | }, | |||
| 1374 | + | }, | |||
| 1375 | + | { | |||
| 1376 | + | ID: "attach-456", | |||
| 1377 | + | Labels: map[string]string{ | |||
| 1378 | + | domain.LabelManaged: "true", | |||
| 1379 | + | domain.LabelAttachment: "true", | |||
| 1380 | + | domain.LabelAttachedTo: "test.example.com", | |||
| 1381 | + | }, | |||
| 1382 | + | }, | |||
| 1383 | + | }, nil).Maybe() | |||
| 1300 | 1384 | ||||
| 1301 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | 1385 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | ||
| 1302 | runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil) | 1386 | runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil) | ||
| @@ -1311,8 +1395,10 @@ func TestService_Restart_WithoutAttachmentFlag_IgnoresMissingAttachments(t *test | |||||
| 1311 | eventBus := mocks.NewMockEventPublisher(t) | 1395 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1312 | 1396 | ||||
| 1313 | provider := mocks.NewMockAttachmentConfigProvider(t) | 1397 | provider := mocks.NewMockAttachmentConfigProvider(t) | ||
| 1314 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{"test.example.com": {"postgres:16"}}).Maybe() | 1398 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 1315 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | 1399 | + | Attachments: map[string][]string{"test.example.com": {"postgres:16"}}, |
| 1400 | + | NetworkGroups: map[string][]string{}, | |||
| 1401 | + | }).Maybe() | |||
| 1316 | 1402 | ||||
| 1317 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | 1403 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | ||
| 1318 | 1404 | ||||
| @@ -1334,8 +1420,10 @@ func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *test | |||||
| 1334 | eventBus := mocks.NewMockEventPublisher(t) | 1420 | eventBus := mocks.NewMockEventPublisher(t) | ||
| 1335 | 1421 | ||||
| 1336 | provider := mocks.NewMockAttachmentConfigProvider(t) | 1422 | provider := mocks.NewMockAttachmentConfigProvider(t) | ||
| 1337 | - | provider.EXPECT().GetAttachments().Return(map[string][]string{}).Maybe() | 1423 | + | provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{ |
| 1338 | - | provider.EXPECT().GetNetworkGroups().Return(map[string][]string{}).Maybe() | 1424 | + | Attachments: map[string][]string{}, |
| 1425 | + | NetworkGroups: map[string][]string{}, | |||
| 1426 | + | }).Maybe() | |||
| 1339 | 1427 | ||||
| 1340 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | 1428 | svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider) | ||
| 1341 | 1429 | ||||
| @@ -1344,6 +1432,7 @@ func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *test | |||||
| 1344 | Name: "gordon-test.example.com", | 1432 | Name: "gordon-test.example.com", | ||
| 1345 | Status: "running", | 1433 | Status: "running", | ||
| 1346 | } | 1434 | } | ||
| 1435 | + | runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe() | |||
| 1347 | 1436 | ||||
| 1348 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | 1437 | runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil) | ||
| 1349 | 1438 | ||||