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 | |||
| 813 | 813 | 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 | 829 | // Restart the main container | |
| 817 | 830 | if err := s.runtime.RestartContainer(ctx, container.ID); err != nil { | |
| 818 | 831 | if !isContainerNotFoundError(err) { | |
| @@ -813,6 +813,19 @@ func (s *Service) Restart(ctx context.Context, domainName string, withAttachment | |||||
| 813 | return domain.ErrContainerNotFound | 813 | 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 container | 829 | // 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) { | |||
| 1236 | 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 | 1369 | func TestNormalizeImageRef(t *testing.T) { | |
| 1240 | 1370 | tests := []struct { | |
| 1241 | 1371 | 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 string | 1371 | name string | ||