detached 9cd24cc

fix: resolve deploy context cancellation, spurious auth warnings, and local CLI auth gate

- Use detached context for container restart so podman operations complete
  even if the HTTP client disconnects (fixes "context canceled" on restart)
- Use WithoutCancel for GetContainerLogs so log streaming outlives the
  deploy HTTP request (fixes "container log streaming ended with error")
- Skip insecure-HTTP warning for localhost registry requests since they
  are internal proxy traffic that never leaves the machine
- Allow local CLI control plane when auth.enabled=true — shell access
  already implies full server access, so the gate added no security
commit
9cd24cc5bda3bd66b08fb85f1f71fd61293b4b60
author
brice · · 2026-03-19 03:45 UTC
parent
05b767c
signature
unsigned
5 files · +20 -18
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 +6 -9
@@ -3,11 +3,10 @@ package cli
33 import (
44 "os"
55 "path/filepath"
6- "strings"
76 "testing"
87 )
98
10-func TestResolveControlPlane_LocalDeniedWhenAuthEnabled(t *testing.T) {
9+func TestResolveControlPlane_LocalAllowedWhenAuthEnabled(t *testing.T) {
1110 tmpDir := t.TempDir()
1211 t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
1312 t.Setenv("GORDON_REMOTE", "")
@@ -37,15 +36,13 @@ secrets_backend = "unsafe"
3736 }
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")
39+ if err != nil {
40+ t.Fatalf("expected local control-plane handle, got error: %v", err)
4541 }
46- if !strings.Contains(err.Error(), "local control plane is disabled when auth.enabled=true") {
47- t.Fatalf("unexpected error: %v", err)
42+ if handle == nil || handle.plane == nil {
43+ t.Fatalf("expected non-nil local control-plane handle")
4844 }
45+ handle.close()
4946 }
5047
5148 func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {
@@ -3,11 +3,10 @@ package cli
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6- "strings"
7 "testing"6 "testing"
8 )7 )
9 8
10-func TestResolveControlPlane_LocalDeniedWhenAuthEnabled(t *testing.T) {9+func TestResolveControlPlane_LocalAllowedWhenAuthEnabled(t *testing.T) {
11 tmpDir := t.TempDir()10 tmpDir := t.TempDir()
12 t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))11 t.Setenv("XDG_CONFIG_HOME", filepath.Join(tmpDir, "xdg"))
13 t.Setenv("GORDON_REMOTE", "")12 t.Setenv("GORDON_REMOTE", "")
@@ -37,15 +36,13 @@ secrets_backend = "unsafe"
37 }36 }
38 37
39 handle, err := resolveControlPlane(configPath)38 handle, err := resolveControlPlane(configPath)
40- if err == nil {39+ if err != nil {
41- if handle != nil {40+ t.Fatalf("expected local control-plane handle, got error: %v", err)
42- handle.close()
43- }
44- t.Fatalf("expected error when auth.enabled=true and no remote target")
45 }41 }
46- if !strings.Contains(err.Error(), "local control plane is disabled when auth.enabled=true") {42+ if handle == nil || handle.plane == nil {
47- t.Fatalf("unexpected error: %v", err)43+ t.Fatalf("expected non-nil local control-plane handle")
48 }44 }
45+ handle.close()
49 }46 }
50 47
51 func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {48 func TestResolveControlPlane_LocalAllowedWhenAuthDisabled(t *testing.T) {
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/usecase/container/service.go +6 -2
@@ -1429,13 +1429,17 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
14291429
14301430 log := zerowrap.FromCtx(ctx)
14311431
1432- logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)
1432+ // Use context.WithoutCancel so the log stream outlives the HTTP request.
1433+ // Without this, the Docker log stream closes when the deploy response completes.
1434+ bgCtx := context.WithoutCancel(ctx)
1435+
1436+ logStream, err := s.runtime.GetContainerLogs(bgCtx, containerID, true)
14331437 if err != nil {
14341438 log.Warn().Err(err).Msg("failed to get container logs for collection")
14351439 return
14361440 }
14371441
1438- if err := s.logWriter.StartLogging(context.WithoutCancel(ctx), containerID, domainName, logStream); err != nil {
1442+ if err := s.logWriter.StartLogging(bgCtx, containerID, domainName, logStream); err != nil {
14391443 log.Warn().Err(err).Msg("failed to start container log collection")
14401444 logStream.Close()
14411445 }
@@ -1429,13 +1429,17 @@ func (s *Service) startLogCollection(ctx context.Context, containerID, domainNam
1429 1429
1430 log := zerowrap.FromCtx(ctx)1430 log := zerowrap.FromCtx(ctx)
1431 1431
1432- logStream, err := s.runtime.GetContainerLogs(ctx, containerID, true)1432+ // Use context.WithoutCancel so the log stream outlives the HTTP request.
1433+ // Without this, the Docker log stream closes when the deploy response completes.
1434+ bgCtx := context.WithoutCancel(ctx)
1435+
1436+ logStream, err := s.runtime.GetContainerLogs(bgCtx, containerID, true)
1433 if err != nil {1437 if err != nil {
1434 log.Warn().Err(err).Msg("failed to get container logs for collection")1438 log.Warn().Err(err).Msg("failed to get container logs for collection")
1435 return1439 return
1436 }1440 }
1437 1441
1438- if err := s.logWriter.StartLogging(context.WithoutCancel(ctx), containerID, domainName, logStream); err != nil {1442+ if err := s.logWriter.StartLogging(bgCtx, containerID, domainName, logStream); err != nil {
1439 log.Warn().Err(err).Msg("failed to start container log collection")1443 log.Warn().Err(err).Msg("failed to start container log collection")
1440 logStream.Close()1444 logStream.Close()
1441 }1445 }

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