detached a315454

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 @@
11 package out
22
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+
310 // AttachmentConfigProvider is a read-only view into the live config service
411 // for attachment and network group resolution. It decouples the container
512 // service from the full ConfigService interface while ensuring fresh reads
@@ -9,6 +16,10 @@ package out
916 // callers cannot mutate provider state. Each call must produce an independent
1017 // map with independently allocated slices.
1118 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+
1223 // GetAttachments returns a deep copy of the current attachment config: domain/group -> []image.
1324 GetAttachments() map[string][]string
1425
@@ -1,5 +1,12 @@
1 package out1 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 service10 // AttachmentConfigProvider is a read-only view into the live config service
4 // for attachment and network group resolution. It decouples the container11 // for attachment and network group resolution. It decouples the container
5 // service from the full ConfigService interface while ensuring fresh reads12 // 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 independent16 // 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][]string24 GetAttachments() map[string][]string
14 25
M internal/boundaries/out/mocks/mock_attachment_config_provider.go +45 -0
@@ -5,6 +5,7 @@
55 package mocks
66
77 import (
8+ "github.com/bnema/gordon/internal/boundaries/out"
89 mock "github.com/stretchr/testify/mock"
910 )
1011
@@ -35,6 +36,50 @@ func (_m *MockAttachmentConfigProvider) EXPECT() *MockAttachmentConfigProvider_E
3536 return &MockAttachmentConfigProvider_Expecter{mock: &_m.Mock}
3637 }
3738
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+
3883 // GetAttachments provides a mock function for the type MockAttachmentConfigProvider
3984 func (_mock *MockAttachmentConfigProvider) GetAttachments() map[string][]string {
4085 ret := _mock.Called()
@@ -5,6 +5,7 @@
5 package mocks5 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 MockAttachmentConfigProvider83 // 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 {
860860 return result
861861 }
862862
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+
863885 // GetAttachments returns attachment configuration.
864886 // Deprecated: Use GetAllAttachments instead.
865887 func (s *Service) GetAttachments() map[string][]string {
@@ -860,6 +860,28 @@ func (s *Service) GetNetworkGroups() map[string][]string {
860 return result860 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) {
487487 assert.ElementsMatch(t, []string{"redis:latest", "postgres:18"}, attachments["app.example.com"])
488488 }
489489
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+
490520 func TestService_GetAllAttachments(t *testing.T) {
491521 t.Run("returns all attachments", func(t *testing.T) {
492522 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
851851 return domain.ErrContainerNotFound
852852 }
853853
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.
855856 // If fewer attachment containers are tracked than configured, fail early with
856857 // guidance rather than restarting the main container into a broken state
857858 // (e.g., missing database hostname).
858859 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+
859874 configuredAttachments := s.resolveAttachmentsForDomain(domainName)
860875 if len(configuredAttachments) > 0 && len(attachmentIDs) < len(configuredAttachments) {
861876 missing := len(configuredAttachments) - len(attachmentIDs)
@@ -1806,7 +1821,8 @@ func (s *Service) getNetworkForApp(domainName string) string {
18061821 var networkGroups map[string][]string
18071822
18081823 if s.configProvider != nil {
1809- networkGroups = s.configProvider.GetNetworkGroups()
1824+ snap := s.configProvider.GetAttachmentConfig()
1825+ networkGroups = snap.NetworkGroups
18101826 s.mu.RLock()
18111827 networkIsolation = s.config.NetworkIsolation
18121828 s.mu.RUnlock()
@@ -1993,8 +2009,9 @@ func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
19932009 var networkGroups map[string][]string
19942010
19952011 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
19982015 } else {
19992016 s.mu.RLock()
20002017 attachments = s.config.Attachments
@@ -851,11 +851,26 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment
851 return domain.ErrContainerNotFound851 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 with856 // If fewer attachment containers are tracked than configured, fail early with
856 // guidance rather than restarting the main container into a broken state857 // 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][]string1821 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.NetworkIsolation1827 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][]string2009 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.Attachments2017 attachments = s.config.Attachments
M internal/usecase/container/service_test.go +117 -28
@@ -17,6 +17,7 @@ import (
1717 "go.opentelemetry.io/otel/sdk/metric/metricdata"
1818
1919 "github.com/bnema/gordon/internal/adapters/out/telemetry"
20+ "github.com/bnema/gordon/internal/boundaries/out"
2021 mocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
2122 "github.com/bnema/gordon/internal/domain"
2223 )
@@ -86,8 +87,10 @@ func TestService_NewService_WithConfigProvider(t *testing.T) {
8687 eventBus := mocks.NewMockEventPublisher(t)
8788
8889 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()
9194
9295 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
9396 assert.NotNil(t, svc)
@@ -99,9 +102,14 @@ func TestService_ResolveAttachments_UsesLiveConfigProvider(t *testing.T) {
99102 eventBus := mocks.NewMockEventPublisher(t)
100103
101104 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()
105113
106114 // Create service with EMPTY static config but a live provider
107115 svc := NewService(runtime, envLoader, eventBus, nil, Config{
@@ -119,12 +127,14 @@ func TestService_ResolveAttachments_UsesLiveConfigProvider(t *testing.T) {
119127
120128 func TestService_ResolveAttachments_GroupBasedViaProvider(t *testing.T) {
121129 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()
128138
129139 svc := NewService(
130140 mocks.NewMockContainerRuntime(t),
@@ -144,15 +154,23 @@ func TestService_ResolveAttachments_GroupBasedViaProvider(t *testing.T) {
144154
145155 func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) {
146156 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+ },
150162 }).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+ },
153168 }).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+ },
156174 }).Once()
157175
158176 svc := NewService(
@@ -1216,6 +1234,31 @@ func TestService_Restart_WithAttachments(t *testing.T) {
12161234 Status: "running",
12171235 }
12181236 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()
12191262
12201263 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
12211264 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
@@ -1232,8 +1275,10 @@ func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *t
12321275 eventBus := mocks.NewMockEventPublisher(t)
12331276
12341277 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()
12371282
12381283 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
12391284
@@ -1243,6 +1288,7 @@ func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *t
12431288 Status: "running",
12441289 }
12451290 // No attachment containers tracked — s.attachments is empty
1291+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
12461292
12471293 err := svc.Restart(testContext(), "test.example.com", true)
12481294
@@ -1258,8 +1304,10 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.
12581304 eventBus := mocks.NewMockEventPublisher(t)
12591305
12601306 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()
12631311
12641312 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
12651313
@@ -1270,6 +1318,23 @@ func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.
12701318 }
12711319 // Only 1 of 2 attachments deployed
12721320 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()
12731338
12741339 err := svc.Restart(testContext(), "test.example.com", true)
12751340
@@ -1286,8 +1351,10 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
12861351 eventBus := mocks.NewMockEventPublisher(t)
12871352
12881353 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()
12911358
12921359 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
12931360
@@ -1297,6 +1364,23 @@ func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
12971364 Status: "running",
12981365 }
12991366 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()
13001384
13011385 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
13021386 runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil)
@@ -1311,8 +1395,10 @@ func TestService_Restart_WithoutAttachmentFlag_IgnoresMissingAttachments(t *test
13111395 eventBus := mocks.NewMockEventPublisher(t)
13121396
13131397 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()
13161402
13171403 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
13181404
@@ -1334,8 +1420,10 @@ func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *test
13341420 eventBus := mocks.NewMockEventPublisher(t)
13351421
13361422 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()
13391427
13401428 svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
13411429
@@ -1344,6 +1432,7 @@ func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *test
13441432 Name: "gordon-test.example.com",
13451433 Status: "running",
13461434 }
1435+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
13471436
13481437 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
13491438
@@ -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 provider114 // 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 empty1290 // 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 deployed1319 // 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close