detached abb3644

Merge pull request #133 from bnema/fix/deploy-errors-and-local-cli

fix: deploy context cancellation, async cleanup, and local CLI auth gate
commit
abb36443ee0b20f93a4bd95b5b5c6851d77926ac
author
bnema · · 2026-03-19 04:04 UTC
committer
GitHub · · 2026-03-19 04:04 UTC
merge of
05b767c + 210b482 · diff against the first parent
signature
unsigned
7 files · +65 -43
M internal/adapters/in/cli/controlplane_resolver.go +0 -4
@@ -34,10 +34,6 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
3434 if err != nil {
3535 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
3636 }
37- if kernel.AuthEnabled() {
38- kernel.Close()
39- return nil, fmt.Errorf("local control plane is disabled when auth.enabled=true; use --remote with token")
40- }
4137
4238 return &controlPlaneHandle{
4339 plane: NewLocalControlPlane(kernel),
@@ -34,10 +34,6 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) {
34 if err != nil {34 if err != nil {
35 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)35 return nil, fmt.Errorf("failed to initialize local control plane: %w", err)
36 }36 }
37- if kernel.AuthEnabled() {
38- kernel.Close()
39- return nil, fmt.Errorf("local control plane is disabled when auth.enabled=true; use --remote with token")
40- }
41 37
42 return &controlPlaneHandle{38 return &controlPlaneHandle{
43 plane: NewLocalControlPlane(kernel),39 plane: NewLocalControlPlane(kernel),
M internal/adapters/in/cli/controlplane_resolver_test.go +13 -24
@@ -3,11 +3,12 @@ package cli
33 import (
44 "os"
55 "path/filepath"
6- "strings"
76 "testing"
7+
8+ "github.com/stretchr/testify/require"
89 )
910
10-func TestResolveControlPlane_LocalDeniedWhenAuthEnabled(t *testing.T) {
11+func TestResolveControlPlane_LocalAllowedWhenAuthEnabled(t *testing.T) {
1112 tmpDir := t.TempDir()
1213 t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
1314 t.Setenv("GORDON_REMOTE", "")
@@ -32,20 +33,13 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
3233 enabled = true
3334 secrets_backend = "unsafe"
3435 `), 0o600)
35- if err != nil {
36- t.Fatalf("failed to write config: %v", err)
37- }
36+ require.NoError(t, err)
3837
3938 handle, err := resolveControlPlane(configPath)
40- if err == nil {
41- if handle != nil {
42- handle.close()
43- }
44- t.Fatalf("expected error when auth.enabled=true and no remote target")
45- }
46- if !strings.Contains(err.Error(), "local control plane is disabled when auth.enabled=true") {
47- t.Fatalf("unexpected error: %v", err)
48- }
39+ require.NoError(t, err)
40+ require.NotNil(t, handle)
41+ require.NotNil(t, handle.plane)
42+ defer handle.close()
4943 }
5044
5145 func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {
@@ -73,16 +67,11 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
7367 enabled = false
7468 secrets_backend = "unsafe"
7569 `), 0o600)
76- if err != nil {
77- t.Fatalf("failed to write config: %v", err)
78- }
70+ require.NoError(t, err)
7971
8072 handle, err := resolveControlPlane(configPath)
81- if err != nil {
82- t.Fatalf("expected local control-plane handle, got error: %v", err)
83- }
84- if handle == nil || handle.plane == nil {
85- t.Fatalf("expected non-nil local control-plane handle")
86- }
87- handle.close()
73+ require.NoError(t, err)
74+ require.NotNil(t, handle)
75+ require.NotNil(t, handle.plane)
76+ defer handle.close()
8877 }
@@ -3,11 +3,12 @@ package cli
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6- "strings"
7 "testing"6 "testing"
7+
8+ "github.com/stretchr/testify/require"
8 )9 )
9 10
10-func TestResolveControlPlane_LocalDeniedWhenAuthEnabled(t *testing.T) {11+func TestResolveControlPlane_LocalAllowedWhenAuthEnabled(t *testing.T) {
11 tmpDir := t.TempDir()12 tmpDir := t.TempDir()
12 t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))13 t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
13 t.Setenv("GORDON_REMOTE", "")14 t.Setenv("GORDON_REMOTE", "")
@@ -32,20 +33,13 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
32 enabled = true33 enabled = true
33 secrets_backend = "unsafe"34 secrets_backend = "unsafe"
34 `), 0o600)35 `), 0o600)
35- if err != nil {36+ require.NoError(t, err)
36- t.Fatalf("failed to write config: %v", err)
37- }
38 37
39 handle, err := resolveControlPlane(configPath)38 handle, err := resolveControlPlane(configPath)
40- if err == nil {39+ require.NoError(t, err)
41- if handle != nil {40+ require.NotNil(t, handle)
42- handle.close()41+ require.NotNil(t, handle.plane)
43- }42+ defer handle.close()
44- t.Fatalf("expected error when auth.enabled=true and no remote target")
45- }
46- if !strings.Contains(err.Error(), "local control plane is disabled when auth.enabled=true") {
47- t.Fatalf("unexpected error: %v", err)
48- }
49 }43 }
50 44
51 func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {45 func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {
@@ -73,16 +67,11 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`"
73 enabled = false67 enabled = false
74 secrets_backend = "unsafe"68 secrets_backend = "unsafe"
75 `), 0o600)69 `), 0o600)
76- if err != nil {70+ require.NoError(t, err)
77- t.Fatalf("failed to write config: %v", err)
78- }
79 71
80 handle, err := resolveControlPlane(configPath)72 handle, err := resolveControlPlane(configPath)
81- if err != nil {73+ require.NoError(t, err)
82- t.Fatalf("expected local control-plane handle, got error: %v", err)74+ require.NotNil(t, handle)
83- }75+ require.NotNil(t, handle.plane)
84- if handle == nil || handle.plane == nil {76+ defer handle.close()
85- t.Fatalf("expected non-nil local control-plane handle")
86- }
87- handle.close()
88 }77 }
M internal/adapters/in/http/admin/handler.go +6 -1
@@ -1325,7 +1325,12 @@ func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path str
13251325
13261326 withAttachments := r.URL.Query().Get("attachments") == "true"
13271327
1328- if err := h.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
1328+ // Use a detached context so the restart completes even if the HTTP client
1329+ // disconnects. Podman restart can take 30+ seconds (SIGTERM + wait + start).
1330+ restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
1331+ defer cancel()
1332+
1333+ if err := h.containerSvc.Restart(restartCtx, restartDomain, withAttachments); err != nil {
13291334 log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
13301335 if errors.Is(err, domain.ErrContainerNotFound) {
13311336 h.sendError(w, http.StatusNotFound, "container not found")
@@ -1325,7 +1325,12 @@ func (h *Handler) handleRestart(w http.ResponseWriter, r *http.Request, path str
1325 1325
1326 withAttachments := r.URL.Query().Get("attachments") == "true"1326 withAttachments := r.URL.Query().Get("attachments") == "true"
1327 1327
1328- if err := h.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {1328+ // Use a detached context so the restart completes even if the HTTP client
1329+ // disconnects. Podman restart can take 30+ seconds (SIGTERM + wait + start).
1330+ restartCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 2*time.Minute)
1331+ defer cancel()
1332+
1333+ if err := h.containerSvc.Restart(restartCtx, restartDomain, withAttachments); err != nil {
1329 log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")1334 log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container")
1330 if errors.Is(err, domain.ErrContainerNotFound) {1335 if errors.Is(err, domain.ErrContainerNotFound) {
1331 h.sendError(w, http.StatusNotFound, "container not found")1336 h.sendError(w, http.StatusNotFound, "container not found")
M internal/adapters/in/http/middleware/auth.go +2 -2
@@ -69,8 +69,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
6969 return
7070 }
7171
72- // Warn if not using TLS
73- if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {
72+ // Warn if not using TLS (skip for localhost — internal proxy traffic)
73+ if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httputil.IsLocalhostRequest(r) {
7474 log.Warn().
7575 Str(zerowrap.FieldLayer, "adapter").
7676 Str(zerowrap.FieldAdapter, "http").
@@ -69,8 +69,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
69 return69 return
70 }70 }
71 71
72- // Warn if not using TLS72+ // Warn if not using TLS (skip for localhost — internal proxy traffic)
73- if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" {73+ if r.TLS == nil && r.Header.Get("X-Forwarded-Proto") != "https" && !httputil.IsLocalhostRequest(r) {
74 log.Warn().74 log.Warn().
75 Str(zerowrap.FieldLayer, "adapter").75 Str(zerowrap.FieldLayer, "adapter").
76 Str(zerowrap.FieldAdapter, "http").76 Str(zerowrap.FieldAdapter, "http").
M internal/adapters/out/docker/runtime.go +2 -2
@@ -217,7 +217,7 @@ func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
217217 })
218218 log := zerowrap.FromCtx(ctx)
219219
220- timeout := 30 // 30 seconds timeout
220+ timeout := 20 // 20 seconds before SIGKILL
221221 err := r.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
222222 if err != nil {
223223 return log.WrapErr(err, "failed to stop container")
@@ -237,7 +237,7 @@ func (r *Runtime) RestartContainer(ctx context.Context, containerID string) erro
237237 })
238238 log := zerowrap.FromCtx(ctx)
239239
240- timeout := 30 // 30 seconds timeout
240+ timeout := 20 // 20 seconds before SIGKILL
241241 err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
242242 if err != nil {
243243 return log.WrapErr(err, "failed to restart container")
@@ -217,7 +217,7 @@ func (r *Runtime) StopContainer(ctx context.Context, containerID string) error {
217 })217 })
218 log := zerowrap.FromCtx(ctx)218 log := zerowrap.FromCtx(ctx)
219 219
220- timeout := 30 // 30 seconds timeout220+ timeout := 20 // 20 seconds before SIGKILL
221 err := r.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})221 err := r.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
222 if err != nil {222 if err != nil {
223 return log.WrapErr(err, "failed to stop container")223 return log.WrapErr(err, "failed to stop container")
@@ -237,7 +237,7 @@ func (r *Runtime) RestartContainer(ctx context.Context, containerID string) erro
237 })237 })
238 log := zerowrap.FromCtx(ctx)238 log := zerowrap.FromCtx(ctx)
239 239
240- timeout := 30 // 30 seconds timeout240+ timeout := 20 // 20 seconds before SIGKILL
241 err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})241 err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
242 if err != nil {242 if err != nil {
243 return log.WrapErr(err, "failed to restart container")243 return log.WrapErr(err, "failed to restart container")
M internal/usecase/container/service.go +26 -10
@@ -82,7 +82,8 @@ type Service struct {
8282 attachments map[string][]string // ownerDomain → []containerIDs
8383 managedCount int64 // tracks UpDownCounter value for delta computation
8484 mu sync.RWMutex
85- deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
85+ deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
86+ cleanupWg sync.WaitGroup // tracks background old-container cleanup goroutines
8687 monitor *Monitor
8788 }
8889
@@ -344,7 +345,15 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
344345 }
345346 }
346347
347- s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID)
348+ // Finalize old container in the background — the new container is already
349+ // serving traffic, so there's no reason to block the deploy response while
350+ // waiting for the old container to stop (which can take 20s if the app
351+ // doesn't handle SIGTERM).
352+ s.cleanupWg.Add(1)
353+ go func() {
354+ defer s.cleanupWg.Done()
355+ s.finalizePreviousContainer(context.WithoutCancel(ctx), route.Domain, existing, hasExisting, invalidated, newContainer.ID)
356+ }()
348357
349358 // Start container log collection (non-blocking, errors don't fail deployment)
350359 s.startLogCollection(ctx, newContainer.ID, route.Domain)
@@ -734,17 +743,11 @@ func (s *Service) finalizePreviousContainer(ctx context.Context, domainName stri
734743 if !hasExisting {
735744 return
736745 }
737- log := zerowrap.FromCtx(ctx)
738746
739747 if invalidated {
740748 s.waitForDrain(ctx, existing.ID)
741749 }
742750
743- if err := ctx.Err(); err != nil {
744- log.Debug().Err(err).Str("domain", domainName).Str("new_container_id", newContainerID).Msg("skipping old container cleanup due to canceled context")
745- return
746- }
747-
748751 s.cleanupOldContainer(ctx, existing, newContainerID, domainName)
749752 }
750753
@@ -1314,6 +1317,9 @@ func (s *Service) Shutdown(ctx context.Context) error {
13141317 log := zerowrap.FromCtx(ctx)
13151318 log.Info().Msg("shutting down container manager...")
13161319
1320+ // Wait for any in-flight background cleanup goroutines to finish
1321+ s.cleanupWg.Wait()
1322+
13171323 // Containers are left running across Gordon restarts.
13181324 // SyncContainers + AutoStart will pick them back up on next boot.
13191325
@@ -1328,6 +1334,12 @@ func (s *Service) Shutdown(ctx context.Context) error {
13281334 return nil
13291335 }
13301336
1337+// WaitForCleanup blocks until all background cleanup goroutines complete.
1338+// Intended for use in tests to avoid mock assertion races.
1339+func (s *Service) WaitForCleanup() {
1340+ s.cleanupWg.Wait()
1341+}
1342+
13311343 // StartMonitor begins background monitoring of tracked containers.
13321344 // The monitor restarts crashed containers and detects crash loops.
13331345 // Safe to call multiple times; subsequent calls are no-ops.
@@ -1429,13 +1441,17 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
14291441
14301442 log := zerowrap.FromCtx(ctx)
14311443
1432- logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
1444+ // Use context.WithoutCancel so the log stream outlives the HTTP request.
1445+ // Without this, the Docker log stream closes when the deploy response completes.
1446+ bgCtx := context.WithoutCancel(ctx)
1447+
1448+ logStream, err := s.runtime.GetContainerLogs(bgCtx, containerID, true)
14331449 if err != nil {
14341450 log.Warn().Err(err).Msg("failed to get container logs for collection")
14351451 return
14361452 }
14371453
1438- if err := s.logWriter.StartLogging(context.WithoutCancel(ctx), containerID, domainName, logStream); err != nil {
1454+ if err := s.logWriter.StartLogging(bgCtx, containerID, domainName, logStream); err != nil {
14391455 log.Warn().Err(err).Msg("failed to start container log collection")
14401456 logStream.Close()
14411457 }
@@ -82,7 +82,8 @@ type Service struct {
82 attachments map[string][]string // ownerDomain → []containerIDs82 attachments map[string][]string // ownerDomain → []containerIDs
83 managedCount int64 // tracks UpDownCounter value for delta computation83 managedCount int64 // tracks UpDownCounter value for delta computation
84 mu sync.RWMutex84 mu sync.RWMutex
85- deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)85+ deployMu sync.Map // per-domain deploy locks (domain → *domainDeployLock)
86+ cleanupWg sync.WaitGroup // tracks background old-container cleanup goroutines
86 monitor *Monitor87 monitor *Monitor
87 }88 }
88 89
@@ -344,7 +345,15 @@ func (s *Service) Deploy(ctx context.Context, route domain.Route) (*domain.Conta
344 }345 }
345 }346 }
346 347
347- s.finalizePreviousContainer(ctx, route.Domain, existing, hasExisting, invalidated, newContainer.ID)348+ // Finalize old container in the background — the new container is already
349+ // serving traffic, so there's no reason to block the deploy response while
350+ // waiting for the old container to stop (which can take 20s if the app
351+ // doesn't handle SIGTERM).
352+ s.cleanupWg.Add(1)
353+ go func() {
354+ defer s.cleanupWg.Done()
355+ s.finalizePreviousContainer(context.WithoutCancel(ctx), route.Domain, existing, hasExisting, invalidated, newContainer.ID)
356+ }()
348 357
349 // Start container log collection (non-blocking, errors don't fail deployment)358 // Start container log collection (non-blocking, errors don't fail deployment)
350 s.startLogCollection(ctx, newContainer.ID, route.Domain)359 s.startLogCollection(ctx, newContainer.ID, route.Domain)
@@ -734,17 +743,11 @@ func (s *Service) finalizePreviousContainer(ctx context.Context, domainName stri
734 if !hasExisting {743 if !hasExisting {
735 return744 return
736 }745 }
737- log := zerowrap.FromCtx(ctx)
738 746
739 if invalidated {747 if invalidated {
740 s.waitForDrain(ctx, existing.ID)748 s.waitForDrain(ctx, existing.ID)
741 }749 }
742 750
743- if err := ctx.Err(); err != nil {
744- log.Debug().Err(err).Str("domain", domainName).Str("new_container_id", newContainerID).Msg("skipping old container cleanup due to canceled context")
745- return
746- }
747-
748 s.cleanupOldContainer(ctx, existing, newContainerID, domainName)751 s.cleanupOldContainer(ctx, existing, newContainerID, domainName)
749 }752 }
750 753
@@ -1314,6 +1317,9 @@ func (s *Service) Shutdown(ctx context.Context) error {
1314 log := zerowrap.FromCtx(ctx)1317 log := zerowrap.FromCtx(ctx)
1315 log.Info().Msg("shutting down container manager...")1318 log.Info().Msg("shutting down container manager...")
1316 1319
1320+ // Wait for any in-flight background cleanup goroutines to finish
1321+ s.cleanupWg.Wait()
1322+
1317 // Containers are left running across Gordon restarts.1323 // Containers are left running across Gordon restarts.
1318 // SyncContainers + AutoStart will pick them back up on next boot.1324 // SyncContainers + AutoStart will pick them back up on next boot.
1319 1325
@@ -1328,6 +1334,12 @@ func (s *Service) Shutdown(ctx context.Context) error {
1328 return nil1334 return nil
1329 }1335 }
1330 1336
1337+// WaitForCleanup blocks until all background cleanup goroutines complete.
1338+// Intended for use in tests to avoid mock assertion races.
1339+func (s *Service) WaitForCleanup() {
1340+ s.cleanupWg.Wait()
1341+}
1342+
1331 // StartMonitor begins background monitoring of tracked containers.1343 // StartMonitor begins background monitoring of tracked containers.
1332 // The monitor restarts crashed containers and detects crash loops.1344 // The monitor restarts crashed containers and detects crash loops.
1333 // Safe to call multiple times; subsequent calls are no-ops.1345 // Safe to call multiple times; subsequent calls are no-ops.
@@ -1429,13 +1441,17 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
1429 1441
1430 log := zerowrap.FromCtx(ctx)1442 log := zerowrap.FromCtx(ctx)
1431 1443
1432- logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)1444+ // Use context.WithoutCancel so the log stream outlives the HTTP request.
1445+ // Without this, the Docker log stream closes when the deploy response completes.
1446+ bgCtx := context.WithoutCancel(ctx)
1447+
1448+ logStream, err := s.runtime.GetContainerLogs(bgCtx, containerID, true)
1433 if err != nil {1449 if err != nil {
1434 log.Warn().Err(err).Msg("failed to get container logs for collection")1450 log.Warn().Err(err).Msg("failed to get container logs for collection")
1435 return1451 return
1436 }1452 }
1437 1453
1438- if err := s.logWriter.StartLogging(context.WithoutCancel(ctx), containerID, domainName, logStream); err != nil {1454+ if err := s.logWriter.StartLogging(bgCtx, containerID, domainName, logStream); err != nil {
1439 log.Warn().Err(err).Msg("failed to start container log collection")1455 log.Warn().Err(err).Msg("failed to start container log collection")
1440 logStream.Close()1456 logStream.Close()
1441 }1457 }
M internal/usecase/container/service_test.go +16 -0
@@ -609,6 +609,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
609609 result, err := svc.Deploy(ctx, route)
610610
611611 assert.NoError(t, err)
612+ svc.WaitForCleanup()
612613 assert.Equal(t, "new-container", result.ID)
613614 }
614615
@@ -706,6 +707,7 @@ func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T
706707 result, err := svc.Deploy(ctx, route)
707708
708709 assert.NoError(t, err)
710+ svc.WaitForCleanup()
709711 assert.Equal(t, "new-container", result.ID)
710712
711713 // Verify new container is now tracked
@@ -772,6 +774,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
772774 result, err := svc.Deploy(ctx, route)
773775
774776 assert.NoError(t, err)
777+ svc.WaitForCleanup()
775778 assert.Equal(t, "new-container", result.ID)
776779 }
777780
@@ -2082,6 +2085,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
20822085 result, err := svc.Deploy(ctx, route)
20832086
20842087 assert.NoError(t, err)
2088+ svc.WaitForCleanup()
20852089 assert.Equal(t, "new-container", result.ID)
20862090
20872091 // Verify new container is now tracked
@@ -2239,6 +2243,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
22392243
22402244 result, err := svc.Deploy(ctx, route)
22412245 assert.NoError(t, err)
2246+ svc.WaitForCleanup()
22422247 assert.NotNil(t, result)
22432248 assert.Equal(t, "new-container", result.ID)
22442249 }
@@ -2303,6 +2308,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
23032308
23042309 result, err := svc.Deploy(ctx, route)
23052310 assert.NoError(t, err)
2311+ svc.WaitForCleanup()
23062312 assert.NotNil(t, result)
23072313 assert.Equal(t, "new-container", result.ID)
23082314 }
@@ -2525,6 +2531,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
25252531 }(i)
25262532 }
25272533 wg.Wait()
2534+ svc.WaitForCleanup()
25282535
25292536 // Both should succeed (second deploy waits for the first to finish)
25302537 assert.NoError(t, errs[0], "first deploy should succeed")
@@ -2637,6 +2644,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
26372644 result, err := svc.Deploy(ctx, route)
26382645
26392646 assert.NoError(t, err)
2647+ svc.WaitForCleanup()
26402648 assert.Equal(t, "new-container", result.ID)
26412649
26422650 // Verify ordering: cache invalidation MUST happen before old container stop
@@ -2693,6 +2701,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
26932701 result, err := svc.Deploy(ctx, route)
26942702
26952703 assert.NoError(t, err)
2704+ svc.WaitForCleanup()
26962705 assert.Equal(t, "new-container", result.ID)
26972706 }
26982707
@@ -2870,6 +2879,7 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
28702879 result, err := svc.Deploy(ctx, route)
28712880
28722881 assert.NoError(t, err)
2882+ svc.WaitForCleanup()
28732883 assert.Equal(t, "new-container", result.ID)
28742884 }
28752885
@@ -2975,6 +2985,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
29752985 result, err := svc.Deploy(ctx, route)
29762986
29772987 assert.NoError(t, err)
2988+ svc.WaitForCleanup()
29782989 assert.Equal(t, "new-container", result.ID)
29792990 }
29802991
@@ -3032,6 +3043,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
30323043 result, err := svc.Deploy(ctx, route)
30333044
30343045 assert.NoError(t, err)
3046+ svc.WaitForCleanup()
30353047 assert.Equal(t, "new-container", result.ID)
30363048 }
30373049
@@ -3087,6 +3099,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
30873099 result, err := svc.Deploy(ctx, route)
30883100
30893101 assert.NoError(t, err)
3102+ svc.WaitForCleanup()
30903103 assert.Equal(t, "new-container", result.ID)
30913104 }
30923105
@@ -3243,6 +3256,7 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
32433256 result, err := svc.Deploy(ctx, route)
32443257
32453258 assert.NoError(t, err)
3259+ svc.WaitForCleanup()
32463260 assert.Equal(t, "new-container", result.ID)
32473261
32483262 // Verify new container is tracked
@@ -3421,6 +3435,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) {
34213435 result, err := svc.Deploy(ctx, route)
34223436
34233437 assert.NoError(t, err)
3438+ svc.WaitForCleanup()
34243439 assert.NotNil(t, result)
34253440 assert.Equal(t, "new-container", result.ID, "should return new container on successful stabilization")
34263441
@@ -3515,6 +3530,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T)
35153530 result, err := svc.Deploy(ctx, route)
35163531
35173532 assert.NoError(t, err)
3533+ svc.WaitForCleanup()
35183534 assert.NotNil(t, result)
35193535 assert.Equal(t, "new-container", result.ID)
35203536
@@ -609,6 +609,7 @@ func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
609 result, err := svc.Deploy(ctx, route)609 result, err := svc.Deploy(ctx, route)
610 610
611 assert.NoError(t, err)611 assert.NoError(t, err)
612+ svc.WaitForCleanup()
612 assert.Equal(t, "new-container", result.ID)613 assert.Equal(t, "new-container", result.ID)
613 }614 }
614 615
@@ -706,6 +707,7 @@ func TestService_Deploy_ResolvesExistingFromRuntime_WhenMemoryStale(t *testing.T
706 result, err := svc.Deploy(ctx, route)707 result, err := svc.Deploy(ctx, route)
707 708
708 assert.NoError(t, err)709 assert.NoError(t, err)
710+ svc.WaitForCleanup()
709 assert.Equal(t, "new-container", result.ID)711 assert.Equal(t, "new-container", result.ID)
710 712
711 // Verify new container is now tracked713 // Verify new container is now tracked
@@ -772,6 +774,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) {
772 result, err := svc.Deploy(ctx, route)774 result, err := svc.Deploy(ctx, route)
773 775
774 assert.NoError(t, err)776 assert.NoError(t, err)
777+ svc.WaitForCleanup()
775 assert.Equal(t, "new-container", result.ID)778 assert.Equal(t, "new-container", result.ID)
776 }779 }
777 780
@@ -2082,6 +2085,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) {
2082 result, err := svc.Deploy(ctx, route)2085 result, err := svc.Deploy(ctx, route)
2083 2086
2084 assert.NoError(t, err)2087 assert.NoError(t, err)
2088+ svc.WaitForCleanup()
2085 assert.Equal(t, "new-container", result.ID)2089 assert.Equal(t, "new-container", result.ID)
2086 2090
2087 // Verify new container is now tracked2091 // Verify new container is now tracked
@@ -2239,6 +2243,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) {
2239 2243
2240 result, err := svc.Deploy(ctx, route)2244 result, err := svc.Deploy(ctx, route)
2241 assert.NoError(t, err)2245 assert.NoError(t, err)
2246+ svc.WaitForCleanup()
2242 assert.NotNil(t, result)2247 assert.NotNil(t, result)
2243 assert.Equal(t, "new-container", result.ID)2248 assert.Equal(t, "new-container", result.ID)
2244 }2249 }
@@ -2303,6 +2308,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T)
2303 2308
2304 result, err := svc.Deploy(ctx, route)2309 result, err := svc.Deploy(ctx, route)
2305 assert.NoError(t, err)2310 assert.NoError(t, err)
2311+ svc.WaitForCleanup()
2306 assert.NotNil(t, result)2312 assert.NotNil(t, result)
2307 assert.Equal(t, "new-container", result.ID)2313 assert.Equal(t, "new-container", result.ID)
2308 }2314 }
@@ -2525,6 +2531,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) {
2525 }(i)2531 }(i)
2526 }2532 }
2527 wg.Wait()2533 wg.Wait()
2534+ svc.WaitForCleanup()
2528 2535
2529 // Both should succeed (second deploy waits for the first to finish)2536 // Both should succeed (second deploy waits for the first to finish)
2530 assert.NoError(t, errs[0], "first deploy should succeed")2537 assert.NoError(t, errs[0], "first deploy should succeed")
@@ -2637,6 +2644,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) {
2637 result, err := svc.Deploy(ctx, route)2644 result, err := svc.Deploy(ctx, route)
2638 2645
2639 assert.NoError(t, err)2646 assert.NoError(t, err)
2647+ svc.WaitForCleanup()
2640 assert.Equal(t, "new-container", result.ID)2648 assert.Equal(t, "new-container", result.ID)
2641 2649
2642 // Verify ordering: cache invalidation MUST happen before old container stop2650 // Verify ordering: cache invalidation MUST happen before old container stop
@@ -2693,6 +2701,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) {
2693 result, err := svc.Deploy(ctx, route)2701 result, err := svc.Deploy(ctx, route)
2694 2702
2695 assert.NoError(t, err)2703 assert.NoError(t, err)
2704+ svc.WaitForCleanup()
2696 assert.Equal(t, "new-container", result.ID)2705 assert.Equal(t, "new-container", result.ID)
2697 }2706 }
2698 2707
@@ -2870,6 +2879,7 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) {
2870 result, err := svc.Deploy(ctx, route)2879 result, err := svc.Deploy(ctx, route)
2871 2880
2872 assert.NoError(t, err)2881 assert.NoError(t, err)
2882+ svc.WaitForCleanup()
2873 assert.Equal(t, "new-container", result.ID)2883 assert.Equal(t, "new-container", result.ID)
2874 }2884 }
2875 2885
@@ -2975,6 +2985,7 @@ func TestService_Deploy_SkipRedundantDeploy_ContainerNotRunning(t *testing.T) {
2975 result, err := svc.Deploy(ctx, route)2985 result, err := svc.Deploy(ctx, route)
2976 2986
2977 assert.NoError(t, err)2987 assert.NoError(t, err)
2988+ svc.WaitForCleanup()
2978 assert.Equal(t, "new-container", result.ID)2989 assert.Equal(t, "new-container", result.ID)
2979 }2990 }
2980 2991
@@ -3032,6 +3043,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashDiffers(t *testing.T) {
3032 result, err := svc.Deploy(ctx, route)3043 result, err := svc.Deploy(ctx, route)
3033 3044
3034 assert.NoError(t, err)3045 assert.NoError(t, err)
3046+ svc.WaitForCleanup()
3035 assert.Equal(t, "new-container", result.ID)3047 assert.Equal(t, "new-container", result.ID)
3036 }3048 }
3037 3049
@@ -3087,6 +3099,7 @@ func TestService_Deploy_DoesNotSkip_WhenEnvHashMissing(t *testing.T) {
3087 result, err := svc.Deploy(ctx, route)3099 result, err := svc.Deploy(ctx, route)
3088 3100
3089 assert.NoError(t, err)3101 assert.NoError(t, err)
3102+ svc.WaitForCleanup()
3090 assert.Equal(t, "new-container", result.ID)3103 assert.Equal(t, "new-container", result.ID)
3091 }3104 }
3092 3105
@@ -3243,6 +3256,7 @@ func TestService_Deploy_OrphanCleanup_NeverKillsRunningCanonical(t *testing.T) {
3243 result, err := svc.Deploy(ctx, route)3256 result, err := svc.Deploy(ctx, route)
3244 3257
3245 assert.NoError(t, err)3258 assert.NoError(t, err)
3259+ svc.WaitForCleanup()
3246 assert.Equal(t, "new-container", result.ID)3260 assert.Equal(t, "new-container", result.ID)
3247 3261
3248 // Verify new container is tracked3262 // Verify new container is tracked
@@ -3421,6 +3435,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) {
3421 result, err := svc.Deploy(ctx, route)3435 result, err := svc.Deploy(ctx, route)
3422 3436
3423 assert.NoError(t, err)3437 assert.NoError(t, err)
3438+ svc.WaitForCleanup()
3424 assert.NotNil(t, result)3439 assert.NotNil(t, result)
3425 assert.Equal(t, "new-container", result.ID, "should return new container on successful stabilization")3440 assert.Equal(t, "new-container", result.ID, "should return new container on successful stabilization")
3426 3441
@@ -3515,6 +3530,7 @@ func TestService_Deploy_ZeroDowntime_OldNeverStoppedBeforeNewReady(t *testing.T)
3515 result, err := svc.Deploy(ctx, route)3530 result, err := svc.Deploy(ctx, route)
3516 3531
3517 assert.NoError(t, err)3532 assert.NoError(t, err)
3533+ svc.WaitForCleanup()
3518 assert.NotNil(t, result)3534 assert.NotNil(t, result)
3519 assert.Equal(t, "new-container", result.ID)3535 assert.Equal(t, "new-container", result.ID)
3520 3536

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