Merge pull request #133 from bnema/fix/deploy-errors-and-local-cli
fix: deploy context cancellation, async cleanup, and local CLI auth gate
7 files · +65 -43
M internal/adapters/in/cli/controlplane_resolver.go +0 -4
| @@ -34,10 +34,6 @@ func resolveControlPlane(configPath string) (*controlPlaneHandle, error) { | |||
| 34 | 34 | if err != nil { | |
| 35 | 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 | 38 | return &controlPlaneHandle{ | |
| 43 | 39 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "os" | |
| 5 | 5 | "path/filepath" | |
| 6 | - | "strings" | |
| 7 | 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 | 12 | tmpDir := t.TempDir() | |
| 12 | 13 | t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg")) | |
| 13 | 14 | t.Setenv("GORDON_REMOTE", "") | |
| @@ -32,20 +33,13 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`" | |||
| 32 | 33 | enabled = true | |
| 33 | 34 | secrets_backend = "unsafe" | |
| 34 | 35 | `), 0o600) | |
| 35 | - | if err != nil { | |
| 36 | - | t.Fatalf("failed to write config: %v", err) | |
| 37 | - | } | |
| 36 | + | require.NoError(t, err) | |
| 38 | 37 | ||
| 39 | 38 | 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() | |
| 49 | 43 | } | |
| 50 | 44 | ||
| 51 | 45 | func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) { | |
| @@ -73,16 +67,11 @@ data_dir = "`+filepath.Join(tmpDir, "data")+`" | |||
| 73 | 67 | enabled = false | |
| 74 | 68 | secrets_backend = "unsafe" | |
| 75 | 69 | `), 0o600) | |
| 76 | - | if err != nil { | |
| 77 | - | t.Fatalf("failed to write config: %v", err) | |
| 78 | - | } | |
| 70 | + | require.NoError(t, err) | |
| 79 | 71 | ||
| 80 | 72 | 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() | |
| 88 | 77 | } | |
| @@ -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 = true | 33 | 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 = false | 67 | 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 | |||
| 1325 | 1325 | ||
| 1326 | 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 | 1334 | log.Error().Err(err).Str("domain", restartDomain).Msg("failed to restart container") | |
| 1330 | 1335 | if errors.Is(err, domain.ErrContainerNotFound) { | |
| 1331 | 1336 | 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 | |||
| 69 | 69 | return | |
| 70 | 70 | } | |
| 71 | 71 | ||
| 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) { | |
| 74 | 74 | log.Warn(). | |
| 75 | 75 | Str(zerowrap.FieldLayer, "adapter"). | |
| 76 | 76 | Str(zerowrap.FieldAdapter, "http"). | |
| @@ -69,8 +69,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l | |||||
| 69 | return | 69 | return | ||
| 70 | } | 70 | } | ||
| 71 | 71 | ||||
| 72 | - | // Warn if not using TLS | 72 | + | // 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 { | |||
| 217 | 217 | }) | |
| 218 | 218 | log := zerowrap.FromCtx(ctx) | |
| 219 | 219 | ||
| 220 | - | timeout := 30 // 30 seconds timeout | |
| 220 | + | timeout := 20 // 20 seconds before SIGKILL | |
| 221 | 221 | err := r.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}) | |
| 222 | 222 | if err != nil { | |
| 223 | 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 | 238 | log := zerowrap.FromCtx(ctx) | |
| 239 | 239 | ||
| 240 | - | timeout := 30 // 30 seconds timeout | |
| 240 | + | timeout := 20 // 20 seconds before SIGKILL | |
| 241 | 241 | err := r.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout}) | |
| 242 | 242 | if err != nil { | |
| 243 | 243 | 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 timeout | 220 | + | 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 timeout | 240 | + | 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 { | |||
| 82 | 82 | attachments map[string][]string // ownerDomain → []containerIDs | |
| 83 | 83 | managedCount int64 // tracks UpDownCounter value for delta computation | |
| 84 | 84 | 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 | 87 | 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 | 358 | // Start container log collection (non-blocking, errors don't fail deployment) | |
| 350 | 359 | s.startLogCollection(ctx, newContainer.ID, route.Domain) | |
| @@ -734,17 +743,11 @@ func (s *Service) finalizePreviousContainer(ctx context.Context, domainName stri | |||
| 734 | 743 | if !hasExisting { | |
| 735 | 744 | return | |
| 736 | 745 | } | |
| 737 | - | log := zerowrap.FromCtx(ctx) | |
| 738 | 746 | ||
| 739 | 747 | if invalidated { | |
| 740 | 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 | 751 | s.cleanupOldContainer(ctx, existing, newContainerID, domainName) | |
| 749 | 752 | } | |
| 750 | 753 | ||
| @@ -1314,6 +1317,9 @@ func (s *Service) Shutdown(ctx context.Context) error { | |||
| 1314 | 1317 | log := zerowrap.FromCtx(ctx) | |
| 1315 | 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 | 1323 | // Containers are left running across Gordon restarts. | |
| 1318 | 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 | 1334 | 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 | 1343 | // StartMonitor begins background monitoring of tracked containers. | |
| 1332 | 1344 | // The monitor restarts crashed containers and detects crash loops. | |
| 1333 | 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 | 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 | 1449 | if err != nil { | |
| 1434 | 1450 | log.Warn().Err(err).Msg("failed to get container logs for collection") | |
| 1435 | 1451 | 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 | 1455 | log.Warn().Err(err).Msg("failed to start container log collection") | |
| 1440 | 1456 | logStream.Close() | |
| 1441 | 1457 | } | |
| @@ -82,7 +82,8 @@ type Service struct { | |||||
| 82 | attachments map[string][]string // ownerDomain → []containerIDs | 82 | attachments map[string][]string // ownerDomain → []containerIDs | ||
| 83 | managedCount int64 // tracks UpDownCounter value for delta computation | 83 | managedCount int64 // tracks UpDownCounter value for delta computation | ||
| 84 | mu sync.RWMutex | 84 | 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 *Monitor | 87 | 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 | return | 744 | 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 nil | 1334 | 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 | return | 1451 | 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) { | |||
| 609 | 609 | result, err := svc.Deploy(ctx, route) | |
| 610 | 610 | ||
| 611 | 611 | assert.NoError(t, err) | |
| 612 | + | svc.WaitForCleanup() | |
| 612 | 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 | 707 | result, err := svc.Deploy(ctx, route) | |
| 707 | 708 | ||
| 708 | 709 | assert.NoError(t, err) | |
| 710 | + | svc.WaitForCleanup() | |
| 709 | 711 | assert.Equal(t, "new-container", result.ID) | |
| 710 | 712 | ||
| 711 | 713 | // Verify new container is now tracked | |
| @@ -772,6 +774,7 @@ func TestService_Deploy_SkipRedundantDeploy_GetImageIDError(t *testing.T) { | |||
| 772 | 774 | result, err := svc.Deploy(ctx, route) | |
| 773 | 775 | ||
| 774 | 776 | assert.NoError(t, err) | |
| 777 | + | svc.WaitForCleanup() | |
| 775 | 778 | assert.Equal(t, "new-container", result.ID) | |
| 776 | 779 | } | |
| 777 | 780 | ||
| @@ -2082,6 +2085,7 @@ func TestService_Deploy_OrphanCleanupSkipsTrackedContainer(t *testing.T) { | |||
| 2082 | 2085 | result, err := svc.Deploy(ctx, route) | |
| 2083 | 2086 | ||
| 2084 | 2087 | assert.NoError(t, err) | |
| 2088 | + | svc.WaitForCleanup() | |
| 2085 | 2089 | assert.Equal(t, "new-container", result.ID) | |
| 2086 | 2090 | ||
| 2087 | 2091 | // Verify new container is now tracked | |
| @@ -2239,6 +2243,7 @@ func TestService_Deploy_OrphanCleanupRemovesStaleNewContainer(t *testing.T) { | |||
| 2239 | 2243 | ||
| 2240 | 2244 | result, err := svc.Deploy(ctx, route) | |
| 2241 | 2245 | assert.NoError(t, err) | |
| 2246 | + | svc.WaitForCleanup() | |
| 2242 | 2247 | assert.NotNil(t, result) | |
| 2243 | 2248 | assert.Equal(t, "new-container", result.ID) | |
| 2244 | 2249 | } | |
| @@ -2303,6 +2308,7 @@ func TestService_Deploy_TrackedTempContainerUsesAlternateTempName(t *testing.T) | |||
| 2303 | 2308 | ||
| 2304 | 2309 | result, err := svc.Deploy(ctx, route) | |
| 2305 | 2310 | assert.NoError(t, err) | |
| 2311 | + | svc.WaitForCleanup() | |
| 2306 | 2312 | assert.NotNil(t, result) | |
| 2307 | 2313 | assert.Equal(t, "new-container", result.ID) | |
| 2308 | 2314 | } | |
| @@ -2525,6 +2531,7 @@ func TestService_Deploy_ConcurrentSameDomain(t *testing.T) { | |||
| 2525 | 2531 | }(i) | |
| 2526 | 2532 | } | |
| 2527 | 2533 | wg.Wait() | |
| 2534 | + | svc.WaitForCleanup() | |
| 2528 | 2535 | ||
| 2529 | 2536 | // Both should succeed (second deploy waits for the first to finish) | |
| 2530 | 2537 | assert.NoError(t, errs[0], "first deploy should succeed") | |
| @@ -2637,6 +2644,7 @@ func TestService_Deploy_CacheInvalidationBeforeOldContainerStop(t *testing.T) { | |||
| 2637 | 2644 | result, err := svc.Deploy(ctx, route) | |
| 2638 | 2645 | ||
| 2639 | 2646 | assert.NoError(t, err) | |
| 2647 | + | svc.WaitForCleanup() | |
| 2640 | 2648 | assert.Equal(t, "new-container", result.ID) | |
| 2641 | 2649 | ||
| 2642 | 2650 | // Verify ordering: cache invalidation MUST happen before old container stop | |
| @@ -2693,6 +2701,7 @@ func TestService_Deploy_NilCacheInvalidator(t *testing.T) { | |||
| 2693 | 2701 | result, err := svc.Deploy(ctx, route) | |
| 2694 | 2702 | ||
| 2695 | 2703 | assert.NoError(t, err) | |
| 2704 | + | svc.WaitForCleanup() | |
| 2696 | 2705 | assert.Equal(t, "new-container", result.ID) | |
| 2697 | 2706 | } | |
| 2698 | 2707 | ||
| @@ -2870,6 +2879,7 @@ func TestService_Deploy_DoesNotSkipWhenImageIDDiffers(t *testing.T) { | |||
| 2870 | 2879 | result, err := svc.Deploy(ctx, route) | |
| 2871 | 2880 | ||
| 2872 | 2881 | assert.NoError(t, err) | |
| 2882 | + | svc.WaitForCleanup() | |
| 2873 | 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 | 2985 | result, err := svc.Deploy(ctx, route) | |
| 2976 | 2986 | ||
| 2977 | 2987 | assert.NoError(t, err) | |
| 2988 | + | svc.WaitForCleanup() | |
| 2978 | 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 | 3043 | result, err := svc.Deploy(ctx, route) | |
| 3033 | 3044 | ||
| 3034 | 3045 | assert.NoError(t, err) | |
| 3046 | + | svc.WaitForCleanup() | |
| 3035 | 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 | 3099 | result, err := svc.Deploy(ctx, route) | |
| 3088 | 3100 | ||
| 3089 | 3101 | assert.NoError(t, err) | |
| 3102 | + | svc.WaitForCleanup() | |
| 3090 | 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 | 3256 | result, err := svc.Deploy(ctx, route) | |
| 3244 | 3257 | ||
| 3245 | 3258 | assert.NoError(t, err) | |
| 3259 | + | svc.WaitForCleanup() | |
| 3246 | 3260 | assert.Equal(t, "new-container", result.ID) | |
| 3247 | 3261 | ||
| 3248 | 3262 | // Verify new container is tracked | |
| @@ -3421,6 +3435,7 @@ func TestService_Deploy_StabilizationSuccess(t *testing.T) { | |||
| 3421 | 3435 | result, err := svc.Deploy(ctx, route) | |
| 3422 | 3436 | ||
| 3423 | 3437 | assert.NoError(t, err) | |
| 3438 | + | svc.WaitForCleanup() | |
| 3424 | 3439 | assert.NotNil(t, result) | |
| 3425 | 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 | 3530 | result, err := svc.Deploy(ctx, route) | |
| 3516 | 3531 | ||
| 3517 | 3532 | assert.NoError(t, err) | |
| 3533 | + | svc.WaitForCleanup() | |
| 3518 | 3534 | assert.NotNil(t, result) | |
| 3519 | 3535 | assert.Equal(t, "new-container", result.ID) | |
| 3520 | 3536 | ||
| @@ -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 tracked | 713 | // 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 tracked | 2091 | // 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 stop | 2650 | // 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 tracked | 3262 | // 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 | ||||