detached 7643cd4

fix(container): rebuild attachment map in SyncContainers to prevent orphaned containers after restart

commit
7643cd4f04d679eb003d1d46c21858c94d1fea72
author
bnema · · 2026-02-24 21:54 UTC
parent
d35750b
signature
unsigned
2 files · +56 -3
M internal/usecase/container/service.go +13 -3
@@ -903,16 +903,26 @@ func (s *Service) SyncContainers(ctx context.Context) error {
903903 }
904904
905905 managed := make(map[string]*domain.Container)
906+ attachments := make(map[string][]string)
906907 for _, c := range allContainers {
907- if c.Labels != nil {
908- if d, ok := c.Labels[domain.LabelDomain]; ok && c.Labels[domain.LabelManaged] == "true" {
909- managed[d] = c
908+ if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
909+ continue
910+ }
911+ if c.Labels[domain.LabelAttachment] == "true" {
912+ owner := c.Labels[domain.LabelAttachedTo]
913+ if owner != "" {
914+ attachments[owner] = append(attachments[owner], c.ID)
910915 }
916+ continue
917+ }
918+ if d, ok := c.Labels[domain.LabelDomain]; ok {
919+ managed[d] = c
911920 }
912921 }
913922
914923 s.mu.Lock()
915924 s.containers = managed
925+ s.attachments = attachments
916926 newCount := int64(len(managed))
917927 delta := newCount - s.managedCount
918928 s.managedCount = newCount
@@ -903,16 +903,26 @@ func (s *Service) SyncContainers(ctx context.Context) error {
903 }903 }
904 904
905 managed := make(map[string]*domain.Container)905 managed := make(map[string]*domain.Container)
906+ attachments := make(map[string][]string)
906 for _, c := range allContainers {907 for _, c := range allContainers {
907- if c.Labels != nil {908+ if c.Labels == nil || c.Labels[domain.LabelManaged] != "true" {
908- if d, ok := c.Labels[domain.LabelDomain]; ok && c.Labels[domain.LabelManaged] == "true" {909+ continue
909- managed[d] = c910+ }
911+ if c.Labels[domain.LabelAttachment] == "true" {
912+ owner := c.Labels[domain.LabelAttachedTo]
913+ if owner != "" {
914+ attachments[owner] = append(attachments[owner], c.ID)
910 }915 }
916+ continue
917+ }
918+ if d, ok := c.Labels[domain.LabelDomain]; ok {
919+ managed[d] = c
911 }920 }
912 }921 }
913 922
914 s.mu.Lock()923 s.mu.Lock()
915 s.containers = managed924 s.containers = managed
925+ s.attachments = attachments
916 newCount := int64(len(managed))926 newCount := int64(len(managed))
917 delta := newCount - s.managedCount927 delta := newCount - s.managedCount
918 s.managedCount = newCount928 s.managedCount = newCount
M internal/usecase/container/service_test.go +43 -0
@@ -2332,6 +2332,49 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
23322332 assert.Equal(t, "new-container", result.ID)
23332333 }
23342334
2335+func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
2336+ runtime := mocks.NewMockContainerRuntime(t)
2337+ envLoader := mocks.NewMockEnvLoader(t)
2338+ eventBus := mocks.NewMockEventPublisher(t)
2339+
2340+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
2341+ ctx := testContext()
2342+
2343+ // ListContainers returns one main container and one attachment container
2344+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
2345+ {
2346+ ID: "main-container-1",
2347+ Labels: map[string]string{
2348+ domain.LabelDomain: "app.example.com",
2349+ domain.LabelManaged: "true",
2350+ },
2351+ },
2352+ {
2353+ ID: "attachment-container-1",
2354+ Labels: map[string]string{
2355+ domain.LabelManaged: "true",
2356+ domain.LabelAttachment: "true",
2357+ domain.LabelAttachedTo: "app.example.com",
2358+ },
2359+ },
2360+ }, nil).Once()
2361+
2362+ require.NoError(t, svc.SyncContainers(ctx))
2363+
2364+ // Main container should be in s.containers
2365+ tracked, exists := svc.Get(ctx, "app.example.com")
2366+ require.True(t, exists, "main container should be tracked after SyncContainers")
2367+ assert.Equal(t, "main-container-1", tracked.ID)
2368+
2369+ // Attachment container should be in s.attachments under the owner domain
2370+ svc.mu.RLock()
2371+ attachIDs := svc.attachments["app.example.com"]
2372+ svc.mu.RUnlock()
2373+
2374+ require.Len(t, attachIDs, 1, "attachment map should contain one entry for app.example.com after SyncContainers")
2375+ assert.Equal(t, "attachment-container-1", attachIDs[0])
2376+}
2377+
23352378 func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
23362379 runtime := mocks.NewMockContainerRuntime(t)
23372380 envLoader := mocks.NewMockEnvLoader(t)
@@ -2332,6 +2332,49 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2332 assert.Equal(t, "new-container", result.ID)2332 assert.Equal(t, "new-container", result.ID)
2333 }2333 }
2334 2334
2335+func TestSyncContainersRebuildsAttachmentMap(t *testing.T) {
2336+ runtime := mocks.NewMockContainerRuntime(t)
2337+ envLoader := mocks.NewMockEnvLoader(t)
2338+ eventBus := mocks.NewMockEventPublisher(t)
2339+
2340+ svc := NewService(runtime, envLoader, eventBus, nil, Config{})
2341+ ctx := testContext()
2342+
2343+ // ListContainers returns one main container and one attachment container
2344+ runtime.EXPECT().ListContainers(mock.Anything, false).Return([]*domain.Container{
2345+ {
2346+ ID: "main-container-1",
2347+ Labels: map[string]string{
2348+ domain.LabelDomain: "app.example.com",
2349+ domain.LabelManaged: "true",
2350+ },
2351+ },
2352+ {
2353+ ID: "attachment-container-1",
2354+ Labels: map[string]string{
2355+ domain.LabelManaged: "true",
2356+ domain.LabelAttachment: "true",
2357+ domain.LabelAttachedTo: "app.example.com",
2358+ },
2359+ },
2360+ }, nil).Once()
2361+
2362+ require.NoError(t, svc.SyncContainers(ctx))
2363+
2364+ // Main container should be in s.containers
2365+ tracked, exists := svc.Get(ctx, "app.example.com")
2366+ require.True(t, exists, "main container should be tracked after SyncContainers")
2367+ assert.Equal(t, "main-container-1", tracked.ID)
2368+
2369+ // Attachment container should be in s.attachments under the owner domain
2370+ svc.mu.RLock()
2371+ attachIDs := svc.attachments["app.example.com"]
2372+ svc.mu.RUnlock()
2373+
2374+ require.Len(t, attachIDs, 1, "attachment map should contain one entry for app.example.com after SyncContainers")
2375+ assert.Equal(t, "attachment-container-1", attachIDs[0])
2376+}
2377+
2335 func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {2378 func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2336 runtime := mocks.NewMockContainerRuntime(t)2379 runtime := mocks.NewMockContainerRuntime(t)
2337 envLoader := mocks.NewMockEnvLoader(t)2380 envLoader := mocks.NewMockEnvLoader(t)

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