detached ee73a96

feat(container): restart fails with actionable error when configured attachments are missing

commit
ee73a9642c12118da2100348452fdedd6a96fb3c
author
brice · · 2026-03-15 16:12 UTC
parent
cda1f8c
signature
unsigned
2 files · +143 -0
M internal/usecase/container/service.go +13 -0
@@ -813,6 +813,19 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment
813813 return domain.ErrContainerNotFound
814814 }
815815
816+ // When attachments are requested, check if configured attachments are deployed.
817+ // If fewer attachment containers are tracked than configured, fail early with
818+ // guidance rather than restarting the main container into a broken state
819+ // (e.g., missing database hostname).
820+ if withAttachments {
821+ configuredAttachments := s.resolveAttachmentsForDomain(domainName)
822+ if len(configuredAttachments) > 0 && len(attachmentIDs) < len(configuredAttachments) {
823+ missing := len(configuredAttachments) - len(attachmentIDs)
824+ return fmt.Errorf("%w: domain %q has %d configured attachment(s) but only %d deployed (%d missing)",
825+ domain.ErrAttachmentNotDeployed, domainName, len(configuredAttachments), len(attachmentIDs), missing)
826+ }
827+ }
828+
816829 // Restart the main container
817830 if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
818831 if !isContainerNotFoundError(err) {
@@ -813,6 +813,19 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment
813 return domain.ErrContainerNotFound813 return domain.ErrContainerNotFound
814 }814 }
815 815
816+ // When attachments are requested, check if configured attachments are deployed.
817+ // If fewer attachment containers are tracked than configured, fail early with
818+ // guidance rather than restarting the main container into a broken state
819+ // (e.g., missing database hostname).
820+ if withAttachments {
821+ configuredAttachments := s.resolveAttachmentsForDomain(domainName)
822+ if len(configuredAttachments) > 0 && len(attachmentIDs) < len(configuredAttachments) {
823+ missing := len(configuredAttachments) - len(attachmentIDs)
824+ return fmt.Errorf("%w: domain %q has %d configured attachment(s) but only %d deployed (%d missing)",
825+ domain.ErrAttachmentNotDeployed, domainName, len(configuredAttachments), len(attachmentIDs), missing)
826+ }
827+ }
828+
816 // Restart the main container829 // Restart the main container
817 if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {830 if err := s.runtime.RestartContainer(ctx, container.ID); err != nil {
818 if !isContainerNotFoundError(err) {831 if !isContainerNotFoundError(err) {
M internal/usecase/container/service_test.go +130 -0
@@ -1236,6 +1236,136 @@ func TestService_Restart_WithAttachments(t *testing.T) {
12361236 assert.NoError(t, err)
12371237 }
12381238
1239+func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *testing.T) {
1240+ runtime := mocks.NewMockContainerRuntime(t)
1241+ envLoader := mocks.NewMockEnvLoader(t)
1242+ eventBus := mocks.NewMockEventPublisher(t)
1243+
1244+ provider := &fakeConfigProvider{
1245+ attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1246+ networkGroups: map[string][]string{},
1247+ }
1248+
1249+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1250+
1251+ svc.containers["test.example.com"] = &domain.Container{
1252+ ID: "container-123",
1253+ Name: "gordon-test.example.com",
1254+ Status: "running",
1255+ }
1256+ // No attachment containers tracked — s.attachments is empty
1257+
1258+ err := svc.Restart(testContext(), "test.example.com", true)
1259+
1260+ assert.Error(t, err)
1261+ assert.True(t, errors.Is(err, domain.ErrAttachmentNotDeployed))
1262+ // Main container should NOT have been restarted
1263+ runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything)
1264+}
1265+
1266+func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.T) {
1267+ runtime := mocks.NewMockContainerRuntime(t)
1268+ envLoader := mocks.NewMockEnvLoader(t)
1269+ eventBus := mocks.NewMockEventPublisher(t)
1270+
1271+ provider := &fakeConfigProvider{
1272+ attachments: map[string][]string{"test.example.com": {"postgres:16", "redis:7"}},
1273+ networkGroups: map[string][]string{},
1274+ }
1275+
1276+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1277+
1278+ svc.containers["test.example.com"] = &domain.Container{
1279+ ID: "container-123",
1280+ Name: "gordon-test.example.com",
1281+ Status: "running",
1282+ }
1283+ // Only 1 of 2 attachments deployed
1284+ svc.attachments["test.example.com"] = []string{"attach-postgres"}
1285+
1286+ err := svc.Restart(testContext(), "test.example.com", true)
1287+
1288+ assert.Error(t, err)
1289+ assert.True(t, errors.Is(err, domain.ErrAttachmentNotDeployed))
1290+ assert.Contains(t, err.Error(), "1 missing")
1291+ // Main container should NOT have been restarted
1292+ runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything)
1293+}
1294+
1295+func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
1296+ runtime := mocks.NewMockContainerRuntime(t)
1297+ envLoader := mocks.NewMockEnvLoader(t)
1298+ eventBus := mocks.NewMockEventPublisher(t)
1299+
1300+ provider := &fakeConfigProvider{
1301+ attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1302+ networkGroups: map[string][]string{},
1303+ }
1304+
1305+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1306+
1307+ svc.containers["test.example.com"] = &domain.Container{
1308+ ID: "container-123",
1309+ Name: "gordon-test.example.com",
1310+ Status: "running",
1311+ }
1312+ svc.attachments["test.example.com"] = []string{"attach-456"}
1313+
1314+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1315+ runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil)
1316+
1317+ err := svc.Restart(testContext(), "test.example.com", true)
1318+ assert.NoError(t, err)
1319+}
1320+
1321+func TestService_Restart_WithoutAttachmentFlag_IgnoresMissingAttachments(t *testing.T) {
1322+ runtime := mocks.NewMockContainerRuntime(t)
1323+ envLoader := mocks.NewMockEnvLoader(t)
1324+ eventBus := mocks.NewMockEventPublisher(t)
1325+
1326+ provider := &fakeConfigProvider{
1327+ attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1328+ networkGroups: map[string][]string{},
1329+ }
1330+
1331+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1332+
1333+ svc.containers["test.example.com"] = &domain.Container{
1334+ ID: "container-123",
1335+ Name: "gordon-test.example.com",
1336+ Status: "running",
1337+ }
1338+
1339+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1340+
1341+ err := svc.Restart(testContext(), "test.example.com", false)
1342+ assert.NoError(t, err)
1343+}
1344+
1345+func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *testing.T) {
1346+ runtime := mocks.NewMockContainerRuntime(t)
1347+ envLoader := mocks.NewMockEnvLoader(t)
1348+ eventBus := mocks.NewMockEventPublisher(t)
1349+
1350+ provider := &fakeConfigProvider{
1351+ attachments: map[string][]string{},
1352+ networkGroups: map[string][]string{},
1353+ }
1354+
1355+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1356+
1357+ svc.containers["test.example.com"] = &domain.Container{
1358+ ID: "container-123",
1359+ Name: "gordon-test.example.com",
1360+ Status: "running",
1361+ }
1362+
1363+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1364+
1365+ err := svc.Restart(testContext(), "test.example.com", true)
1366+ assert.NoError(t, err)
1367+}
1368+
12391369 func TestNormalizeImageRef(t *testing.T) {
12401370 tests := []struct {
12411371 name string
@@ -1236,6 +1236,136 @@ func TestService_Restart_WithAttachments(t *testing.T) {
1236 assert.NoError(t, err)1236 assert.NoError(t, err)
1237 }1237 }
1238 1238
1239+func TestService_Restart_WithAttachments_ErrorsWhenConfiguredButNotDeployed(t *testing.T) {
1240+ runtime := mocks.NewMockContainerRuntime(t)
1241+ envLoader := mocks.NewMockEnvLoader(t)
1242+ eventBus := mocks.NewMockEventPublisher(t)
1243+
1244+ provider := &fakeConfigProvider{
1245+ attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1246+ networkGroups: map[string][]string{},
1247+ }
1248+
1249+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1250+
1251+ svc.containers["test.example.com"] = &domain.Container{
1252+ ID: "container-123",
1253+ Name: "gordon-test.example.com",
1254+ Status: "running",
1255+ }
1256+ // No attachment containers tracked — s.attachments is empty
1257+
1258+ err := svc.Restart(testContext(), "test.example.com", true)
1259+
1260+ assert.Error(t, err)
1261+ assert.True(t, errors.Is(err, domain.ErrAttachmentNotDeployed))
1262+ // Main container should NOT have been restarted
1263+ runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything)
1264+}
1265+
1266+func TestService_Restart_WithAttachments_ErrorsWhenPartiallyDeployed(t *testing.T) {
1267+ runtime := mocks.NewMockContainerRuntime(t)
1268+ envLoader := mocks.NewMockEnvLoader(t)
1269+ eventBus := mocks.NewMockEventPublisher(t)
1270+
1271+ provider := &fakeConfigProvider{
1272+ attachments: map[string][]string{"test.example.com": {"postgres:16", "redis:7"}},
1273+ networkGroups: map[string][]string{},
1274+ }
1275+
1276+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1277+
1278+ svc.containers["test.example.com"] = &domain.Container{
1279+ ID: "container-123",
1280+ Name: "gordon-test.example.com",
1281+ Status: "running",
1282+ }
1283+ // Only 1 of 2 attachments deployed
1284+ svc.attachments["test.example.com"] = []string{"attach-postgres"}
1285+
1286+ err := svc.Restart(testContext(), "test.example.com", true)
1287+
1288+ assert.Error(t, err)
1289+ assert.True(t, errors.Is(err, domain.ErrAttachmentNotDeployed))
1290+ assert.Contains(t, err.Error(), "1 missing")
1291+ // Main container should NOT have been restarted
1292+ runtime.AssertNotCalled(t, "RestartContainer", mock.Anything, mock.Anything)
1293+}
1294+
1295+func TestService_Restart_WithAttachments_SucceedsWhenAllDeployed(t *testing.T) {
1296+ runtime := mocks.NewMockContainerRuntime(t)
1297+ envLoader := mocks.NewMockEnvLoader(t)
1298+ eventBus := mocks.NewMockEventPublisher(t)
1299+
1300+ provider := &fakeConfigProvider{
1301+ attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1302+ networkGroups: map[string][]string{},
1303+ }
1304+
1305+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1306+
1307+ svc.containers["test.example.com"] = &domain.Container{
1308+ ID: "container-123",
1309+ Name: "gordon-test.example.com",
1310+ Status: "running",
1311+ }
1312+ svc.attachments["test.example.com"] = []string{"attach-456"}
1313+
1314+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1315+ runtime.EXPECT().RestartContainer(mock.Anything, "attach-456").Return(nil)
1316+
1317+ err := svc.Restart(testContext(), "test.example.com", true)
1318+ assert.NoError(t, err)
1319+}
1320+
1321+func TestService_Restart_WithoutAttachmentFlag_IgnoresMissingAttachments(t *testing.T) {
1322+ runtime := mocks.NewMockContainerRuntime(t)
1323+ envLoader := mocks.NewMockEnvLoader(t)
1324+ eventBus := mocks.NewMockEventPublisher(t)
1325+
1326+ provider := &fakeConfigProvider{
1327+ attachments: map[string][]string{"test.example.com": {"postgres:16"}},
1328+ networkGroups: map[string][]string{},
1329+ }
1330+
1331+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1332+
1333+ svc.containers["test.example.com"] = &domain.Container{
1334+ ID: "container-123",
1335+ Name: "gordon-test.example.com",
1336+ Status: "running",
1337+ }
1338+
1339+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1340+
1341+ err := svc.Restart(testContext(), "test.example.com", false)
1342+ assert.NoError(t, err)
1343+}
1344+
1345+func TestService_Restart_WithAttachments_NoConfiguredNoDeployed_Succeeds(t *testing.T) {
1346+ runtime := mocks.NewMockContainerRuntime(t)
1347+ envLoader := mocks.NewMockEnvLoader(t)
1348+ eventBus := mocks.NewMockEventPublisher(t)
1349+
1350+ provider := &fakeConfigProvider{
1351+ attachments: map[string][]string{},
1352+ networkGroups: map[string][]string{},
1353+ }
1354+
1355+ svc := NewService(runtime, envLoader, eventBus, nil, Config{}, provider)
1356+
1357+ svc.containers["test.example.com"] = &domain.Container{
1358+ ID: "container-123",
1359+ Name: "gordon-test.example.com",
1360+ Status: "running",
1361+ }
1362+
1363+ runtime.EXPECT().RestartContainer(mock.Anything, "container-123").Return(nil)
1364+
1365+ err := svc.Restart(testContext(), "test.example.com", true)
1366+ assert.NoError(t, err)
1367+}
1368+
1239 func TestNormalizeImageRef(t *testing.T) {1369 func TestNormalizeImageRef(t *testing.T) {
1240 tests := []struct {1370 tests := []struct {
1241 name string1371 name string

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