detached defb232

Merge pull request #121 from bnema/fix/attachment-deploy-lifecycle

fix: deploy/restart handles missing attachment containers (#120)
commit
defb2320765a609b850ab92a094d3ef8f2d1f556
author
bnema · · 2026-03-15 18:11 UTC
committer
GitHub · · 2026-03-15 18:11 UTC
merge of
e86854e + a315454 · diff against the first parent
signature
unsigned
13 files · +755 -118
M .gitignore +2 -1
@@ -1,4 +1,5 @@
11 gordon
22 .env
33 dist/
4-docs/plans/⏎̸
4+docs/plans/
5+.worktrees/⏎̸
@@ -1,4 +1,5 @@
1 gordon1 gordon
2 .env2 .env
3 dist/3 dist/
4-docs/plans/⏎̸4+docs/plans/
5+.worktrees/⏎̸
M .mockery.yaml +1 -0
@@ -19,6 +19,7 @@ packages:
1919 SecretProvider:
2020 EnvLoader:
2121 ContainerLogWriter:
22+ AttachmentConfigProvider:
2223 TokenStore:
2324 DomainSecretStore:
2425 RateLimiter:
@@ -19,6 +19,7 @@ packages:
19 SecretProvider:19 SecretProvider:
20 EnvLoader:20 EnvLoader:
21 ContainerLogWriter:21 ContainerLogWriter:
22+ AttachmentConfigProvider:
22 TokenStore:23 TokenStore:
23 DomainSecretStore:24 DomainSecretStore:
24 RateLimiter:25 RateLimiter:
M internal/app/run.go +1 -1
@@ -1239,7 +1239,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
12391239 log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")
12401240 }
12411241
1242- return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
1242+ return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil
12431243 }
12441244
12451245 func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
@@ -1239,7 +1239,7 @@ func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc
1239 log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")1239 log.Warn().Msg("registry auth disabled; container image pulls will use unauthenticated mode")
1240 }1240 }
1241 1241
1242- return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil1242+ return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig, svc.configSvc), nil
1243 }1243 }
1244 1244
1245 func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {1245 func createBackupService(cfg Config, svc *services, log zerowrap.Logger) (*filesystem.BackupStorage, *backup.Service, error) {
A internal/boundaries/out/attachment_config.go +28 -0
@@ -0,0 +1,28 @@
1+package out
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+
10+// AttachmentConfigProvider is a read-only view into the live config service
11+// for attachment and network group resolution. It decouples the container
12+// service from the full ConfigService interface while ensuring fresh reads
13+// at decision time (deploy, restart).
14+//
15+// Implementations must return deep copies of the underlying data so that
16+// callers cannot mutate provider state. Each call must produce an independent
17+// map with independently allocated slices.
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+
23+ // GetAttachments returns a deep copy of the current attachment config: domain/group -> []image.
24+ GetAttachments() map[string][]string
25+
26+ // GetNetworkGroups returns a deep copy of the current network group config: group -> []domain.
27+ GetNetworkGroups() map[string][]string
28+}
@@ -0,0 +1,28 @@
1+package out
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+
10+// AttachmentConfigProvider is a read-only view into the live config service
11+// for attachment and network group resolution. It decouples the container
12+// service from the full ConfigService interface while ensuring fresh reads
13+// at decision time (deploy, restart).
14+//
15+// Implementations must return deep copies of the underlying data so that
16+// callers cannot mutate provider state. Each call must produce an independent
17+// map with independently allocated slices.
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+
23+ // GetAttachments returns a deep copy of the current attachment config: domain/group -> []image.
24+ GetAttachments() map[string][]string
25+
26+ // GetNetworkGroups returns a deep copy of the current network group config: group -> []domain.
27+ GetNetworkGroups() map[string][]string
28+}
A internal/boundaries/out/mocks/mock_attachment_config_provider.go +173 -0
@@ -0,0 +1,173 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "github.com/bnema/gordon/internal/boundaries/out"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockAttachmentConfigProvider creates a new instance of MockAttachmentConfigProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockAttachmentConfigProvider(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockAttachmentConfigProvider {
18+ mock := &MockAttachmentConfigProvider{}
19+ mock.Mock.Test(t)
20+
21+ t.Cleanup(func() { mock.AssertExpectations(t) })
22+
23+ return mock
24+}
25+
26+// MockAttachmentConfigProvider is an autogenerated mock type for the AttachmentConfigProvider type
27+type MockAttachmentConfigProvider struct {
28+ mock.Mock
29+}
30+
31+type MockAttachmentConfigProvider_Expecter struct {
32+ mock *mock.Mock
33+}
34+
35+func (_m *MockAttachmentConfigProvider) EXPECT() *MockAttachmentConfigProvider_Expecter {
36+ return &MockAttachmentConfigProvider_Expecter{mock: &_m.Mock}
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+
83+// GetAttachments provides a mock function for the type MockAttachmentConfigProvider
84+func (_mock *MockAttachmentConfigProvider) GetAttachments() map[string][]string {
85+ ret := _mock.Called()
86+
87+ if len(ret) == 0 {
88+ panic("no return value specified for GetAttachments")
89+ }
90+
91+ var r0 map[string][]string
92+ if returnFunc, ok := ret.Get(0).(func() map[string][]string); ok {
93+ r0 = returnFunc()
94+ } else {
95+ if ret.Get(0) != nil {
96+ r0 = ret.Get(0).(map[string][]string)
97+ }
98+ }
99+ return r0
100+}
101+
102+// MockAttachmentConfigProvider_GetAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachments'
103+type MockAttachmentConfigProvider_GetAttachments_Call struct {
104+ *mock.Call
105+}
106+
107+// GetAttachments is a helper method to define mock.On call
108+func (_e *MockAttachmentConfigProvider_Expecter) GetAttachments() *MockAttachmentConfigProvider_GetAttachments_Call {
109+ return &MockAttachmentConfigProvider_GetAttachments_Call{Call: _e.mock.On("GetAttachments")}
110+}
111+
112+func (_c *MockAttachmentConfigProvider_GetAttachments_Call) Run(run func()) *MockAttachmentConfigProvider_GetAttachments_Call {
113+ _c.Call.Run(func(args mock.Arguments) {
114+ run()
115+ })
116+ return _c
117+}
118+
119+func (_c *MockAttachmentConfigProvider_GetAttachments_Call) Return(stringToStrings map[string][]string) *MockAttachmentConfigProvider_GetAttachments_Call {
120+ _c.Call.Return(stringToStrings)
121+ return _c
122+}
123+
124+func (_c *MockAttachmentConfigProvider_GetAttachments_Call) RunAndReturn(run func() map[string][]string) *MockAttachmentConfigProvider_GetAttachments_Call {
125+ _c.Call.Return(run)
126+ return _c
127+}
128+
129+// GetNetworkGroups provides a mock function for the type MockAttachmentConfigProvider
130+func (_mock *MockAttachmentConfigProvider) GetNetworkGroups() map[string][]string {
131+ ret := _mock.Called()
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for GetNetworkGroups")
135+ }
136+
137+ var r0 map[string][]string
138+ if returnFunc, ok := ret.Get(0).(func() map[string][]string); ok {
139+ r0 = returnFunc()
140+ } else {
141+ if ret.Get(0) != nil {
142+ r0 = ret.Get(0).(map[string][]string)
143+ }
144+ }
145+ return r0
146+}
147+
148+// MockAttachmentConfigProvider_GetNetworkGroups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetNetworkGroups'
149+type MockAttachmentConfigProvider_GetNetworkGroups_Call struct {
150+ *mock.Call
151+}
152+
153+// GetNetworkGroups is a helper method to define mock.On call
154+func (_e *MockAttachmentConfigProvider_Expecter) GetNetworkGroups() *MockAttachmentConfigProvider_GetNetworkGroups_Call {
155+ return &MockAttachmentConfigProvider_GetNetworkGroups_Call{Call: _e.mock.On("GetNetworkGroups")}
156+}
157+
158+func (_c *MockAttachmentConfigProvider_GetNetworkGroups_Call) Run(run func()) *MockAttachmentConfigProvider_GetNetworkGroups_Call {
159+ _c.Call.Run(func(args mock.Arguments) {
160+ run()
161+ })
162+ return _c
163+}
164+
165+func (_c *MockAttachmentConfigProvider_GetNetworkGroups_Call) Return(stringToStrings map[string][]string) *MockAttachmentConfigProvider_GetNetworkGroups_Call {
166+ _c.Call.Return(stringToStrings)
167+ return _c
168+}
169+
170+func (_c *MockAttachmentConfigProvider_GetNetworkGroups_Call) RunAndReturn(run func() map[string][]string) *MockAttachmentConfigProvider_GetNetworkGroups_Call {
171+ _c.Call.Return(run)
172+ return _c
173+}
@@ -0,0 +1,173 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package mocks
6+
7+import (
8+ "github.com/bnema/gordon/internal/boundaries/out"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockAttachmentConfigProvider creates a new instance of MockAttachmentConfigProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockAttachmentConfigProvider(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockAttachmentConfigProvider {
18+ mock := &MockAttachmentConfigProvider{}
19+ mock.Mock.Test(t)
20+
21+ t.Cleanup(func() { mock.AssertExpectations(t) })
22+
23+ return mock
24+}
25+
26+// MockAttachmentConfigProvider is an autogenerated mock type for the AttachmentConfigProvider type
27+type MockAttachmentConfigProvider struct {
28+ mock.Mock
29+}
30+
31+type MockAttachmentConfigProvider_Expecter struct {
32+ mock *mock.Mock
33+}
34+
35+func (_m *MockAttachmentConfigProvider) EXPECT() *MockAttachmentConfigProvider_Expecter {
36+ return &MockAttachmentConfigProvider_Expecter{mock: &_m.Mock}
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+
83+// GetAttachments provides a mock function for the type MockAttachmentConfigProvider
84+func (_mock *MockAttachmentConfigProvider) GetAttachments() map[string][]string {
85+ ret := _mock.Called()
86+
87+ if len(ret) == 0 {
88+ panic("no return value specified for GetAttachments")
89+ }
90+
91+ var r0 map[string][]string
92+ if returnFunc, ok := ret.Get(0).(func() map[string][]string); ok {
93+ r0 = returnFunc()
94+ } else {
95+ if ret.Get(0) != nil {
96+ r0 = ret.Get(0).(map[string][]string)
97+ }
98+ }
99+ return r0
100+}
101+
102+// MockAttachmentConfigProvider_GetAttachments_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAttachments'
103+type MockAttachmentConfigProvider_GetAttachments_Call struct {
104+ *mock.Call
105+}
106+
107+// GetAttachments is a helper method to define mock.On call
108+func (_e *MockAttachmentConfigProvider_Expecter) GetAttachments() *MockAttachmentConfigProvider_GetAttachments_Call {
109+ return &MockAttachmentConfigProvider_GetAttachments_Call{Call: _e.mock.On("GetAttachments")}
110+}
111+
112+func (_c *MockAttachmentConfigProvider_GetAttachments_Call) Run(run func()) *MockAttachmentConfigProvider_GetAttachments_Call {
113+ _c.Call.Run(func(args mock.Arguments) {
114+ run()
115+ })
116+ return _c
117+}
118+
119+func (_c *MockAttachmentConfigProvider_GetAttachments_Call) Return(stringToStrings map[string][]string) *MockAttachmentConfigProvider_GetAttachments_Call {
120+ _c.Call.Return(stringToStrings)
121+ return _c
122+}
123+
124+func (_c *MockAttachmentConfigProvider_GetAttachments_Call) RunAndReturn(run func() map[string][]string) *MockAttachmentConfigProvider_GetAttachments_Call {
125+ _c.Call.Return(run)
126+ return _c
127+}
128+
129+// GetNetworkGroups provides a mock function for the type MockAttachmentConfigProvider
130+func (_mock *MockAttachmentConfigProvider) GetNetworkGroups() map[string][]string {
131+ ret := _mock.Called()
132+
133+ if len(ret) == 0 {
134+ panic("no return value specified for GetNetworkGroups")
135+ }
136+
137+ var r0 map[string][]string
138+ if returnFunc, ok := ret.Get(0).(func() map[string][]string); ok {
139+ r0 = returnFunc()
140+ } else {
141+ if ret.Get(0) != nil {
142+ r0 = ret.Get(0).(map[string][]string)
143+ }
144+ }
145+ return r0
146+}
147+
148+// MockAttachmentConfigProvider_GetNetworkGroups_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetNetworkGroups'
149+type MockAttachmentConfigProvider_GetNetworkGroups_Call struct {
150+ *mock.Call
151+}
152+
153+// GetNetworkGroups is a helper method to define mock.On call
154+func (_e *MockAttachmentConfigProvider_Expecter) GetNetworkGroups() *MockAttachmentConfigProvider_GetNetworkGroups_Call {
155+ return &MockAttachmentConfigProvider_GetNetworkGroups_Call{Call: _e.mock.On("GetNetworkGroups")}
156+}
157+
158+func (_c *MockAttachmentConfigProvider_GetNetworkGroups_Call) Run(run func()) *MockAttachmentConfigProvider_GetNetworkGroups_Call {
159+ _c.Call.Run(func(args mock.Arguments) {
160+ run()
161+ })
162+ return _c
163+}
164+
165+func (_c *MockAttachmentConfigProvider_GetNetworkGroups_Call) Return(stringToStrings map[string][]string) *MockAttachmentConfigProvider_GetNetworkGroups_Call {
166+ _c.Call.Return(stringToStrings)
167+ return _c
168+}
169+
170+func (_c *MockAttachmentConfigProvider_GetNetworkGroups_Call) RunAndReturn(run func() map[string][]string) *MockAttachmentConfigProvider_GetNetworkGroups_Call {
171+ _c.Call.Return(run)
172+ return _c
173+}
M internal/domain/errors.go +1 -0
@@ -71,4 +71,5 @@ var (
7171 ErrAttachmentExists = errors.New("attachment already exists")
7272 ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty")
7373 ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty")
74+ ErrAttachmentNotDeployed = errors.New("configured attachment is not deployed; run gordon deploy to create it")
7475 )
@@ -71,4 +71,5 @@ var (
71 ErrAttachmentExists = errors.New("attachment already exists")71 ErrAttachmentExists = errors.New("attachment already exists")
72 ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty")72 ErrAttachmentImageEmpty = errors.New("attachment image cannot be empty")
73 ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty")73 ErrAttachmentTargetEmpty = errors.New("attachment target (domain or group) cannot be empty")
74+ ErrAttachmentNotDeployed = errors.New("configured attachment is not deployed; run gordon deploy to create it")
74 )75 )
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()
A internal/usecase/container/config_provider.go +7 -0
@@ -0,0 +1,7 @@
1+package container
2+
3+import "github.com/bnema/gordon/internal/boundaries/out"
4+
5+// AttachmentConfigProvider is an alias for the boundary interface, kept for
6+// internal readability.
7+type AttachmentConfigProvider = out.AttachmentConfigProvider
@@ -0,0 +1,7 @@
1+package container
2+
3+import "github.com/bnema/gordon/internal/boundaries/out"
4+
5+// AttachmentConfigProvider is an alias for the boundary interface, kept for
6+// internal readability.
7+type AttachmentConfigProvider = out.AttachmentConfigProvider
M internal/usecase/container/routes_test.go +5 -5
@@ -12,7 +12,7 @@ import (
1212
1313 func TestService_ListRoutesWithDetails(t *testing.T) {
1414 runtime := mocks.NewMockContainerRuntime(t)
15- svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
15+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}, nil)
1616 ctx := testContext()
1717
1818 svc.containers["app.example.com"] = &domain.Container{
@@ -80,7 +80,7 @@ func TestService_ListRoutesWithDetails(t *testing.T) {
8080
8181 func TestService_ListRoutesWithDetails_Empty(t *testing.T) {
8282 runtime := mocks.NewMockContainerRuntime(t)
83- svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
83+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}, nil)
8484 ctx := testContext()
8585
8686 // getAllAttachments is called even with empty containers
@@ -93,7 +93,7 @@ func TestService_ListRoutesWithDetails_Empty(t *testing.T) {
9393
9494 func TestService_ListRoutesWithDetails_NetworkError(t *testing.T) {
9595 runtime := mocks.NewMockContainerRuntime(t)
96- svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
96+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}, nil)
9797 ctx := testContext()
9898
9999 svc.containers["app.example.com"] = &domain.Container{
@@ -113,7 +113,7 @@ func TestService_ListRoutesWithDetails_NetworkError(t *testing.T) {
113113
114114 func TestService_ListAttachments(t *testing.T) {
115115 runtime := mocks.NewMockContainerRuntime(t)
116- svc := NewService(runtime, nil, nil, nil, Config{})
116+ svc := NewService(runtime, nil, nil, nil, Config{}, nil)
117117 ctx := testContext()
118118
119119 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
@@ -153,7 +153,7 @@ func TestService_ListAttachments(t *testing.T) {
153153
154154 func TestService_ListNetworks(t *testing.T) {
155155 runtime := mocks.NewMockContainerRuntime(t)
156- svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})
156+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}, nil)
157157 ctx := testContext()
158158
159159 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
@@ -12,7 +12,7 @@ import (
12 12
13 func TestService_ListRoutesWithDetails(t *testing.T) {13 func TestService_ListRoutesWithDetails(t *testing.T) {
14 runtime := mocks.NewMockContainerRuntime(t)14 runtime := mocks.NewMockContainerRuntime(t)
15- svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})15+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}, nil)
16 ctx := testContext()16 ctx := testContext()
17 17
18 svc.containers["app.example.com"] = &domain.Container{18 svc.containers["app.example.com"] = &domain.Container{
@@ -80,7 +80,7 @@ func TestService_ListRoutesWithDetails(t *testing.T) {
80 80
81 func TestService_ListRoutesWithDetails_Empty(t *testing.T) {81 func TestService_ListRoutesWithDetails_Empty(t *testing.T) {
82 runtime := mocks.NewMockContainerRuntime(t)82 runtime := mocks.NewMockContainerRuntime(t)
83- svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})83+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}, nil)
84 ctx := testContext()84 ctx := testContext()
85 85
86 // getAllAttachments is called even with empty containers86 // getAllAttachments is called even with empty containers
@@ -93,7 +93,7 @@ func TestService_ListRoutesWithDetails_Empty(t *testing.T) {
93 93
94 func TestService_ListRoutesWithDetails_NetworkError(t *testing.T) {94 func TestService_ListRoutesWithDetails_NetworkError(t *testing.T) {
95 runtime := mocks.NewMockContainerRuntime(t)95 runtime := mocks.NewMockContainerRuntime(t)
96- svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})96+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}, nil)
97 ctx := testContext()97 ctx := testContext()
98 98
99 svc.containers["app.example.com"] = &domain.Container{99 svc.containers["app.example.com"] = &domain.Container{
@@ -113,7 +113,7 @@ func TestService_ListRoutesWithDetails_NetworkError(t *testing.T) {
113 113
114 func TestService_ListAttachments(t *testing.T) {114 func TestService_ListAttachments(t *testing.T) {
115 runtime := mocks.NewMockContainerRuntime(t)115 runtime := mocks.NewMockContainerRuntime(t)
116- svc := NewService(runtime, nil, nil, nil, Config{})116+ svc := NewService(runtime, nil, nil, nil, Config{}, nil)
117 ctx := testContext()117 ctx := testContext()
118 118
119 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{119 runtime.EXPECT().ListContainers(mock.Anything, true).Return([]*domain.Container{
@@ -153,7 +153,7 @@ func TestService_ListAttachments(t *testing.T) {
153 153
154 func TestService_ListNetworks(t *testing.T) {154 func TestService_ListNetworks(t *testing.T) {
155 runtime := mocks.NewMockContainerRuntime(t)155 runtime := mocks.NewMockContainerRuntime(t)
156- svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"})156+ svc := NewService(runtime, nil, nil, nil, Config{NetworkPrefix: "gordon"}, nil)
157 ctx := testContext()157 ctx := testContext()
158 158
159 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{159 runtime.EXPECT().ListNetworks(mock.Anything).Return([]*domain.NetworkInfo{
M internal/usecase/container/service.go +106 -40
@@ -76,6 +76,7 @@ type Service struct {
7676 cacheInvalidator out.ProxyCacheInvalidator
7777 drainWaiter out.ProxyDrainWaiter
7878 config Config
79+ configProvider AttachmentConfigProvider // live config reads for attachments/networks (may be nil)
7980 metrics *telemetry.Metrics
8081 containers map[string]*domain.Container
8182 attachments map[string][]string // ownerDomain → []containerIDs
@@ -136,15 +137,17 @@ func NewService(
136137 eventBus out.EventPublisher,
137138 logWriter out.ContainerLogWriter,
138139 config Config,
140+ configProvider AttachmentConfigProvider,
139141 ) *Service {
140142 return &Service{
141- runtime: runtime,
142- envLoader: envLoader,
143- eventBus: eventBus,
144- logWriter: logWriter,
145- config: config,
146- containers: make(map[string]*domain.Container),
147- attachments: make(map[string][]string),
143+ runtime: runtime,
144+ envLoader: envLoader,
145+ eventBus: eventBus,
146+ logWriter: logWriter,
147+ config: config,
148+ configProvider: configProvider,
149+ containers: make(map[string]*domain.Container),
150+ attachments: make(map[string][]string),
148151 }
149152 }
150153
@@ -787,6 +790,44 @@ func (s *Service) waitForDrain(ctx context.Context, oldContainerID string) {
787790 }
788791 }
789792
793+// restartContainerWithRecovery restarts a container, reconciling stale state if needed.
794+// It returns the (possibly refreshed) container and updated attachment IDs.
795+func (s *Service) restartContainerWithRecovery(ctx context.Context, domainName string, container *domain.Container, attachmentIDs []string) (*domain.Container, []string, error) {
796+ log := zerowrap.FromCtx(ctx)
797+
798+ if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
799+ if !isContainerNotFoundError(err) {
800+ return nil, nil, log.WrapErr(err, "failed to restart container")
801+ }
802+
803+ // In-memory container ID can become stale after external runtime changes.
804+ // Re-sync and retry once with the latest tracked container.
805+ log.Warn().
806+ Err(err).
807+ Str(zerowrap.FieldEntityID, container.ID).
808+ Msg("tracked container missing during restart, attempting state reconciliation")
809+
810+ if syncErr := s.SyncContainers(ctx); syncErr != nil {
811+ log.Warn().Err(syncErr).Msg("failed to sync container state during restart recovery")
812+ }
813+
814+ s.mu.RLock()
815+ refreshed, refreshedExists := s.containers[domainName]
816+ attachmentIDs = append([]string{}, s.attachments[domainName]...)
817+ s.mu.RUnlock()
818+
819+ if !refreshedExists || refreshed == nil {
820+ return nil, nil, domain.ErrContainerNotFound
821+ }
822+ if err := s.runtime.RestartContainer(ctx, refreshed.ID); err != nil {
823+ return nil, nil, log.WrapErr(err, "failed to restart container after state reconciliation")
824+ }
825+ return refreshed, attachmentIDs, nil
826+ }
827+
828+ return container, attachmentIDs, nil
829+}
830+
790831 // Restart restarts a running container for the given domain.
791832 func (s *Service) Restart(ctx context.Context, domainName string, withAttachments bool) error {
792833 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -810,35 +851,38 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment
810851 return domain.ErrContainerNotFound
811852 }
812853
813- // Restart the main container
814- if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
815- if !isContainerNotFoundError(err) {
816- return log.WrapErr(err, "failed to restart container")
817- }
818-
819- // In-memory container ID can become stale after external runtime changes.
820- // Re-sync and retry once with the latest tracked container.
821- log.Warn().
822- Err(err).
823- Str(zerowrap.FieldEntityID, container.ID).
824- Msg("tracked container missing during restart, attempting state reconciliation")
825-
854+ // When attachments are requested, sync runtime state first to get accurate
855+ // attachment tracking, then check if configured attachments are deployed.
856+ // If fewer attachment containers are tracked than configured, fail early with
857+ // guidance rather than restarting the main container into a broken state
858+ // (e.g., missing database hostname).
859+ if withAttachments {
826860 if syncErr := s.SyncContainers(ctx); syncErr != nil {
827- log.Warn().Err(syncErr).Msg("failed to sync container state during restart recovery")
861+ log.Warn().Err(syncErr).Msg("failed to sync container state before attachment check")
828862 }
829863
864+ // Re-read attachment IDs after sync
830865 s.mu.RLock()
831- refreshed, refreshedExists := s.containers[domainName]
832- attachmentIDs = append([]string{}, s.attachments[domainName]...)
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+ }
833872 s.mu.RUnlock()
834873
835- if !refreshedExists || refreshed == nil {
836- return domain.ErrContainerNotFound
874+ configuredAttachments := s.resolveAttachmentsForDomain(domainName)
875+ if len(configuredAttachments) > 0 && len(attachmentIDs) < len(configuredAttachments) {
876+ missing := len(configuredAttachments) - len(attachmentIDs)
877+ return fmt.Errorf("%w: domain %q has %d configured attachment(s) but only %d deployed (%d missing)",
878+ domain.ErrAttachmentNotDeployed, domainName, len(configuredAttachments), len(attachmentIDs), missing)
837879 }
838- if err := s.runtime.RestartContainer(ctx, refreshed.ID); err != nil {
839- return log.WrapErr(err, "failed to restart container after state reconciliation")
840- }
841- container = refreshed
880+ }
881+
882+ // Restart the main container (with stale-state recovery)
883+ container, attachmentIDs, err := s.restartContainerWithRecovery(ctx, domainName, container, attachmentIDs)
884+ if err != nil {
885+ return err
842886 }
843887 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
844888
@@ -1773,15 +1817,27 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
17731817 }
17741818
17751819 func (s *Service) getNetworkForApp(domainName string) string {
1776- s.mu.RLock()
1777- cfg := s.config
1778- s.mu.RUnlock()
1820+ var networkIsolation bool
1821+ var networkGroups map[string][]string
1822+
1823+ if s.configProvider != nil {
1824+ snap := s.configProvider.GetAttachmentConfig()
1825+ networkGroups = snap.NetworkGroups
1826+ s.mu.RLock()
1827+ networkIsolation = s.config.NetworkIsolation
1828+ s.mu.RUnlock()
1829+ } else {
1830+ s.mu.RLock()
1831+ networkIsolation = s.config.NetworkIsolation
1832+ networkGroups = s.config.NetworkGroups
1833+ s.mu.RUnlock()
1834+ }
17791835
1780- if !cfg.NetworkIsolation {
1836+ if !networkIsolation {
17811837 return "bridge"
17821838 }
17831839
1784- for groupName, domains := range cfg.NetworkGroups {
1840+ for groupName, domains := range networkGroups {
17851841 if slices.Contains(domains, domainName) {
17861842 return s.generateNetworkName(groupName)
17871843 }
@@ -1949,15 +2005,25 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
19492005 // 1. Direct domain attachments (attachments[domain])
19502006 // 2. Network group attachments (attachments[group] where domain is in network_groups[group])
19512007 func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1952- s.mu.RLock()
1953- cfg := s.config
1954- s.mu.RUnlock()
2008+ var attachments map[string][]string
2009+ var networkGroups map[string][]string
2010+
2011+ if s.configProvider != nil {
2012+ snap := s.configProvider.GetAttachmentConfig()
2013+ attachments = snap.Attachments
2014+ networkGroups = snap.NetworkGroups
2015+ } else {
2016+ s.mu.RLock()
2017+ attachments = s.config.Attachments
2018+ networkGroups = s.config.NetworkGroups
2019+ s.mu.RUnlock()
2020+ }
19552021
19562022 seen := make(map[string]bool)
19572023 var result []string
19582024
19592025 // First, add domain-specific attachments
1960- if domainAttachments, ok := cfg.Attachments[domainName]; ok {
2026+ if domainAttachments, ok := attachments[domainName]; ok {
19612027 for _, img := range domainAttachments {
19622028 if !seen[img] {
19632029 seen[img] = true
@@ -1967,9 +2033,9 @@ func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
19672033 }
19682034
19692035 // Then, find which network group this domain belongs to and add group attachments
1970- for groupName, domains := range cfg.NetworkGroups {
2036+ for groupName, domains := range networkGroups {
19712037 if slices.Contains(domains, domainName) {
1972- if groupAttachments, ok := cfg.Attachments[groupName]; ok {
2038+ if groupAttachments, ok := attachments[groupName]; ok {
19732039 for _, img := range groupAttachments {
19742040 if !seen[img] {
19752041 seen[img] = true
@@ -76,6 +76,7 @@ type Service struct {
76 cacheInvalidator out.ProxyCacheInvalidator76 cacheInvalidator out.ProxyCacheInvalidator
77 drainWaiter out.ProxyDrainWaiter77 drainWaiter out.ProxyDrainWaiter
78 config Config78 config Config
79+ configProvider AttachmentConfigProvider // live config reads for attachments/networks (may be nil)
79 metrics *telemetry.Metrics80 metrics *telemetry.Metrics
80 containers map[string]*domain.Container81 containers map[string]*domain.Container
81 attachments map[string][]string // ownerDomain → []containerIDs82 attachments map[string][]string // ownerDomain → []containerIDs
@@ -136,15 +137,17 @@ func NewService(
136 eventBus out.EventPublisher,137 eventBus out.EventPublisher,
137 logWriter out.ContainerLogWriter,138 logWriter out.ContainerLogWriter,
138 config Config,139 config Config,
140+ configProvider AttachmentConfigProvider,
139 ) *Service {141 ) *Service {
140 return &Service{142 return &Service{
141- runtime: runtime,143+ runtime: runtime,
142- envLoader: envLoader,144+ envLoader: envLoader,
143- eventBus: eventBus,145+ eventBus: eventBus,
144- logWriter: logWriter,146+ logWriter: logWriter,
145- config: config,147+ config: config,
146- containers: make(map[string]*domain.Container),148+ configProvider: configProvider,
147- attachments: make(map[string][]string),149+ containers: make(map[string]*domain.Container),
150+ attachments: make(map[string][]string),
148 }151 }
149 }152 }
150 153
@@ -787,6 +790,44 @@ func (s *Service) waitForDrain(ctx context.Context, oldContainerID string) {
787 }790 }
788 }791 }
789 792
793+// restartContainerWithRecovery restarts a container, reconciling stale state if needed.
794+// It returns the (possibly refreshed) container and updated attachment IDs.
795+func (s *Service) restartContainerWithRecovery(ctx context.Context, domainName string, container *domain.Container, attachmentIDs []string) (*domain.Container, []string, error) {
796+ log := zerowrap.FromCtx(ctx)
797+
798+ if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
799+ if !isContainerNotFoundError(err) {
800+ return nil, nil, log.WrapErr(err, "failed to restart container")
801+ }
802+
803+ // In-memory container ID can become stale after external runtime changes.
804+ // Re-sync and retry once with the latest tracked container.
805+ log.Warn().
806+ Err(err).
807+ Str(zerowrap.FieldEntityID, container.ID).
808+ Msg("tracked container missing during restart, attempting state reconciliation")
809+
810+ if syncErr := s.SyncContainers(ctx); syncErr != nil {
811+ log.Warn().Err(syncErr).Msg("failed to sync container state during restart recovery")
812+ }
813+
814+ s.mu.RLock()
815+ refreshed, refreshedExists := s.containers[domainName]
816+ attachmentIDs = append([]string{}, s.attachments[domainName]...)
817+ s.mu.RUnlock()
818+
819+ if !refreshedExists || refreshed == nil {
820+ return nil, nil, domain.ErrContainerNotFound
821+ }
822+ if err := s.runtime.RestartContainer(ctx, refreshed.ID); err != nil {
823+ return nil, nil, log.WrapErr(err, "failed to restart container after state reconciliation")
824+ }
825+ return refreshed, attachmentIDs, nil
826+ }
827+
828+ return container, attachmentIDs, nil
829+}
830+
790 // Restart restarts a running container for the given domain.831 // Restart restarts a running container for the given domain.
791 func (s *Service) Restart(ctx context.Context, domainName string, withAttachments bool) error {832 func (s *Service) Restart(ctx context.Context, domainName string, withAttachments bool) error {
792 ctx = zerowrap.CtxWithFields(ctx, map[string]any{833 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
@@ -810,35 +851,38 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment
810 return domain.ErrContainerNotFound851 return domain.ErrContainerNotFound
811 }852 }
812 853
813- // Restart the main container854+ // When attachments are requested, sync runtime state first to get accurate
814- if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {855+ // attachment tracking, then check if configured attachments are deployed.
815- if !isContainerNotFoundError(err) {856+ // If fewer attachment containers are tracked than configured, fail early with
816- return log.WrapErr(err, "failed to restart container")857+ // guidance rather than restarting the main container into a broken state
817- }858+ // (e.g., missing database hostname).
818-859+ if withAttachments {
819- // In-memory container ID can become stale after external runtime changes.
820- // Re-sync and retry once with the latest tracked container.
821- log.Warn().
822- Err(err).
823- Str(zerowrap.FieldEntityID, container.ID).
824- Msg("tracked container missing during restart, attempting state reconciliation")
825-
826 if syncErr := s.SyncContainers(ctx); syncErr != nil {860 if syncErr := s.SyncContainers(ctx); syncErr != nil {
827- log.Warn().Err(syncErr).Msg("failed to sync container state during restart recovery")861+ log.Warn().Err(syncErr).Msg("failed to sync container state before attachment check")
828 }862 }
829 863
864+ // Re-read attachment IDs after sync
830 s.mu.RLock()865 s.mu.RLock()
831- refreshed, refreshedExists := s.containers[domainName]866+ if freshAttachments := s.attachments[domainName]; len(freshAttachments) > 0 {
832- attachmentIDs = append([]string{}, s.attachments[domainName]...)867+ attachmentIDs = make([]string, len(freshAttachments))
868+ copy(attachmentIDs, freshAttachments)
869+ } else {
870+ attachmentIDs = nil
871+ }
833 s.mu.RUnlock()872 s.mu.RUnlock()
834 873
835- if !refreshedExists || refreshed == nil {874+ configuredAttachments := s.resolveAttachmentsForDomain(domainName)
836- return domain.ErrContainerNotFound875+ if len(configuredAttachments) > 0 && len(attachmentIDs) < len(configuredAttachments) {
876+ missing := len(configuredAttachments) - len(attachmentIDs)
877+ return fmt.Errorf("%w: domain %q has %d configured attachment(s) but only %d deployed (%d missing)",
878+ domain.ErrAttachmentNotDeployed, domainName, len(configuredAttachments), len(attachmentIDs), missing)
837 }879 }
838- if err := s.runtime.RestartContainer(ctx, refreshed.ID); err != nil {880+ }
839- return log.WrapErr(err, "failed to restart container after state reconciliation")881+
840- }882+ // Restart the main container (with stale-state recovery)
841- container = refreshed883+ container, attachmentIDs, err := s.restartContainerWithRecovery(ctx, domainName, container, attachmentIDs)
884+ if err != nil {
885+ return err
842 }886 }
843 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")887 log.Info().Str(zerowrap.FieldEntityID, container.ID).Msg("container restarted")
844 888
@@ -1773,15 +1817,27 @@ func (s *Service) setupVolumes(ctx context.Context, domainName, imageRef string)
1773 }1817 }
1774 1818
1775 func (s *Service) getNetworkForApp(domainName string) string {1819 func (s *Service) getNetworkForApp(domainName string) string {
1776- s.mu.RLock()1820+ var networkIsolation bool
1777- cfg := s.config1821+ var networkGroups map[string][]string
1778- s.mu.RUnlock()1822+
1823+ if s.configProvider != nil {
1824+ snap := s.configProvider.GetAttachmentConfig()
1825+ networkGroups = snap.NetworkGroups
1826+ s.mu.RLock()
1827+ networkIsolation = s.config.NetworkIsolation
1828+ s.mu.RUnlock()
1829+ } else {
1830+ s.mu.RLock()
1831+ networkIsolation = s.config.NetworkIsolation
1832+ networkGroups = s.config.NetworkGroups
1833+ s.mu.RUnlock()
1834+ }
1779 1835
1780- if !cfg.NetworkIsolation {1836+ if !networkIsolation {
1781 return "bridge"1837 return "bridge"
1782 }1838 }
1783 1839
1784- for groupName, domains := range cfg.NetworkGroups {1840+ for groupName, domains := range networkGroups {
1785 if slices.Contains(domains, domainName) {1841 if slices.Contains(domains, domainName) {
1786 return s.generateNetworkName(groupName)1842 return s.generateNetworkName(groupName)
1787 }1843 }
@@ -1949,15 +2005,25 @@ func (s *Service) deployAttachments(ctx context.Context, domainName, networkName
1949 // 1. Direct domain attachments (attachments[domain])2005 // 1. Direct domain attachments (attachments[domain])
1950 // 2. Network group attachments (attachments[group] where domain is in network_groups[group])2006 // 2. Network group attachments (attachments[group] where domain is in network_groups[group])
1951 func (s *Service) resolveAttachmentsForDomain(domainName string) []string {2007 func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1952- s.mu.RLock()2008+ var attachments map[string][]string
1953- cfg := s.config2009+ var networkGroups map[string][]string
1954- s.mu.RUnlock()2010+
2011+ if s.configProvider != nil {
2012+ snap := s.configProvider.GetAttachmentConfig()
2013+ attachments = snap.Attachments
2014+ networkGroups = snap.NetworkGroups
2015+ } else {
2016+ s.mu.RLock()
2017+ attachments = s.config.Attachments
2018+ networkGroups = s.config.NetworkGroups
2019+ s.mu.RUnlock()
2020+ }
1955 2021
1956 seen := make(map[string]bool)2022 seen := make(map[string]bool)
1957 var result []string2023 var result []string
1958 2024
1959 // First, add domain-specific attachments2025 // First, add domain-specific attachments
1960- if domainAttachments, ok := cfg.Attachments[domainName]; ok {2026+ if domainAttachments, ok := attachments[domainName]; ok {
1961 for _, img := range domainAttachments {2027 for _, img := range domainAttachments {
1962 if !seen[img] {2028 if !seen[img] {
1963 seen[img] = true2029 seen[img] = true
@@ -1967,9 +2033,9 @@ func (s *Service) resolveAttachmentsForDomain(domainName string) []string {
1967 }2033 }
1968 2034
1969 // Then, find which network group this domain belongs to and add group attachments2035 // Then, find which network group this domain belongs to and add group attachments
1970- for groupName, domains := range cfg.NetworkGroups {2036+ for groupName, domains := range networkGroups {
1971 if slices.Contains(domains, domainName) {2037 if slices.Contains(domains, domainName) {
1972- if groupAttachments, ok := cfg.Attachments[groupName]; ok {2038+ if groupAttachments, ok := attachments[groupName]; ok {
1973 for _, img := range groupAttachments {2039 for _, img := range groupAttachments {
1974 if !seen[img] {2040 if !seen[img] {
1975 seen[img] = true2041 seen[img] = true
M internal/usecase/container/service_readiness_test.go +10 -10
@@ -31,7 +31,7 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T
3131 svc := NewService(runtime, nil, nil, nil, Config{
3232 ReadinessMode: "auto",
3333 ReadinessDelay: time.Millisecond,
34- })
34+ }, nil)
3535
3636 ctx := testContext()
3737 containerID := "container-1"
@@ -52,7 +52,7 @@ func TestService_WaitForReady_AutoCascadeUsesDefaultHTTPAliveProbeWhenNoHealthch
5252 svc := NewService(runtime, nil, nil, nil, Config{
5353 ReadinessMode: "auto",
5454 HTTPProbeTimeout: 50 * time.Millisecond,
55- })
55+ }, nil)
5656
5757 ctx := testContext()
5858 containerID := "container-1"
@@ -87,7 +87,7 @@ func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *test
8787 svc := NewService(runtime, nil, nil, nil, Config{
8888 ReadinessMode: "auto",
8989 HTTPProbeTimeout: 50 * time.Millisecond,
90- })
90+ }, nil)
9191
9292 ctx := testContext()
9393 containerID := "container-1"
@@ -122,7 +122,7 @@ func TestService_WaitForReady_AutoCascadeUsesHealthcheckWhenPresent(t *testing.T
122122 svc := NewService(runtime, nil, nil, nil, Config{
123123 ReadinessMode: "auto",
124124 HealthTimeout: 50 * time.Millisecond,
125- })
125+ }, nil)
126126
127127 ctx := testContext()
128128 containerID := "container-1"
@@ -151,7 +151,7 @@ func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T
151151 svc := NewService(runtime, nil, nil, nil, Config{
152152 ReadinessMode: "auto",
153153 ReadinessDelay: time.Millisecond,
154- })
154+ }, nil)
155155
156156 ctx := testContext()
157157 containerID := "container-1"
@@ -180,7 +180,7 @@ func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) {
180180 svc := NewService(runtime, nil, nil, nil, Config{
181181 ReadinessMode: "delay",
182182 ReadinessDelay: time.Millisecond,
183- })
183+ }, nil)
184184
185185 ctx := testContext()
186186 containerID := "container-1"
@@ -205,7 +205,7 @@ func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) {
205205
206206 func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) {
207207 runtime := mocks.NewMockContainerRuntime(t)
208- svc := NewService(runtime, nil, nil, nil, Config{})
208+ svc := NewService(runtime, nil, nil, nil, Config{}, nil)
209209
210210 ctx := testContext()
211211 containerID := "container-1"
@@ -222,7 +222,7 @@ func TestService_WaitForReady_ProbeUsesGordonProxyPortLabel(t *testing.T) {
222222 svc := NewService(runtime, nil, nil, nil, Config{
223223 ReadinessMode: "auto",
224224 HTTPProbeTimeout: 50 * time.Millisecond,
225- })
225+ }, nil)
226226
227227 ctx := testContext()
228228 containerID := "container-port-label"
@@ -255,7 +255,7 @@ func TestService_WaitForReady_ProbeUsesDeprecatedPortLabel(t *testing.T) {
255255 svc := NewService(runtime, nil, nil, nil, Config{
256256 ReadinessMode: "auto",
257257 HTTPProbeTimeout: 50 * time.Millisecond,
258- })
258+ }, nil)
259259
260260 ctx := testContext()
261261 containerID := "container-proxy-port"
@@ -286,7 +286,7 @@ func TestService_WaitForReady_ProxyPortTakesPriorityOverPort(t *testing.T) {
286286 svc := NewService(runtime, nil, nil, nil, Config{
287287 ReadinessMode: "auto",
288288 HTTPProbeTimeout: 50 * time.Millisecond,
289- })
289+ }, nil)
290290
291291 ctx := testContext()
292292 containerID := "container-priority"
@@ -31,7 +31,7 @@ func TestService_WaitForReady_AutoFallsBackToDelayWhenNoHealthcheck(t *testing.T
31 svc := NewService(runtime, nil, nil, nil, Config{31 svc := NewService(runtime, nil, nil, nil, Config{
32 ReadinessMode: "auto",32 ReadinessMode: "auto",
33 ReadinessDelay: time.Millisecond,33 ReadinessDelay: time.Millisecond,
34- })34+ }, nil)
35 35
36 ctx := testContext()36 ctx := testContext()
37 containerID := "container-1"37 containerID := "container-1"
@@ -52,7 +52,7 @@ func TestService_WaitForReady_AutoCascadeUsesDefaultHTTPAliveProbeWhenNoHealthch
52 svc := NewService(runtime, nil, nil, nil, Config{52 svc := NewService(runtime, nil, nil, nil, Config{
53 ReadinessMode: "auto",53 ReadinessMode: "auto",
54 HTTPProbeTimeout: 50 * time.Millisecond,54 HTTPProbeTimeout: 50 * time.Millisecond,
55- })55+ }, nil)
56 56
57 ctx := testContext()57 ctx := testContext()
58 containerID := "container-1"58 containerID := "container-1"
@@ -87,7 +87,7 @@ func TestService_WaitForReady_AutoCascadeUsesHTTPProbeWhenHealthLabelSet(t *test
87 svc := NewService(runtime, nil, nil, nil, Config{87 svc := NewService(runtime, nil, nil, nil, Config{
88 ReadinessMode: "auto",88 ReadinessMode: "auto",
89 HTTPProbeTimeout: 50 * time.Millisecond,89 HTTPProbeTimeout: 50 * time.Millisecond,
90- })90+ }, nil)
91 91
92 ctx := testContext()92 ctx := testContext()
93 containerID := "container-1"93 containerID := "container-1"
@@ -122,7 +122,7 @@ func TestService_WaitForReady_AutoCascadeUsesHealthcheckWhenPresent(t *testing.T
122 svc := NewService(runtime, nil, nil, nil, Config{122 svc := NewService(runtime, nil, nil, nil, Config{
123 ReadinessMode: "auto",123 ReadinessMode: "auto",
124 HealthTimeout: 50 * time.Millisecond,124 HealthTimeout: 50 * time.Millisecond,
125- })125+ }, nil)
126 126
127 ctx := testContext()127 ctx := testContext()
128 containerID := "container-1"128 containerID := "container-1"
@@ -151,7 +151,7 @@ func TestService_WaitForReady_AutoCascadeFallsToDelayWhenNoEndpoint(t *testing.T
151 svc := NewService(runtime, nil, nil, nil, Config{151 svc := NewService(runtime, nil, nil, nil, Config{
152 ReadinessMode: "auto",152 ReadinessMode: "auto",
153 ReadinessDelay: time.Millisecond,153 ReadinessDelay: time.Millisecond,
154- })154+ }, nil)
155 155
156 ctx := testContext()156 ctx := testContext()
157 containerID := "container-1"157 containerID := "container-1"
@@ -180,7 +180,7 @@ func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) {
180 svc := NewService(runtime, nil, nil, nil, Config{180 svc := NewService(runtime, nil, nil, nil, Config{
181 ReadinessMode: "delay",181 ReadinessMode: "delay",
182 ReadinessDelay: time.Millisecond,182 ReadinessDelay: time.Millisecond,
183- })183+ }, nil)
184 184
185 ctx := testContext()185 ctx := testContext()
186 containerID := "container-1"186 containerID := "container-1"
@@ -205,7 +205,7 @@ func TestService_WaitForReady_ExplicitDelayModeIgnoresCascade(t *testing.T) {
205 205
206 func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) {206 func TestService_WaitForHealthy_EmptyStatusIsReportedAsStarting(t *testing.T) {
207 runtime := mocks.NewMockContainerRuntime(t)207 runtime := mocks.NewMockContainerRuntime(t)
208- svc := NewService(runtime, nil, nil, nil, Config{})208+ svc := NewService(runtime, nil, nil, nil, Config{}, nil)
209 209
210 ctx := testContext()210 ctx := testContext()
211 containerID := "container-1"211 containerID := "container-1"
@@ -222,7 +222,7 @@ func TestService_WaitForReady_ProbeUsesGordonProxyPortLabel(t *testing.T) {
222 svc := NewService(runtime, nil, nil, nil, Config{222 svc := NewService(runtime, nil, nil, nil, Config{
223 ReadinessMode: "auto",223 ReadinessMode: "auto",
224 HTTPProbeTimeout: 50 * time.Millisecond,224 HTTPProbeTimeout: 50 * time.Millisecond,
225- })225+ }, nil)
226 226
227 ctx := testContext()227 ctx := testContext()
228 containerID := "container-port-label"228 containerID := "container-port-label"
@@ -255,7 +255,7 @@ func TestService_WaitForReady_ProbeUsesDeprecatedPortLabel(t *testing.T) {
255 svc := NewService(runtime, nil, nil, nil, Config{255 svc := NewService(runtime, nil, nil, nil, Config{
256 ReadinessMode: "auto",256 ReadinessMode: "auto",
257 HTTPProbeTimeout: 50 * time.Millisecond,257 HTTPProbeTimeout: 50 * time.Millisecond,
258- })258+ }, nil)
259 259
260 ctx := testContext()260 ctx := testContext()
261 containerID := "container-proxy-port"261 containerID := "container-proxy-port"
@@ -286,7 +286,7 @@ func TestService_WaitForReady_ProxyPortTakesPriorityOverPort(t *testing.T) {
286 svc := NewService(runtime, nil, nil, nil, Config{286 svc := NewService(runtime, nil, nil, nil, Config{
287 ReadinessMode: "auto",287 ReadinessMode: "auto",
288 HTTPProbeTimeout: 50 * time.Millisecond,288 HTTPProbeTimeout: 50 * time.Millisecond,
289- })289+ }, nil)
290 290
291 ctx := testContext()291 ctx := testContext()
292 containerID := "container-priority"292 containerID := "container-priority"
M internal/usecase/container/service_test.go +369 -61
@@ -17,7 +17,8 @@ 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/mocks"
20+ "github.com/bnema/gordon/internal/boundaries/out"
21+ mocks "github.com/bnema/gordon/internal/boundaries/out/mocks"
2122 "github.com/bnema/gordon/internal/domain"
2223 )
2324
@@ -80,12 +81,123 @@ func managedMetricState(t *testing.T, reader *sdkmetric.ManualReader) (int64, in
8081 return 0, 0, nil
8182 }
8283
84+func TestService_NewService_WithConfigProvider(t *testing.T) {
85+ runtime := mocks.NewMockContainerRuntime(t)
86+ envLoader := mocks.NewMockEnvLoader(t)
87+ eventBus := mocks.NewMockEventPublisher(t)
88+
89+ provider := mocks.NewMockAttachmentConfigProvider(t)
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()
94+
95+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
96+ assert.NotNil(t, svc)
97+}
98+
99+func TestService_ResolveAttachments_UsesLiveConfigProvider(t *testing.T) {
100+ runtime := mocks.NewMockContainerRuntime(t)
101+ envLoader := mocks.NewMockEnvLoader(t)
102+ eventBus := mocks.NewMockEventPublisher(t)
103+
104+ provider := mocks.NewMockAttachmentConfigProvider(t)
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()
113+
114+ // Create service with EMPTY static config but a live provider
115+ svc := NewService(runtime, envLoader, eventBus, nil, Config{
116+ NetworkIsolation: true,
117+ NetworkPrefix: "gordon",
118+ }, provider)
119+
120+ // resolveAttachmentsForDomain should return the provider's data, not the empty snapshot
121+ result := svc.resolveAttachmentsForDomain("app.example.com")
122+ assert.Equal(t, []string{"postgres:16"}, result)
123+
124+ result = svc.resolveAttachmentsForDomain("app.example.com")
125+ assert.Equal(t, []string{"postgres:16", "redis:7"}, result)
126+}
127+
128+func TestService_ResolveAttachments_GroupBasedViaProvider(t *testing.T) {
129+ provider := mocks.NewMockAttachmentConfigProvider(t)
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()
138+
139+ svc := NewService(
140+ mocks.NewMockContainerRuntime(t),
141+ mocks.NewMockEnvLoader(t),
142+ mocks.NewMockEventPublisher(t),
143+ nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider,
144+ )
145+
146+ // Domain in group should see group attachments
147+ result := svc.resolveAttachmentsForDomain("app1.example.com")
148+ assert.Equal(t, []string{"postgres:16"}, result)
149+
150+ // Domain not in any group should see nothing
151+ result = svc.resolveAttachmentsForDomain("other.example.com")
152+ assert.Empty(t, result)
153+}
154+
155+func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) {
156+ provider := mocks.NewMockAttachmentConfigProvider(t)
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+ },
162+ }).Once()
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+ },
168+ }).Once()
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+ },
174+ }).Once()
175+
176+ svc := NewService(
177+ mocks.NewMockContainerRuntime(t),
178+ mocks.NewMockEnvLoader(t),
179+ mocks.NewMockEventPublisher(t),
180+ nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider,
181+ )
182+
183+ // Should use group network: generateNetworkName produces "gordon-shared-group"
184+ network := svc.getNetworkForApp("app1.example.com")
185+ assert.Equal(t, "gordon-shared-group", network)
186+
187+ network = svc.getNetworkForApp("app3.example.com")
188+ assert.Equal(t, "gordon-shared-group", network)
189+
190+ // Domain not in any group gets its own network: "gordon-solo-example-com"
191+ network = svc.getNetworkForApp("solo.example.com")
192+ assert.Equal(t, "gordon-solo-example-com", network)
193+}
194+
83195 func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {
84196 runtime := mocks.NewMockContainerRuntime(t)
85197 envLoader := mocks.NewMockEnvLoader(t)
86198 eventBus := mocks.NewMockEventPublisher(t)
87199
88- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
200+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
89201 ctx := testContext()
90202 metrics, reader := setupMetricsTest(t)
91203 svc.SetMetrics(metrics)
@@ -119,7 +231,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T)
119231 envLoader := mocks.NewMockEnvLoader(t)
120232 eventBus := mocks.NewMockEventPublisher(t)
121233
122- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
234+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
123235 ctx := testContext()
124236 metrics, reader := setupMetricsTest(t)
125237 svc.SetMetrics(metrics)
@@ -202,7 +314,7 @@ func TestService_Deploy_Success(t *testing.T) {
202314 DrainDelayConfigured: true,
203315 }
204316
205- svc := NewService(runtime, envLoader, eventBus, nil, config)
317+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
206318 ctx := testContext()
207319
208320 route := domain.Route{
@@ -284,7 +396,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
284396 DrainDelayConfigured: true,
285397 }
286398
287- svc := NewService(runtime, envLoader, eventBus, nil, config)
399+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
288400 ctx := testContext()
289401
290402 route := domain.Route{
@@ -342,7 +454,7 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
342454 eventBus := mocks.NewMockEventPublisher(t)
343455
344456 config := Config{}
345- svc := NewService(runtime, envLoader, eventBus, nil, config)
457+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
346458 ctx := testContext()
347459
348460 route := domain.Route{
@@ -373,7 +485,7 @@ func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
373485 ServiceToken: "service-token",
374486 }
375487
376- svc := NewService(runtime, nil, nil, nil, config)
488+ svc := NewService(runtime, nil, nil, nil, config, nil)
377489 ctx := testContext()
378490
379491 runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)
@@ -390,7 +502,7 @@ func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
390502 RegistryAuthEnabled: true,
391503 }
392504
393- svc := NewService(runtime, nil, nil, nil, config)
505+ svc := NewService(runtime, nil, nil, nil, config, nil)
394506 ctx := testContext()
395507
396508 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
@@ -408,7 +520,7 @@ func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
408520 InternalRegistryPassword: "secret",
409521 }
410522
411- svc := NewService(runtime, nil, nil, nil, config)
523+ svc := NewService(runtime, nil, nil, nil, config, nil)
412524 ctx := testContext()
413525
414526 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").
@@ -435,7 +547,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
435547 DrainDelayConfigured: true,
436548 StabilizationDelay: time.Millisecond,
437549 }
438- svc := NewService(runtime, envLoader, eventBus, nil, config)
550+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
439551 svc.SetProxyCacheInvalidator(cacheInvalidator)
440552 ctx := testContext()
441553
@@ -511,7 +623,7 @@ func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T
511623 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
512624
513625 config := testMinDelayConfig()
514- svc := NewService(runtime, envLoader, eventBus, nil, config)
626+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
515627 svc.SetProxyCacheInvalidator(cacheInvalidator)
516628 ctx := testContext()
517629
@@ -608,7 +720,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
608720 eventBus := mocks.NewMockEventPublisher(t)
609721
610722 config := testMinDelayConfig()
611- svc := NewService(runtime, envLoader, eventBus, nil, config)
723+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
612724 ctx := testContext()
613725 expectedEnvHash := hashEnvironment([]string{})
614726
@@ -675,7 +787,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
675787 DrainDelay: time.Millisecond,
676788 DrainDelayConfigured: true,
677789 }
678- svc := NewService(runtime, envLoader, eventBus, nil, config)
790+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
679791 ctx := testContext()
680792
681793 route := domain.Route{
@@ -732,7 +844,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
732844 DrainDelay: time.Millisecond,
733845 DrainDelayConfigured: true,
734846 }
735- svc := NewService(runtime, envLoader, eventBus, nil, config)
847+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
736848 ctx := testContext()
737849
738850 route := domain.Route{
@@ -784,7 +896,7 @@ func TestService_Stop(t *testing.T) {
784896 envLoader := mocks.NewMockEnvLoader(t)
785897 eventBus := mocks.NewMockEventPublisher(t)
786898
787- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
899+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
788900 ctx := testContext()
789901
790902 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)
@@ -799,7 +911,7 @@ func TestService_Stop_Error(t *testing.T) {
799911 envLoader := mocks.NewMockEnvLoader(t)
800912 eventBus := mocks.NewMockEventPublisher(t)
801913
802- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
914+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
803915 ctx := testContext()
804916
805917 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))
@@ -815,7 +927,7 @@ func TestService_Remove(t *testing.T) {
815927 envLoader := mocks.NewMockEnvLoader(t)
816928 eventBus := mocks.NewMockEventPublisher(t)
817929
818- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
930+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
819931 ctx := testContext()
820932
821933 // Add tracked container
@@ -844,7 +956,7 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) {
844956 NetworkIsolation: true,
845957 NetworkPrefix: "gordon",
846958 }
847- svc := NewService(runtime, envLoader, eventBus, nil, config)
959+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
848960 ctx := testContext()
849961
850962 svc.containers["test.example.com"] = &domain.Container{
@@ -868,7 +980,7 @@ func TestService_Get(t *testing.T) {
868980 envLoader := mocks.NewMockEnvLoader(t)
869981 eventBus := mocks.NewMockEventPublisher(t)
870982
871- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
983+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
872984 ctx := testContext()
873985
874986 container := &domain.Container{ID: "container-123"}
@@ -885,7 +997,7 @@ func TestService_Get_NotFound(t *testing.T) {
885997 envLoader := mocks.NewMockEnvLoader(t)
886998 eventBus := mocks.NewMockEventPublisher(t)
887999
888- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1000+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
8891001 ctx := testContext()
8901002
8911003 result, exists := svc.Get(ctx, "nonexistent.example.com")
@@ -899,7 +1011,7 @@ func TestService_List(t *testing.T) {
8991011 envLoader := mocks.NewMockEnvLoader(t)
9001012 eventBus := mocks.NewMockEventPublisher(t)
9011013
902- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1014+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
9031015 ctx := testContext()
9041016
9051017 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
@@ -917,7 +1029,7 @@ func TestService_HealthCheck(t *testing.T) {
9171029 envLoader := mocks.NewMockEnvLoader(t)
9181030 eventBus := mocks.NewMockEventPublisher(t)
9191031
920- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1032+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
9211033 ctx := testContext()
9221034
9231035 svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}
@@ -937,7 +1049,7 @@ func TestService_SyncContainers(t *testing.T) {
9371049 envLoader := mocks.NewMockEnvLoader(t)
9381050 eventBus := mocks.NewMockEventPublisher(t)
9391051
940- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1052+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
9411053 ctx := testContext()
9421054
9431055 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
@@ -977,7 +1089,7 @@ func TestService_Shutdown(t *testing.T) {
9771089 envLoader := mocks.NewMockEnvLoader(t)
9781090 eventBus := mocks.NewMockEventPublisher(t)
9791091
980- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1092+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
9811093 ctx := testContext()
9821094
9831095 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
@@ -997,7 +1109,7 @@ func TestService_Restart_NotFound(t *testing.T) {
9971109 envLoader := mocks.NewMockEnvLoader(t)
9981110 eventBus := mocks.NewMockEventPublisher(t)
9991111
1000- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1112+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
10011113 ctx := testContext()
10021114
10031115 err := svc.Restart(ctx, "nonexistent.example.com", false)
@@ -1011,7 +1123,7 @@ func TestService_Restart_Success(t *testing.T) {
10111123 envLoader := mocks.NewMockEnvLoader(t)
10121124 eventBus := mocks.NewMockEventPublisher(t)
10131125
1014- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1126+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
10151127 ctx := testContext()
10161128
10171129 svc.containers["test.example.com"] = &domain.Container{
@@ -1032,7 +1144,7 @@ func TestService_Restart_Error(t *testing.T) {
10321144 envLoader := mocks.NewMockEnvLoader(t)
10331145 eventBus := mocks.NewMockEventPublisher(t)
10341146
1035- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1147+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
10361148 ctx := testContext()
10371149
10381150 svc.containers["test.example.com"] = &domain.Container{
@@ -1054,7 +1166,7 @@ func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) {
10541166 envLoader := mocks.NewMockEnvLoader(t)
10551167 eventBus := mocks.NewMockEventPublisher(t)
10561168
1057- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1169+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
10581170 ctx := testContext()
10591171
10601172 svc.containers["test.example.com"] = &domain.Container{
@@ -1088,7 +1200,7 @@ func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing
10881200 envLoader := mocks.NewMockEnvLoader(t)
10891201 eventBus := mocks.NewMockEventPublisher(t)
10901202
1091- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1203+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
10921204 ctx := testContext()
10931205
10941206 svc.containers["test.example.com"] = &domain.Container{
@@ -1113,7 +1225,7 @@ func TestService_Restart_WithAttachments(t *testing.T) {
11131225 envLoader := mocks.NewMockEnvLoader(t)
11141226 eventBus := mocks.NewMockEventPublisher(t)
11151227
1116- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
1228+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
11171229 ctx := testContext()
11181230
11191231 svc.containers["test.example.com"] = &domain.Container{
@@ -1122,6 +1234,31 @@ func TestService_Restart_WithAttachments(t *testing.T) {
11221234 Status: "running",
11231235 }
11241236 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()
11251262
11261263 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
11271264 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
@@ -1132,6 +1269,177 @@ func TestService_Restart_WithAttachments(t *testing.T) {
11321269 assert.NoError(t, err)
11331270 }
11341271
1272+func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *testing.T) {
1273+ runtime := mocks.NewMockContainerRuntime(t)
1274+ envLoader := mocks.NewMockEnvLoader(t)
1275+ eventBus := mocks.NewMockEventPublisher(t)
1276+
1277+ provider := mocks.NewMockAttachmentConfigProvider(t)
1278+ provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1279+ Attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1280+ NetworkGroups: map[string][]string{},
1281+ }).Maybe()
1282+
1283+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1284+
1285+ svc.containers["test.example.com"] = &domain.Container{
1286+ ID: "container-123",
1287+ Name: "gordon-test.example.com",
1288+ Status: "running",
1289+ }
1290+ // No attachment containers tracked — s.attachments is empty
1291+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
1292+
1293+ err := svc.Restart(testContext(), "test.example.com", true)
1294+
1295+ assert.Error(t, err)
1296+ assert.True(t, errors.Is(err, domain.ErrAttachmentNotDeployed))
1297+ // Main container should NOT have been restarted
1298+ runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything)
1299+}
1300+
1301+func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.T) {
1302+ runtime := mocks.NewMockContainerRuntime(t)
1303+ envLoader := mocks.NewMockEnvLoader(t)
1304+ eventBus := mocks.NewMockEventPublisher(t)
1305+
1306+ provider := mocks.NewMockAttachmentConfigProvider(t)
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()
1311+
1312+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1313+
1314+ svc.containers["test.example.com"] = &domain.Container{
1315+ ID: "container-123",
1316+ Name: "gordon-test.example.com",
1317+ Status: "running",
1318+ }
1319+ // Only 1 of 2 attachments deployed
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()
1338+
1339+ err := svc.Restart(testContext(), "test.example.com", true)
1340+
1341+ assert.Error(t, err)
1342+ assert.True(t, errors.Is(err, domain.ErrAttachmentNotDeployed))
1343+ assert.Contains(t, err.Error(), "1 missing")
1344+ // Main container should NOT have been restarted
1345+ runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything)
1346+}
1347+
1348+func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
1349+ runtime := mocks.NewMockContainerRuntime(t)
1350+ envLoader := mocks.NewMockEnvLoader(t)
1351+ eventBus := mocks.NewMockEventPublisher(t)
1352+
1353+ provider := mocks.NewMockAttachmentConfigProvider(t)
1354+ provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1355+ Attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1356+ NetworkGroups: map[string][]string{},
1357+ }).Maybe()
1358+
1359+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1360+
1361+ svc.containers["test.example.com"] = &domain.Container{
1362+ ID: "container-123",
1363+ Name: "gordon-test.example.com",
1364+ Status: "running",
1365+ }
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()
1384+
1385+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1386+ runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil)
1387+
1388+ err := svc.Restart(testContext(), "test.example.com", true)
1389+ assert.NoError(t, err)
1390+}
1391+
1392+func TestService_Restart_WithoutAttachmentFlag_IgnoresMissingAttachments(t *testing.T) {
1393+ runtime := mocks.NewMockContainerRuntime(t)
1394+ envLoader := mocks.NewMockEnvLoader(t)
1395+ eventBus := mocks.NewMockEventPublisher(t)
1396+
1397+ provider := mocks.NewMockAttachmentConfigProvider(t)
1398+ provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1399+ Attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1400+ NetworkGroups: map[string][]string{},
1401+ }).Maybe()
1402+
1403+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1404+
1405+ svc.containers["test.example.com"] = &domain.Container{
1406+ ID: "container-123",
1407+ Name: "gordon-test.example.com",
1408+ Status: "running",
1409+ }
1410+
1411+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1412+
1413+ err := svc.Restart(testContext(), "test.example.com", false)
1414+ assert.NoError(t, err)
1415+}
1416+
1417+func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *testing.T) {
1418+ runtime := mocks.NewMockContainerRuntime(t)
1419+ envLoader := mocks.NewMockEnvLoader(t)
1420+ eventBus := mocks.NewMockEventPublisher(t)
1421+
1422+ provider := mocks.NewMockAttachmentConfigProvider(t)
1423+ provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1424+ Attachments: map[string][]string{},
1425+ NetworkGroups: map[string][]string{},
1426+ }).Maybe()
1427+
1428+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1429+
1430+ svc.containers["test.example.com"] = &domain.Container{
1431+ ID: "container-123",
1432+ Name: "gordon-test.example.com",
1433+ Status: "running",
1434+ }
1435+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
1436+
1437+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1438+
1439+ err := svc.Restart(testContext(), "test.example.com", true)
1440+ assert.NoError(t, err)
1441+}
1442+
11351443 func TestNormalizeImageRef(t *testing.T) {
11361444 tests := []struct {
11371445 name string
@@ -1452,7 +1760,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
14521760 DrainDelayConfigured: true,
14531761 }
14541762
1455- svc := NewService(runtime, envLoader, eventBus, nil, config)
1763+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
14561764
14571765 // Use internal deploy context (simulating image push event)
14581766 ctx := domain.WithInternalDeploy(testContext())
@@ -1525,7 +1833,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
15251833 DrainDelay: time.Millisecond,
15261834 DrainDelayConfigured: true,
15271835 }
1528- svc := NewService(runtime, envLoader, eventBus, nil, config)
1836+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
15291837 ctx := testContext()
15301838
15311839 routes := []domain.Route{
@@ -1573,7 +1881,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
15731881 DrainDelay: time.Millisecond,
15741882 DrainDelayConfigured: true,
15751883 }
1576- svc := NewService(runtime, envLoader, eventBus, nil, config)
1884+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
15771885 ctx := testContext()
15781886
15791887 // Pre-populate with existing container
@@ -1617,7 +1925,7 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
16171925 eventBus := mocks.NewMockEventPublisher(t)
16181926
16191927 config := Config{}
1620- svc := NewService(runtime, envLoader, eventBus, nil, config)
1928+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
16211929 ctx := testContext()
16221930
16231931 routes := []domain.Route{
@@ -1651,7 +1959,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
16511959 DrainDelay: time.Millisecond,
16521960 DrainDelayConfigured: true,
16531961 }
1654- svc := NewService(runtime, envLoader, eventBus, nil, config)
1962+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
16551963
16561964 // Mark context as internal deploy — this is what syncAndAutoStart should do
16571965 ctx := domain.WithInternalDeploy(testContext())
@@ -1703,7 +2011,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
17032011 DrainDelayConfigured: true,
17042012 StabilizationDelay: time.Millisecond,
17052013 }
1706- svc := NewService(runtime, envLoader, eventBus, nil, config)
2014+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
17072015 svc.SetProxyCacheInvalidator(cacheInvalidator)
17082016 ctx := testContext()
17092017
@@ -1795,7 +2103,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
17952103 DrainDelay: time.Millisecond,
17962104 DrainDelayConfigured: true,
17972105 }
1798- svc := NewService(runtime, envLoader, eventBus, nil, config)
2106+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
17992107 ctx := testContext()
18002108
18012109 // NO tracked container - service is empty for this domain
@@ -1871,7 +2179,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
18712179 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
18722180
18732181 config := testMinDelayConfig()
1874- svc := NewService(runtime, envLoader, eventBus, nil, config)
2182+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
18752183 svc.SetProxyCacheInvalidator(cacheInvalidator)
18762184 ctx := testContext()
18772185
@@ -1942,7 +2250,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
19422250 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
19432251
19442252 config := testMinDelayConfig()
1945- svc := NewService(runtime, envLoader, eventBus, nil, config)
2253+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
19462254 svc.SetProxyCacheInvalidator(cacheInvalidator)
19472255 ctx := testContext()
19482256
@@ -2053,7 +2361,7 @@ func TestService_StripRegistryPrefix(t *testing.T) {
20532361 config := Config{
20542362 RegistryDomain: tt.registryDomain,
20552363 }
2056- svc := NewService(runtime, envLoader, eventBus, nil, config)
2364+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
20572365
20582366 result := svc.stripRegistryPrefix(tt.image)
20592367 assert.Equal(t, tt.expected, result)
@@ -2130,7 +2438,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
21302438 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
21312439
21322440 config := testMinDelayConfig()
2133- svc := NewService(runtime, envLoader, eventBus, nil, config)
2441+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
21342442 svc.SetProxyCacheInvalidator(cacheInvalidator)
21352443 ctx := testContext()
21362444
@@ -2242,7 +2550,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
22422550 DrainDelay: time.Millisecond,
22432551 DrainDelayConfigured: true,
22442552 }
2245- svc := NewService(runtime, envLoader, eventBus, nil, config)
2553+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
22462554
22472555 route := domain.Route{
22482556 Domain: "test.example.com",
@@ -2268,7 +2576,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
22682576 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
22692577
22702578 config := testMinDelayConfig()
2271- svc := NewService(runtime, envLoader, eventBus, nil, config)
2579+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
22722580 svc.SetProxyCacheInvalidator(cacheInvalidator)
22732581 ctx := testContext()
22742582
@@ -2347,7 +2655,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
23472655
23482656 config := testMinDelayConfig()
23492657 // Intentionally NOT setting cache invalidator
2350- svc := NewService(runtime, envLoader, eventBus, nil, config)
2658+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
23512659 ctx := testContext()
23522660
23532661 // Pre-populate with existing container
@@ -2398,7 +2706,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
23982706 DrainDelay: time.Millisecond,
23992707 DrainDelayConfigured: true,
24002708 }
2401- svc := NewService(runtime, envLoader, eventBus, nil, config)
2709+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
24022710 ctx := testContext()
24032711 expectedEnvHash := hashEnvironment([]string{})
24042712
@@ -2457,7 +2765,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
24572765 DrainDelay: time.Millisecond,
24582766 DrainDelayConfigured: true,
24592767 }
2460- svc := NewService(runtime, envLoader, eventBus, nil, config)
2768+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
24612769 ctx := testContext()
24622770 expectedEnvHash := hashEnvironment([]string{})
24632771
@@ -2514,7 +2822,7 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
25142822 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
25152823
25162824 config := testMinDelayConfig()
2517- svc := NewService(runtime, envLoader, eventBus, nil, config)
2825+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
25182826 svc.SetProxyCacheInvalidator(cacheInvalidator)
25192827 ctx := testContext()
25202828
@@ -2570,7 +2878,7 @@ func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
25702878 envLoader := mocks.NewMockEnvLoader(t)
25712879 eventBus := mocks.NewMockEventPublisher(t)
25722880
2573- svc := NewService(runtime, envLoader, eventBus, nil, Config{})
2881+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
25742882 ctx := testContext()
25752883
25762884 // ListContainers returns one main container and one attachment container
@@ -2614,7 +2922,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
26142922 eventBus := mocks.NewMockEventPublisher(t)
26152923
26162924 config := testMinDelayConfig()
2617- svc := NewService(runtime, envLoader, eventBus, nil, config)
2925+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
26182926 ctx := testContext()
26192927 expectedEnvHash := hashEnvironment([]string{})
26202928
@@ -2676,7 +2984,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
26762984 eventBus := mocks.NewMockEventPublisher(t)
26772985
26782986 config := testMinDelayConfig()
2679- svc := NewService(runtime, envLoader, eventBus, nil, config)
2987+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
26802988 ctx := testContext()
26812989 expectedEnvHash := hashEnvironment([]string{})
26822990
@@ -2733,7 +3041,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
27333041 eventBus := mocks.NewMockEventPublisher(t)
27343042
27353043 config := testMinDelayConfig()
2736- svc := NewService(runtime, envLoader, eventBus, nil, config)
3044+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
27373045 ctx := testContext()
27383046 expectedEnvHash := hashEnvironment([]string{})
27393047
@@ -2787,7 +3095,7 @@ func TestService_attachmentEnvDrifted_ReturnsFalseWhenHashMatches(t *testing.T)
27873095 envLoader := mocks.NewMockEnvLoader(t)
27883096 eventBus := mocks.NewMockEventPublisher(t)
27893097
2790- svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig())
3098+ svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
27913099 ctx := testContext()
27923100 imageRef := svc.buildImageRef("postgres:16")
27933101 envVars := []string{"POSTGRES_PASSWORD=secret"}
@@ -2809,7 +3117,7 @@ func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashDiffers(t *testing.T) {
28093117 envLoader := mocks.NewMockEnvLoader(t)
28103118 eventBus := mocks.NewMockEventPublisher(t)
28113119
2812- svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig())
3120+ svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
28133121 ctx := testContext()
28143122 imageRef := svc.buildImageRef("postgres:16")
28153123 envVars := []string{"POSTGRES_PASSWORD=secret"}
@@ -2830,7 +3138,7 @@ func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) {
28303138 envLoader := mocks.NewMockEnvLoader(t)
28313139 eventBus := mocks.NewMockEventPublisher(t)
28323140
2833- svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig())
3141+ svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
28343142 ctx := testContext()
28353143 imageRef := svc.buildImageRef("postgres:16")
28363144 envVars := []string{"POSTGRES_PASSWORD=secret"}
@@ -2858,7 +3166,7 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
28583166 eventBus := mocks.NewMockEventPublisher(t)
28593167
28603168 config := testMinDelayConfig()
2861- svc := NewService(runtime, envLoader, eventBus, nil, config)
3169+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
28623170 ctx := testContext()
28633171
28643172 // The canonical container is running and has the managed label — it will be
@@ -2959,7 +3267,7 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
29593267 DrainDelayConfigured: true,
29603268 StabilizationDelay: time.Millisecond,
29613269 }
2962- svc := NewService(runtime, envLoader, eventBus, nil, config)
3270+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
29633271 svc.SetProxyCacheInvalidator(cacheInvalidator)
29643272 ctx := testContext()
29653273
@@ -3052,7 +3360,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) {
30523360 DrainDelayConfigured: true,
30533361 StabilizationDelay: time.Millisecond,
30543362 }
3055- svc := NewService(runtime, envLoader, eventBus, nil, config)
3363+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
30563364 svc.SetProxyCacheInvalidator(cacheInvalidator)
30573365 ctx := testContext()
30583366
@@ -3138,7 +3446,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T)
31383446 DrainDelayConfigured: true,
31393447 StabilizationDelay: time.Millisecond,
31403448 }
3141- svc := NewService(runtime, envLoader, eventBus, nil, config)
3449+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
31423450 svc.SetProxyCacheInvalidator(cacheInvalidator)
31433451 ctx := testContext()
31443452
@@ -3231,7 +3539,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
32313539 DrainDelayConfigured: true,
32323540 StabilizationDelay: time.Millisecond,
32333541 }
3234- svc := NewService(runtime, envLoader, eventBus, nil, config)
3542+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
32353543 ctx := testContext()
32363544
32373545 // Pre-populate with existing tracked container
@@ -3312,7 +3620,7 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
33123620 DrainDelayConfigured: true,
33133621 StabilizationDelay: time.Millisecond,
33143622 }
3315- svc := NewService(runtime, envLoader, eventBus, nil, config)
3623+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
33163624 ctx := testContext()
33173625
33183626 // NO existing container — this is a first deploy
@@ -3389,7 +3697,7 @@ func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
33893697 DrainDelayConfigured: true,
33903698 }
33913699
3392- svc := NewService(runtime, envLoader, eventBus, nil, config)
3700+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
33933701 ctx := testContext()
33943702
33953703 route := domain.Route{
@@ -17,7 +17,8 @@ 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/mocks"20+ "github.com/bnema/gordon/internal/boundaries/out"
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 )
23 24
@@ -80,12 +81,123 @@ func managedMetricState(t *testing.T, reader *sdkmetric.ManualReader) (int64, in
80 return 0, 0, nil81 return 0, 0, nil
81 }82 }
82 83
84+func TestService_NewService_WithConfigProvider(t *testing.T) {
85+ runtime := mocks.NewMockContainerRuntime(t)
86+ envLoader := mocks.NewMockEnvLoader(t)
87+ eventBus := mocks.NewMockEventPublisher(t)
88+
89+ provider := mocks.NewMockAttachmentConfigProvider(t)
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()
94+
95+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
96+ assert.NotNil(t, svc)
97+}
98+
99+func TestService_ResolveAttachments_UsesLiveConfigProvider(t *testing.T) {
100+ runtime := mocks.NewMockContainerRuntime(t)
101+ envLoader := mocks.NewMockEnvLoader(t)
102+ eventBus := mocks.NewMockEventPublisher(t)
103+
104+ provider := mocks.NewMockAttachmentConfigProvider(t)
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()
113+
114+ // Create service with EMPTY static config but a live provider
115+ svc := NewService(runtime, envLoader, eventBus, nil, Config{
116+ NetworkIsolation: true,
117+ NetworkPrefix: "gordon",
118+ }, provider)
119+
120+ // resolveAttachmentsForDomain should return the provider's data, not the empty snapshot
121+ result := svc.resolveAttachmentsForDomain("app.example.com")
122+ assert.Equal(t, []string{"postgres:16"}, result)
123+
124+ result = svc.resolveAttachmentsForDomain("app.example.com")
125+ assert.Equal(t, []string{"postgres:16", "redis:7"}, result)
126+}
127+
128+func TestService_ResolveAttachments_GroupBasedViaProvider(t *testing.T) {
129+ provider := mocks.NewMockAttachmentConfigProvider(t)
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()
138+
139+ svc := NewService(
140+ mocks.NewMockContainerRuntime(t),
141+ mocks.NewMockEnvLoader(t),
142+ mocks.NewMockEventPublisher(t),
143+ nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider,
144+ )
145+
146+ // Domain in group should see group attachments
147+ result := svc.resolveAttachmentsForDomain("app1.example.com")
148+ assert.Equal(t, []string{"postgres:16"}, result)
149+
150+ // Domain not in any group should see nothing
151+ result = svc.resolveAttachmentsForDomain("other.example.com")
152+ assert.Empty(t, result)
153+}
154+
155+func TestService_GetNetworkForApp_UsesLiveConfigProvider(t *testing.T) {
156+ provider := mocks.NewMockAttachmentConfigProvider(t)
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+ },
162+ }).Once()
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+ },
168+ }).Once()
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+ },
174+ }).Once()
175+
176+ svc := NewService(
177+ mocks.NewMockContainerRuntime(t),
178+ mocks.NewMockEnvLoader(t),
179+ mocks.NewMockEventPublisher(t),
180+ nil, Config{NetworkIsolation: true, NetworkPrefix: "gordon"}, provider,
181+ )
182+
183+ // Should use group network: generateNetworkName produces "gordon-shared-group"
184+ network := svc.getNetworkForApp("app1.example.com")
185+ assert.Equal(t, "gordon-shared-group", network)
186+
187+ network = svc.getNetworkForApp("app3.example.com")
188+ assert.Equal(t, "gordon-shared-group", network)
189+
190+ // Domain not in any group gets its own network: "gordon-solo-example-com"
191+ network = svc.getNetworkForApp("solo.example.com")
192+ assert.Equal(t, "gordon-solo-example-com", network)
193+}
194+
83 func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {195 func TestService_ManagedContainersMetric_GlobalSeriesOnDeployReplaceAndRemove(t *testing.T) {
84 runtime := mocks.NewMockContainerRuntime(t)196 runtime := mocks.NewMockContainerRuntime(t)
85 envLoader := mocks.NewMockEnvLoader(t)197 envLoader := mocks.NewMockEnvLoader(t)
86 eventBus := mocks.NewMockEventPublisher(t)198 eventBus := mocks.NewMockEventPublisher(t)
87 199
88- svc := NewService(runtime, envLoader, eventBus, nil, Config{})200+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
89 ctx := testContext()201 ctx := testContext()
90 metrics, reader := setupMetricsTest(t)202 metrics, reader := setupMetricsTest(t)
91 svc.SetMetrics(metrics)203 svc.SetMetrics(metrics)
@@ -119,7 +231,7 @@ func TestService_SyncContainers_ManagedContainersMetricTracksDelta(t *testing.T)
119 envLoader := mocks.NewMockEnvLoader(t)231 envLoader := mocks.NewMockEnvLoader(t)
120 eventBus := mocks.NewMockEventPublisher(t)232 eventBus := mocks.NewMockEventPublisher(t)
121 233
122- svc := NewService(runtime, envLoader, eventBus, nil, Config{})234+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
123 ctx := testContext()235 ctx := testContext()
124 metrics, reader := setupMetricsTest(t)236 metrics, reader := setupMetricsTest(t)
125 svc.SetMetrics(metrics)237 svc.SetMetrics(metrics)
@@ -202,7 +314,7 @@ func TestService_Deploy_Success(t *testing.T) {
202 DrainDelayConfigured: true,314 DrainDelayConfigured: true,
203 }315 }
204 316
205- svc := NewService(runtime, envLoader, eventBus, nil, config)317+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
206 ctx := testContext()318 ctx := testContext()
207 319
208 route := domain.Route{320 route := domain.Route{
@@ -284,7 +396,7 @@ func TestService_Deploy_ReadinessRecoveryWindow_AllowsTransientFlap(t *testing.T
284 DrainDelayConfigured: true,396 DrainDelayConfigured: true,
285 }397 }
286 398
287- svc := NewService(runtime, envLoader, eventBus, nil, config)399+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
288 ctx := testContext()400 ctx := testContext()
289 401
290 route := domain.Route{402 route := domain.Route{
@@ -342,7 +454,7 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
342 eventBus := mocks.NewMockEventPublisher(t)454 eventBus := mocks.NewMockEventPublisher(t)
343 455
344 config := Config{}456 config := Config{}
345- svc := NewService(runtime, envLoader, eventBus, nil, config)457+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
346 ctx := testContext()458 ctx := testContext()
347 459
348 route := domain.Route{460 route := domain.Route{
@@ -373,7 +485,7 @@ func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
373 ServiceToken: "service-token",485 ServiceToken: "service-token",
374 }486 }
375 487
376- svc := NewService(runtime, nil, nil, nil, config)488+ svc := NewService(runtime, nil, nil, nil, config, nil)
377 ctx := testContext()489 ctx := testContext()
378 490
379 runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)491 runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)
@@ -390,7 +502,7 @@ func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
390 RegistryAuthEnabled: true,502 RegistryAuthEnabled: true,
391 }503 }
392 504
393- svc := NewService(runtime, nil, nil, nil, config)505+ svc := NewService(runtime, nil, nil, nil, config, nil)
394 ctx := testContext()506 ctx := testContext()
395 507
396 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)508 err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
@@ -408,7 +520,7 @@ func TestService_PullImage_InternalRetriesOnConnectionRefused(t *testing.T) {
408 InternalRegistryPassword: "secret",520 InternalRegistryPassword: "secret",
409 }521 }
410 522
411- svc := NewService(runtime, nil, nil, nil, config)523+ svc := NewService(runtime, nil, nil, nil, config, nil)
412 ctx := testContext()524 ctx := testContext()
413 525
414 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").526 runtime.EXPECT().PullImageWithAuth(mock.Anything, "localhost:5000/myapp:latest", "internal", "secret").
@@ -435,7 +547,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
435 DrainDelayConfigured: true,547 DrainDelayConfigured: true,
436 StabilizationDelay: time.Millisecond,548 StabilizationDelay: time.Millisecond,
437 }549 }
438- svc := NewService(runtime, envLoader, eventBus, nil, config)550+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
439 svc.SetProxyCacheInvalidator(cacheInvalidator)551 svc.SetProxyCacheInvalidator(cacheInvalidator)
440 ctx := testContext()552 ctx := testContext()
441 553
@@ -511,7 +623,7 @@ func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T
511 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)623 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
512 624
513 config := testMinDelayConfig()625 config := testMinDelayConfig()
514- svc := NewService(runtime, envLoader, eventBus, nil, config)626+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
515 svc.SetProxyCacheInvalidator(cacheInvalidator)627 svc.SetProxyCacheInvalidator(cacheInvalidator)
516 ctx := testContext()628 ctx := testContext()
517 629
@@ -608,7 +720,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
608 eventBus := mocks.NewMockEventPublisher(t)720 eventBus := mocks.NewMockEventPublisher(t)
609 721
610 config := testMinDelayConfig()722 config := testMinDelayConfig()
611- svc := NewService(runtime, envLoader, eventBus, nil, config)723+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
612 ctx := testContext()724 ctx := testContext()
613 expectedEnvHash := hashEnvironment([]string{})725 expectedEnvHash := hashEnvironment([]string{})
614 726
@@ -675,7 +787,7 @@ func TestService_Deploy_WithNetworkIsolation(t *testing.T) {
675 DrainDelay: time.Millisecond,787 DrainDelay: time.Millisecond,
676 DrainDelayConfigured: true,788 DrainDelayConfigured: true,
677 }789 }
678- svc := NewService(runtime, envLoader, eventBus, nil, config)790+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
679 ctx := testContext()791 ctx := testContext()
680 792
681 route := domain.Route{793 route := domain.Route{
@@ -732,7 +844,7 @@ func TestService_Deploy_WithVolumeAutoCreate(t *testing.T) {
732 DrainDelay: time.Millisecond,844 DrainDelay: time.Millisecond,
733 DrainDelayConfigured: true,845 DrainDelayConfigured: true,
734 }846 }
735- svc := NewService(runtime, envLoader, eventBus, nil, config)847+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
736 ctx := testContext()848 ctx := testContext()
737 849
738 route := domain.Route{850 route := domain.Route{
@@ -784,7 +896,7 @@ func TestService_Stop(t *testing.T) {
784 envLoader := mocks.NewMockEnvLoader(t)896 envLoader := mocks.NewMockEnvLoader(t)
785 eventBus := mocks.NewMockEventPublisher(t)897 eventBus := mocks.NewMockEventPublisher(t)
786 898
787- svc := NewService(runtime, envLoader, eventBus, nil, Config{})899+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
788 ctx := testContext()900 ctx := testContext()
789 901
790 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)902 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(nil)
@@ -799,7 +911,7 @@ func TestService_Stop_Error(t *testing.T) {
799 envLoader := mocks.NewMockEnvLoader(t)911 envLoader := mocks.NewMockEnvLoader(t)
800 eventBus := mocks.NewMockEventPublisher(t)912 eventBus := mocks.NewMockEventPublisher(t)
801 913
802- svc := NewService(runtime, envLoader, eventBus, nil, Config{})914+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
803 ctx := testContext()915 ctx := testContext()
804 916
805 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))917 runtime.EXPECT().StopContainer(mock.Anything, "container-123").Return(errors.New("container not found"))
@@ -815,7 +927,7 @@ func TestService_Remove(t *testing.T) {
815 envLoader := mocks.NewMockEnvLoader(t)927 envLoader := mocks.NewMockEnvLoader(t)
816 eventBus := mocks.NewMockEventPublisher(t)928 eventBus := mocks.NewMockEventPublisher(t)
817 929
818- svc := NewService(runtime, envLoader, eventBus, nil, Config{})930+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
819 ctx := testContext()931 ctx := testContext()
820 932
821 // Add tracked container933 // Add tracked container
@@ -844,7 +956,7 @@ func TestService_Remove_WithNetworkCleanup(t *testing.T) {
844 NetworkIsolation: true,956 NetworkIsolation: true,
845 NetworkPrefix: "gordon",957 NetworkPrefix: "gordon",
846 }958 }
847- svc := NewService(runtime, envLoader, eventBus, nil, config)959+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
848 ctx := testContext()960 ctx := testContext()
849 961
850 svc.containers["test.example.com"] = &domain.Container{962 svc.containers["test.example.com"] = &domain.Container{
@@ -868,7 +980,7 @@ func TestService_Get(t *testing.T) {
868 envLoader := mocks.NewMockEnvLoader(t)980 envLoader := mocks.NewMockEnvLoader(t)
869 eventBus := mocks.NewMockEventPublisher(t)981 eventBus := mocks.NewMockEventPublisher(t)
870 982
871- svc := NewService(runtime, envLoader, eventBus, nil, Config{})983+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
872 ctx := testContext()984 ctx := testContext()
873 985
874 container := &domain.Container{ID: "container-123"}986 container := &domain.Container{ID: "container-123"}
@@ -885,7 +997,7 @@ func TestService_Get_NotFound(t *testing.T) {
885 envLoader := mocks.NewMockEnvLoader(t)997 envLoader := mocks.NewMockEnvLoader(t)
886 eventBus := mocks.NewMockEventPublisher(t)998 eventBus := mocks.NewMockEventPublisher(t)
887 999
888- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1000+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
889 ctx := testContext()1001 ctx := testContext()
890 1002
891 result, exists := svc.Get(ctx, "nonexistent.example.com")1003 result, exists := svc.Get(ctx, "nonexistent.example.com")
@@ -899,7 +1011,7 @@ func TestService_List(t *testing.T) {
899 envLoader := mocks.NewMockEnvLoader(t)1011 envLoader := mocks.NewMockEnvLoader(t)
900 eventBus := mocks.NewMockEventPublisher(t)1012 eventBus := mocks.NewMockEventPublisher(t)
901 1013
902- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1014+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
903 ctx := testContext()1015 ctx := testContext()
904 1016
905 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}1017 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
@@ -917,7 +1029,7 @@ func TestService_HealthCheck(t *testing.T) {
917 envLoader := mocks.NewMockEnvLoader(t)1029 envLoader := mocks.NewMockEnvLoader(t)
918 eventBus := mocks.NewMockEventPublisher(t)1030 eventBus := mocks.NewMockEventPublisher(t)
919 1031
920- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1032+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
921 ctx := testContext()1033 ctx := testContext()
922 1034
923 svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}1035 svc.containers["healthy.example.com"] = &domain.Container{ID: "healthy-container"}
@@ -937,7 +1049,7 @@ func TestService_SyncContainers(t *testing.T) {
937 envLoader := mocks.NewMockEnvLoader(t)1049 envLoader := mocks.NewMockEnvLoader(t)
938 eventBus := mocks.NewMockEventPublisher(t)1050 eventBus := mocks.NewMockEventPublisher(t)
939 1051
940- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1052+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
941 ctx := testContext()1053 ctx := testContext()
942 1054
943 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{1055 runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
@@ -977,7 +1089,7 @@ func TestService_Shutdown(t *testing.T) {
977 envLoader := mocks.NewMockEnvLoader(t)1089 envLoader := mocks.NewMockEnvLoader(t)
978 eventBus := mocks.NewMockEventPublisher(t)1090 eventBus := mocks.NewMockEventPublisher(t)
979 1091
980- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1092+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
981 ctx := testContext()1093 ctx := testContext()
982 1094
983 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}1095 svc.containers["app1.example.com"] = &domain.Container{ID: "container-1"}
@@ -997,7 +1109,7 @@ func TestService_Restart_NotFound(t *testing.T) {
997 envLoader := mocks.NewMockEnvLoader(t)1109 envLoader := mocks.NewMockEnvLoader(t)
998 eventBus := mocks.NewMockEventPublisher(t)1110 eventBus := mocks.NewMockEventPublisher(t)
999 1111
1000- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1112+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1001 ctx := testContext()1113 ctx := testContext()
1002 1114
1003 err := svc.Restart(ctx, "nonexistent.example.com", false)1115 err := svc.Restart(ctx, "nonexistent.example.com", false)
@@ -1011,7 +1123,7 @@ func TestService_Restart_Success(t *testing.T) {
1011 envLoader := mocks.NewMockEnvLoader(t)1123 envLoader := mocks.NewMockEnvLoader(t)
1012 eventBus := mocks.NewMockEventPublisher(t)1124 eventBus := mocks.NewMockEventPublisher(t)
1013 1125
1014- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1126+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1015 ctx := testContext()1127 ctx := testContext()
1016 1128
1017 svc.containers["test.example.com"] = &domain.Container{1129 svc.containers["test.example.com"] = &domain.Container{
@@ -1032,7 +1144,7 @@ func TestService_Restart_Error(t *testing.T) {
1032 envLoader := mocks.NewMockEnvLoader(t)1144 envLoader := mocks.NewMockEnvLoader(t)
1033 eventBus := mocks.NewMockEventPublisher(t)1145 eventBus := mocks.NewMockEventPublisher(t)
1034 1146
1035- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1147+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1036 ctx := testContext()1148 ctx := testContext()
1037 1149
1038 svc.containers["test.example.com"] = &domain.Container{1150 svc.containers["test.example.com"] = &domain.Container{
@@ -1054,7 +1166,7 @@ func TestService_Restart_ReconcilesStaleContainerID(t *testing.T) {
1054 envLoader := mocks.NewMockEnvLoader(t)1166 envLoader := mocks.NewMockEnvLoader(t)
1055 eventBus := mocks.NewMockEventPublisher(t)1167 eventBus := mocks.NewMockEventPublisher(t)
1056 1168
1057- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1169+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1058 ctx := testContext()1170 ctx := testContext()
1059 1171
1060 svc.containers["test.example.com"] = &domain.Container{1172 svc.containers["test.example.com"] = &domain.Container{
@@ -1088,7 +1200,7 @@ func TestService_Restart_ReconcilesStaleContainerID_NotFoundAfterSync(t *testing
1088 envLoader := mocks.NewMockEnvLoader(t)1200 envLoader := mocks.NewMockEnvLoader(t)
1089 eventBus := mocks.NewMockEventPublisher(t)1201 eventBus := mocks.NewMockEventPublisher(t)
1090 1202
1091- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1203+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1092 ctx := testContext()1204 ctx := testContext()
1093 1205
1094 svc.containers["test.example.com"] = &domain.Container{1206 svc.containers["test.example.com"] = &domain.Container{
@@ -1113,7 +1225,7 @@ func TestService_Restart_WithAttachments(t *testing.T) {
1113 envLoader := mocks.NewMockEnvLoader(t)1225 envLoader := mocks.NewMockEnvLoader(t)
1114 eventBus := mocks.NewMockEventPublisher(t)1226 eventBus := mocks.NewMockEventPublisher(t)
1115 1227
1116- svc := NewService(runtime, envLoader, eventBus, nil, Config{})1228+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
1117 ctx := testContext()1229 ctx := testContext()
1118 1230
1119 svc.containers["test.example.com"] = &domain.Container{1231 svc.containers["test.example.com"] = &domain.Container{
@@ -1122,6 +1234,31 @@ func TestService_Restart_WithAttachments(t *testing.T) {
1122 Status: "running",1234 Status: "running",
1123 }1235 }
1124 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()
1125 1262
1126 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)1263 runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1127 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)1264 runtime.EXPECT().RestartContainer(mock.Anything, "container-attach-1").Return(nil)
@@ -1132,6 +1269,177 @@ func TestService_Restart_WithAttachments(t *testing.T) {
1132 assert.NoError(t, err)1269 assert.NoError(t, err)
1133 }1270 }
1134 1271
1272+func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *testing.T) {
1273+ runtime := mocks.NewMockContainerRuntime(t)
1274+ envLoader := mocks.NewMockEnvLoader(t)
1275+ eventBus := mocks.NewMockEventPublisher(t)
1276+
1277+ provider := mocks.NewMockAttachmentConfigProvider(t)
1278+ provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1279+ Attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1280+ NetworkGroups: map[string][]string{},
1281+ }).Maybe()
1282+
1283+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1284+
1285+ svc.containers["test.example.com"] = &domain.Container{
1286+ ID: "container-123",
1287+ Name: "gordon-test.example.com",
1288+ Status: "running",
1289+ }
1290+ // No attachment containers tracked — s.attachments is empty
1291+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
1292+
1293+ err := svc.Restart(testContext(), "test.example.com", true)
1294+
1295+ assert.Error(t, err)
1296+ assert.True(t, errors.Is(err, domain.ErrAttachmentNotDeployed))
1297+ // Main container should NOT have been restarted
1298+ runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything)
1299+}
1300+
1301+func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.T) {
1302+ runtime := mocks.NewMockContainerRuntime(t)
1303+ envLoader := mocks.NewMockEnvLoader(t)
1304+ eventBus := mocks.NewMockEventPublisher(t)
1305+
1306+ provider := mocks.NewMockAttachmentConfigProvider(t)
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()
1311+
1312+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1313+
1314+ svc.containers["test.example.com"] = &domain.Container{
1315+ ID: "container-123",
1316+ Name: "gordon-test.example.com",
1317+ Status: "running",
1318+ }
1319+ // Only 1 of 2 attachments deployed
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()
1338+
1339+ err := svc.Restart(testContext(), "test.example.com", true)
1340+
1341+ assert.Error(t, err)
1342+ assert.True(t, errors.Is(err, domain.ErrAttachmentNotDeployed))
1343+ assert.Contains(t, err.Error(), "1 missing")
1344+ // Main container should NOT have been restarted
1345+ runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything)
1346+}
1347+
1348+func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
1349+ runtime := mocks.NewMockContainerRuntime(t)
1350+ envLoader := mocks.NewMockEnvLoader(t)
1351+ eventBus := mocks.NewMockEventPublisher(t)
1352+
1353+ provider := mocks.NewMockAttachmentConfigProvider(t)
1354+ provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1355+ Attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1356+ NetworkGroups: map[string][]string{},
1357+ }).Maybe()
1358+
1359+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1360+
1361+ svc.containers["test.example.com"] = &domain.Container{
1362+ ID: "container-123",
1363+ Name: "gordon-test.example.com",
1364+ Status: "running",
1365+ }
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()
1384+
1385+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1386+ runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil)
1387+
1388+ err := svc.Restart(testContext(), "test.example.com", true)
1389+ assert.NoError(t, err)
1390+}
1391+
1392+func TestService_Restart_WithoutAttachmentFlag_IgnoresMissingAttachments(t *testing.T) {
1393+ runtime := mocks.NewMockContainerRuntime(t)
1394+ envLoader := mocks.NewMockEnvLoader(t)
1395+ eventBus := mocks.NewMockEventPublisher(t)
1396+
1397+ provider := mocks.NewMockAttachmentConfigProvider(t)
1398+ provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1399+ Attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1400+ NetworkGroups: map[string][]string{},
1401+ }).Maybe()
1402+
1403+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1404+
1405+ svc.containers["test.example.com"] = &domain.Container{
1406+ ID: "container-123",
1407+ Name: "gordon-test.example.com",
1408+ Status: "running",
1409+ }
1410+
1411+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1412+
1413+ err := svc.Restart(testContext(), "test.example.com", false)
1414+ assert.NoError(t, err)
1415+}
1416+
1417+func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *testing.T) {
1418+ runtime := mocks.NewMockContainerRuntime(t)
1419+ envLoader := mocks.NewMockEnvLoader(t)
1420+ eventBus := mocks.NewMockEventPublisher(t)
1421+
1422+ provider := mocks.NewMockAttachmentConfigProvider(t)
1423+ provider.EXPECT().GetAttachmentConfig().Return(out.AttachmentConfigSnapshot{
1424+ Attachments: map[string][]string{},
1425+ NetworkGroups: map[string][]string{},
1426+ }).Maybe()
1427+
1428+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1429+
1430+ svc.containers["test.example.com"] = &domain.Container{
1431+ ID: "container-123",
1432+ Name: "gordon-test.example.com",
1433+ Status: "running",
1434+ }
1435+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{}, nil).Maybe()
1436+
1437+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1438+
1439+ err := svc.Restart(testContext(), "test.example.com", true)
1440+ assert.NoError(t, err)
1441+}
1442+
1135 func TestNormalizeImageRef(t *testing.T) {1443 func TestNormalizeImageRef(t *testing.T) {
1136 tests := []struct {1444 tests := []struct {
1137 name string1445 name string
@@ -1452,7 +1760,7 @@ func TestService_Deploy_InternalDeployForcesPull(t *testing.T) {
1452 DrainDelayConfigured: true,1760 DrainDelayConfigured: true,
1453 }1761 }
1454 1762
1455- svc := NewService(runtime, envLoader, eventBus, nil, config)1763+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1456 1764
1457 // Use internal deploy context (simulating image push event)1765 // Use internal deploy context (simulating image push event)
1458 ctx := domain.WithInternalDeploy(testContext())1766 ctx := domain.WithInternalDeploy(testContext())
@@ -1525,7 +1833,7 @@ func TestService_AutoStart_StartsNewContainers(t *testing.T) {
1525 DrainDelay: time.Millisecond,1833 DrainDelay: time.Millisecond,
1526 DrainDelayConfigured: true,1834 DrainDelayConfigured: true,
1527 }1835 }
1528- svc := NewService(runtime, envLoader, eventBus, nil, config)1836+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1529 ctx := testContext()1837 ctx := testContext()
1530 1838
1531 routes := []domain.Route{1839 routes := []domain.Route{
@@ -1573,7 +1881,7 @@ func TestService_AutoStart_SkipsExistingContainers(t *testing.T) {
1573 DrainDelay: time.Millisecond,1881 DrainDelay: time.Millisecond,
1574 DrainDelayConfigured: true,1882 DrainDelayConfigured: true,
1575 }1883 }
1576- svc := NewService(runtime, envLoader, eventBus, nil, config)1884+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1577 ctx := testContext()1885 ctx := testContext()
1578 1886
1579 // Pre-populate with existing container1887 // Pre-populate with existing container
@@ -1617,7 +1925,7 @@ func TestService_AutoStart_HandlesDeployErrors(t *testing.T) {
1617 eventBus := mocks.NewMockEventPublisher(t)1925 eventBus := mocks.NewMockEventPublisher(t)
1618 1926
1619 config := Config{}1927 config := Config{}
1620- svc := NewService(runtime, envLoader, eventBus, nil, config)1928+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1621 ctx := testContext()1929 ctx := testContext()
1622 1930
1623 routes := []domain.Route{1931 routes := []domain.Route{
@@ -1651,7 +1959,7 @@ func TestService_AutoStart_UsesInternalDeployContext(t *testing.T) {
1651 DrainDelay: time.Millisecond,1959 DrainDelay: time.Millisecond,
1652 DrainDelayConfigured: true,1960 DrainDelayConfigured: true,
1653 }1961 }
1654- svc := NewService(runtime, envLoader, eventBus, nil, config)1962+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1655 1963
1656 // Mark context as internal deploy — this is what syncAndAutoStart should do1964 // Mark context as internal deploy — this is what syncAndAutoStart should do
1657 ctx := domain.WithInternalDeploy(testContext())1965 ctx := domain.WithInternalDeploy(testContext())
@@ -1703,7 +2011,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
1703 DrainDelayConfigured: true,2011 DrainDelayConfigured: true,
1704 StabilizationDelay: time.Millisecond,2012 StabilizationDelay: time.Millisecond,
1705 }2013 }
1706- svc := NewService(runtime, envLoader, eventBus, nil, config)2014+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1707 svc.SetProxyCacheInvalidator(cacheInvalidator)2015 svc.SetProxyCacheInvalidator(cacheInvalidator)
1708 ctx := testContext()2016 ctx := testContext()
1709 2017
@@ -1795,7 +2103,7 @@ func TestService_Deploy_OrphanCleanupRemovesTrueOrphans(t *testing.T) {
1795 DrainDelay: time.Millisecond,2103 DrainDelay: time.Millisecond,
1796 DrainDelayConfigured: true,2104 DrainDelayConfigured: true,
1797 }2105 }
1798- svc := NewService(runtime, envLoader, eventBus, nil, config)2106+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1799 ctx := testContext()2107 ctx := testContext()
1800 2108
1801 // NO tracked container - service is empty for this domain2109 // NO tracked container - service is empty for this domain
@@ -1871,7 +2179,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
1871 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2179 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1872 2180
1873 config := testMinDelayConfig()2181 config := testMinDelayConfig()
1874- svc := NewService(runtime, envLoader, eventBus, nil, config)2182+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1875 svc.SetProxyCacheInvalidator(cacheInvalidator)2183 svc.SetProxyCacheInvalidator(cacheInvalidator)
1876 ctx := testContext()2184 ctx := testContext()
1877 2185
@@ -1942,7 +2250,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
1942 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2250 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
1943 2251
1944 config := testMinDelayConfig()2252 config := testMinDelayConfig()
1945- svc := NewService(runtime, envLoader, eventBus, nil, config)2253+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
1946 svc.SetProxyCacheInvalidator(cacheInvalidator)2254 svc.SetProxyCacheInvalidator(cacheInvalidator)
1947 ctx := testContext()2255 ctx := testContext()
1948 2256
@@ -2053,7 +2361,7 @@ func TestService_StripRegistryPrefix(t *testing.T) {
2053 config := Config{2361 config := Config{
2054 RegistryDomain: tt.registryDomain,2362 RegistryDomain: tt.registryDomain,
2055 }2363 }
2056- svc := NewService(runtime, envLoader, eventBus, nil, config)2364+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2057 2365
2058 result := svc.stripRegistryPrefix(tt.image)2366 result := svc.stripRegistryPrefix(tt.image)
2059 assert.Equal(t, tt.expected, result)2367 assert.Equal(t, tt.expected, result)
@@ -2130,7 +2438,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
2130 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2438 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2131 2439
2132 config := testMinDelayConfig()2440 config := testMinDelayConfig()
2133- svc := NewService(runtime, envLoader, eventBus, nil, config)2441+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2134 svc.SetProxyCacheInvalidator(cacheInvalidator)2442 svc.SetProxyCacheInvalidator(cacheInvalidator)
2135 ctx := testContext()2443 ctx := testContext()
2136 2444
@@ -2242,7 +2550,7 @@ func TestService_Deploy_ContextCancellation(t *testing.T) {
2242 DrainDelay: time.Millisecond,2550 DrainDelay: time.Millisecond,
2243 DrainDelayConfigured: true,2551 DrainDelayConfigured: true,
2244 }2552 }
2245- svc := NewService(runtime, envLoader, eventBus, nil, config)2553+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2246 2554
2247 route := domain.Route{2555 route := domain.Route{
2248 Domain: "test.example.com",2556 Domain: "test.example.com",
@@ -2268,7 +2576,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2268 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2576 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2269 2577
2270 config := testMinDelayConfig()2578 config := testMinDelayConfig()
2271- svc := NewService(runtime, envLoader, eventBus, nil, config)2579+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2272 svc.SetProxyCacheInvalidator(cacheInvalidator)2580 svc.SetProxyCacheInvalidator(cacheInvalidator)
2273 ctx := testContext()2581 ctx := testContext()
2274 2582
@@ -2347,7 +2655,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
2347 2655
2348 config := testMinDelayConfig()2656 config := testMinDelayConfig()
2349 // Intentionally NOT setting cache invalidator2657 // Intentionally NOT setting cache invalidator
2350- svc := NewService(runtime, envLoader, eventBus, nil, config)2658+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2351 ctx := testContext()2659 ctx := testContext()
2352 2660
2353 // Pre-populate with existing container2661 // Pre-populate with existing container
@@ -2398,7 +2706,7 @@ func TestService_Deploy_SkipsRedundantDeploy(t *testing.T) {
2398 DrainDelay: time.Millisecond,2706 DrainDelay: time.Millisecond,
2399 DrainDelayConfigured: true,2707 DrainDelayConfigured: true,
2400 }2708 }
2401- svc := NewService(runtime, envLoader, eventBus, nil, config)2709+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2402 ctx := testContext()2710 ctx := testContext()
2403 expectedEnvHash := hashEnvironment([]string{})2711 expectedEnvHash := hashEnvironment([]string{})
2404 2712
@@ -2457,7 +2765,7 @@ func TestService_Deploy_SkipsRedundantDeploy_WhenTrackedImageIDMissing(t *testin
2457 DrainDelay: time.Millisecond,2765 DrainDelay: time.Millisecond,
2458 DrainDelayConfigured: true,2766 DrainDelayConfigured: true,
2459 }2767 }
2460- svc := NewService(runtime, envLoader, eventBus, nil, config)2768+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2461 ctx := testContext()2769 ctx := testContext()
2462 expectedEnvHash := hashEnvironment([]string{})2770 expectedEnvHash := hashEnvironment([]string{})
2463 2771
@@ -2514,7 +2822,7 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2514 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)2822 cacheInvalidator := mocks.NewMockProxyCacheInvalidator(t)
2515 2823
2516 config := testMinDelayConfig()2824 config := testMinDelayConfig()
2517- svc := NewService(runtime, envLoader, eventBus, nil, config)2825+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2518 svc.SetProxyCacheInvalidator(cacheInvalidator)2826 svc.SetProxyCacheInvalidator(cacheInvalidator)
2519 ctx := testContext()2827 ctx := testContext()
2520 2828
@@ -2570,7 +2878,7 @@ func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
2570 envLoader := mocks.NewMockEnvLoader(t)2878 envLoader := mocks.NewMockEnvLoader(t)
2571 eventBus := mocks.NewMockEventPublisher(t)2879 eventBus := mocks.NewMockEventPublisher(t)
2572 2880
2573- svc := NewService(runtime, envLoader, eventBus, nil, Config{})2881+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, nil)
2574 ctx := testContext()2882 ctx := testContext()
2575 2883
2576 // ListContainers returns one main container and one attachment container2884 // ListContainers returns one main container and one attachment container
@@ -2614,7 +2922,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2614 eventBus := mocks.NewMockEventPublisher(t)2922 eventBus := mocks.NewMockEventPublisher(t)
2615 2923
2616 config := testMinDelayConfig()2924 config := testMinDelayConfig()
2617- svc := NewService(runtime, envLoader, eventBus, nil, config)2925+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2618 ctx := testContext()2926 ctx := testContext()
2619 expectedEnvHash := hashEnvironment([]string{})2927 expectedEnvHash := hashEnvironment([]string{})
2620 2928
@@ -2676,7 +2984,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
2676 eventBus := mocks.NewMockEventPublisher(t)2984 eventBus := mocks.NewMockEventPublisher(t)
2677 2985
2678 config := testMinDelayConfig()2986 config := testMinDelayConfig()
2679- svc := NewService(runtime, envLoader, eventBus, nil, config)2987+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2680 ctx := testContext()2988 ctx := testContext()
2681 expectedEnvHash := hashEnvironment([]string{})2989 expectedEnvHash := hashEnvironment([]string{})
2682 2990
@@ -2733,7 +3041,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
2733 eventBus := mocks.NewMockEventPublisher(t)3041 eventBus := mocks.NewMockEventPublisher(t)
2734 3042
2735 config := testMinDelayConfig()3043 config := testMinDelayConfig()
2736- svc := NewService(runtime, envLoader, eventBus, nil, config)3044+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2737 ctx := testContext()3045 ctx := testContext()
2738 expectedEnvHash := hashEnvironment([]string{})3046 expectedEnvHash := hashEnvironment([]string{})
2739 3047
@@ -2787,7 +3095,7 @@ func TestService_attachmentEnvDrifted_ReturnsFalseWhenHashMatches(t *testing.T)
2787 envLoader := mocks.NewMockEnvLoader(t)3095 envLoader := mocks.NewMockEnvLoader(t)
2788 eventBus := mocks.NewMockEventPublisher(t)3096 eventBus := mocks.NewMockEventPublisher(t)
2789 3097
2790- svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig())3098+ svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
2791 ctx := testContext()3099 ctx := testContext()
2792 imageRef := svc.buildImageRef("postgres:16")3100 imageRef := svc.buildImageRef("postgres:16")
2793 envVars := []string{"POSTGRES_PASSWORD=secret"}3101 envVars := []string{"POSTGRES_PASSWORD=secret"}
@@ -2809,7 +3117,7 @@ func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashDiffers(t *testing.T) {
2809 envLoader := mocks.NewMockEnvLoader(t)3117 envLoader := mocks.NewMockEnvLoader(t)
2810 eventBus := mocks.NewMockEventPublisher(t)3118 eventBus := mocks.NewMockEventPublisher(t)
2811 3119
2812- svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig())3120+ svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
2813 ctx := testContext()3121 ctx := testContext()
2814 imageRef := svc.buildImageRef("postgres:16")3122 imageRef := svc.buildImageRef("postgres:16")
2815 envVars := []string{"POSTGRES_PASSWORD=secret"}3123 envVars := []string{"POSTGRES_PASSWORD=secret"}
@@ -2830,7 +3138,7 @@ func TestService_attachmentEnvDrifted_ReturnsTrueWhenHashMissing(t *testing.T) {
2830 envLoader := mocks.NewMockEnvLoader(t)3138 envLoader := mocks.NewMockEnvLoader(t)
2831 eventBus := mocks.NewMockEventPublisher(t)3139 eventBus := mocks.NewMockEventPublisher(t)
2832 3140
2833- svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig())3141+ svc := NewService(runtime, envLoader, eventBus, nil, testMinDelayConfig(), nil)
2834 ctx := testContext()3142 ctx := testContext()
2835 imageRef := svc.buildImageRef("postgres:16")3143 imageRef := svc.buildImageRef("postgres:16")
2836 envVars := []string{"POSTGRES_PASSWORD=secret"}3144 envVars := []string{"POSTGRES_PASSWORD=secret"}
@@ -2858,7 +3166,7 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
2858 eventBus := mocks.NewMockEventPublisher(t)3166 eventBus := mocks.NewMockEventPublisher(t)
2859 3167
2860 config := testMinDelayConfig()3168 config := testMinDelayConfig()
2861- svc := NewService(runtime, envLoader, eventBus, nil, config)3169+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2862 ctx := testContext()3170 ctx := testContext()
2863 3171
2864 // The canonical container is running and has the managed label — it will be3172 // The canonical container is running and has the managed label — it will be
@@ -2959,7 +3267,7 @@ func TestService_Deploy_RollbackOnPostSwitchCrash(t *testing.T) {
2959 DrainDelayConfigured: true,3267 DrainDelayConfigured: true,
2960 StabilizationDelay: time.Millisecond,3268 StabilizationDelay: time.Millisecond,
2961 }3269 }
2962- svc := NewService(runtime, envLoader, eventBus, nil, config)3270+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
2963 svc.SetProxyCacheInvalidator(cacheInvalidator)3271 svc.SetProxyCacheInvalidator(cacheInvalidator)
2964 ctx := testContext()3272 ctx := testContext()
2965 3273
@@ -3052,7 +3360,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) {
3052 DrainDelayConfigured: true,3360 DrainDelayConfigured: true,
3053 StabilizationDelay: time.Millisecond,3361 StabilizationDelay: time.Millisecond,
3054 }3362 }
3055- svc := NewService(runtime, envLoader, eventBus, nil, config)3363+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3056 svc.SetProxyCacheInvalidator(cacheInvalidator)3364 svc.SetProxyCacheInvalidator(cacheInvalidator)
3057 ctx := testContext()3365 ctx := testContext()
3058 3366
@@ -3138,7 +3446,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T)
3138 DrainDelayConfigured: true,3446 DrainDelayConfigured: true,
3139 StabilizationDelay: time.Millisecond,3447 StabilizationDelay: time.Millisecond,
3140 }3448 }
3141- svc := NewService(runtime, envLoader, eventBus, nil, config)3449+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3142 svc.SetProxyCacheInvalidator(cacheInvalidator)3450 svc.SetProxyCacheInvalidator(cacheInvalidator)
3143 ctx := testContext()3451 ctx := testContext()
3144 3452
@@ -3231,7 +3539,7 @@ func TestService_Deploy_ZeroDowntime_ReadinessFailure_OldUntouched(t *testing.T)
3231 DrainDelayConfigured: true,3539 DrainDelayConfigured: true,
3232 StabilizationDelay: time.Millisecond,3540 StabilizationDelay: time.Millisecond,
3233 }3541 }
3234- svc := NewService(runtime, envLoader, eventBus, nil, config)3542+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3235 ctx := testContext()3543 ctx := testContext()
3236 3544
3237 // Pre-populate with existing tracked container3545 // Pre-populate with existing tracked container
@@ -3312,7 +3620,7 @@ func TestService_Deploy_ZeroDowntime_NoExisting_FirstDeploy(t *testing.T) {
3312 DrainDelayConfigured: true,3620 DrainDelayConfigured: true,
3313 StabilizationDelay: time.Millisecond,3621 StabilizationDelay: time.Millisecond,
3314 }3622 }
3315- svc := NewService(runtime, envLoader, eventBus, nil, config)3623+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3316 ctx := testContext()3624 ctx := testContext()
3317 3625
3318 // NO existing container — this is a first deploy3626 // NO existing container — this is a first deploy
@@ -3389,7 +3697,7 @@ func TestService_Deploy_PropagatesImageLabelsToContainerConfig(t *testing.T) {
3389 DrainDelayConfigured: true,3697 DrainDelayConfigured: true,
3390 }3698 }
3391 3699
3392- svc := NewService(runtime, envLoader, eventBus, nil, config)3700+ svc := NewService(runtime, envLoader, eventBus, nil, config, nil)
3393 ctx := testContext()3701 ctx := testContext()
3394 3702
3395 route := domain.Route{3703 route := domain.Route{

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