From eca051b46642416686d4c30e7bb6728d385a7375 Mon Sep 17 00:00:00 2001 From: omattsson Date: Sat, 31 Oct 2026 16:20:07 +0100 Subject: [PATCH] feat(stack): use the exec hints of the server in stack pods, stack exec and the stream commands (#183) k8s-stack-manager v0.15.0 sends exec hints (?exec_hints=true on /status and /pods: exec_hints, exec_containers, exec_default_container) and stream_api_keys_allowed in /ui-config. - stack pods: requests the hints (not with -q) and shows an EXEC column after REASON (`web*, worker`, `*` = the container without -c, `-` = no container) when the server sends them; the table is unchanged without hints. -o json/yaml print the fields. Only valid container names are shown; the CHART, POD, PHASE cells and the namespace are sanitized. - stack exec: a refusal with container_not_allowed, container_not_found or service_account_token makes one best-effort /pods?exec_hints=true call and names the accepted containers ("Use -c web or -c worker.") or says that the server lists none for the chart. A failed call leaves the error unchanged; the exit code stays 255. - stack exec and stack port-forward with an API key read the public /ui-config first (no credentials) and stop before the dial when the server does not allow API keys for streams. A missing field or a failed read dials as before; a session makes no extra call. - Ctrl-C during the extra calls: "interrupted before the command started" (exec 130); port-forward binds no port. - U+061C is a bidi control for sanitizing. Vendored swagger refreshed. - Docs: help texts, README, agent guide, CLAUDE.md. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 10 +- README.md | 9 +- cli/cmd/agent_guide.md | 15 + cli/cmd/stack.go | 31 ++ cli/cmd/stack_exec.go | 60 ++- cli/cmd/stack_exec_test.go | 436 ++++++++++++++- cli/cmd/stack_inspect.go | 65 ++- cli/cmd/stack_inspect_test.go | 423 +++++++++++++++ cli/cmd/stack_portforward.go | 32 ++ cli/cmd/stack_portforward_test.go | 18 +- cli/cmd/stack_stream_apikey_test.go | 509 ++++++++++++++++++ cli/cmd/testdata/stack_pods_table.golden.txt | 8 + cli/pkg/client/client.go | 17 +- cli/pkg/client/client_test.go | 1 + cli/pkg/client/exec_hints_test.go | 356 ++++++++++++ cli/pkg/client/stack_inspect_test.go | 3 +- cli/pkg/client/websocket.go | 170 +++++- cli/pkg/types/types.go | 21 +- cli/test/contract/exec_hints_contract_test.go | 87 +++ cli/test/contract/testdata/swagger.json | 46 +- 20 files changed, 2282 insertions(+), 35 deletions(-) create mode 100644 cli/cmd/stack_stream_apikey_test.go create mode 100644 cli/cmd/testdata/stack_pods_table.golden.txt create mode 100644 cli/pkg/client/exec_hints_test.go create mode 100644 cli/test/contract/exec_hints_contract_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 77295c2..6178677 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl login.go # login, logout (--all: logout-all first), whoami, auth register token.go # Token helpers (save/load/delete session, fresh token for plugins) stack.go # stack list/get/create/update/deploy/stop/clean/delete/status/logs/clone/extend/values/compare/history/rollback/history-values/watch - stack_inspect.go # stack preview/pods/recent (deploy-preview, live pods and events, recent) + stack_inspect.go # stack preview/pods/recent (deploy-preview, live pods and events with the EXEC column of the exec hints, recent) stack_action.go # stack action list/run/log (custom actions of the hooks config; job log polling with backoff) stack_followers.go # stack follow/unfollow (in-app notifications; not the --follow log flag) stack_portforward.go # stack port-forward (parsePortSpec, --address, listeners with port fallback, one tunnel per local connection); addrinuse_*.go: EADDRINUSE per OS @@ -132,7 +132,7 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl - 429 → "Rate limited. Try again later. (server: ...)" - 500 → "Server error. Check backend logs. (server: ...)" -**Warning headers**: `send()` writes each `Warning` response header (for example a `299` deprecation notice) to `Client.WarnWriter` (stderr when nil) as `Warning: `, for every command and also on error responses. The quoted warn-text is unescaped and sanitized like server error messages; one header can hold several comma-separated warnings (warn-dates are skipped). The same text is written once per client (retries do not repeat it; the dedupe set holds at most 64 texts), and writes are serialized under `warnMu`. `sanitizeServerMessage` replaces control characters (`unicode.IsControl`: C0, DEL, C1) with a space and drops bidi controls (U+200E, U+200F, U+202A–U+202E, U+2066–U+2069). +**Warning headers**: `send()` writes each `Warning` response header (for example a `299` deprecation notice) to `Client.WarnWriter` (stderr when nil) as `Warning: `, for every command and also on error responses. The quoted warn-text is unescaped and sanitized like server error messages; one header can hold several comma-separated warnings (warn-dates are skipped). The same text is written once per client (retries do not repeat it; the dedupe set holds at most 64 texts), and writes are serialized under `warnMu`. `sanitizeServerMessage` replaces control characters (`unicode.IsControl`: C0, DEL, C1) with a space and drops bidi controls (U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069). **User-Agent**: every request sends `User-Agent: stackctl/ (/)` (`pkg/client/useragent.go`). `cmd.SetVersionInfo` calls `client.SetVersion`, so it matches `stackctl version` (empty: `dev`). `SetVersion` builds the header value once; `UserAgent()` only loads it. Set it where the request is built (`setUserAgent` in `send()` and `sendSession()`; `baseWSHeader()` for both WebSocket dials, also the `?token=` fallback), not in a transport: `stack.go` replaces `HTTPClient`. @@ -196,11 +196,13 @@ All API calls go to `/api/v1/*`. Key route groups: - `GET /:id` returns `values_drift` (v0.6.0+, not on lists); the rollback response has `values_drift` (`*bool`, only for `target_log_id`) and `warning`; the clone response adds `warning` (quota override not copied; v0.7.0+). `stack rollback -o json|yaml` prints the response. `stack get`, `stack rollback` and `stack clone` write these warnings to stderr as `Warning: ` (`printServerWarning`, sanitized). `stack get` fills `warning` with `types.ValuesDriftWarning` when the server sends `values_drift` without text, so `-o json` has both fields. - `owner_username`, `cluster_name`, `definition_name` (k8s-stack-manager#470, v0.7.0+) are optional; tables show the name (`displayName`) or "name (id)" (`displayNameWithID`), else the ID. No extra API calls. - `PUT /:id/quota-overrides` replaces the whole override. `override quota set` does GET (only 404 "Instance quota override not found" = no override), applies the given flags (`""` clears a string field; pod_limit only via `--replace` or delete; `--pod-limit 0` = no limit) and PUTs; `--replace` skips the GET. An empty result is refused (points to `override quota delete`). GET and PUT are not atomic: a concurrent change is overwritten. Owners without admin/devops get 403 above the cluster quota (v0.7.0+); a value equal to the stored value passes. -- `GET /api/v1/stack-instances/:id/exec?chart=&container=` — `stack exec` (omattsson/stackctl#179, k8s-stack-manager#723, server PR #725, merged; k8s-stack-manager v0.15.0 or later, `client.ExecServerVersion`). Same dial, header auth and `{"error","code"}` refusals as port-forward (`WSHandshakeError` with `Op` "exec" and `Container`; exec hints in `execHint`: `container_not_allowed`, `cluster_exec_disabled`, `chart_not_found`, `container_not_found`, `no_ready_pod`, `exec_limit`, `service_account_token`, `token_check_forbidden`, 400 `invalid_chart` / `invalid_container` (the server checks the names; no DNS-1123 check in stackctl)). `container` is left out without `-c`. The command goes only in the first text frame `{"type":"start","command":[...]}` (`types.ExecStartMessage`; the server closes 4408 without it in 10 s). Fixed rules checked before the dial (`client.ValidateExecCommand`): 1 to 256 arguments, first not empty, no NUL; the length limit (`STACK_EXEC_MAX_COMMAND_LENGTH`, default 4096) is not exposed, so a long command gets close 4400. Server frames (`types.ExecServerMessage`): `started` (pod, container; the only thing `--debug` logs), binary frames with stream byte 1 stdout / 2 stderr (written unbuffered; unknown bytes, empty frames and unknown types ignored), `exit` (`exit_code`) + close 1000, or `error` (`message`) + 4502 (pod stream) / 1011 (internal). One reader goroutine, read limit 1 MiB, the keepalive of port-forward. Close codes: 4001 → `ErrExecTimeLimit` (no exit frame); others → `*ExecClosedError` (1008 unwraps to `WSClosedError` but has its own text: "token expired" never says "run the command again" while it can still run; 4400 "the server refused the command (too long or not valid)"; 1009 "the command is too long for the server"). The server sends `started` before it runs the command and its frames arrive in order, so a close frame without `started` or output (a binary frame) before it ends with "; the server did not start the command"; with `started` or output, and for a lost connection (code 0, gorilla 1006: plain "exec: connection lost; the command can still run on the server", no gorilla text) it ends with "; the command can still run on the server". A frame over the 1 MiB read limit (`websocket.ErrReadLimit`) has its own text. Ctx cancel: close 1000, wait up to 2 s (`CancelWait`) for an exit frame, else `*ExecCanceledError{Started}` (`errors.Is` matches `ctx.Err()`; `Started` false only for a cancel before, during or right after the dial (checked before the start frame, so the command is never sent) or a server close frame without `started` or output; `stack exec` then says "interrupted before the command started" (also for an interrupted run that ends with an `*ExecClosedError` with a close code and without `Started`, with the server text: `interruptedExit`), else "interrupted; the command can still run on the server", both exit 130). Stream dials (`dialStream`, also port-forward) close the connection when ctx ends during the handshake (`closeOnCancel`: gorilla v1.5.3 honours only the ctx deadline, so a held upgrade would wait for the 45 s handshake timeout). The second Ctrl-C writes "interrupted" from a goroutine to the raw stderr (never through the `syncWriter`, which the reader can hold in a blocked remote stderr write) and waits at most 100 ms (`writeNoteNoWait`) before `execForceExit(130)`. Remote output is passed through unchanged, terminal escape sequences included (help, README and agent guide say so). Never reconnects. The route is in the vendored swagger, so the normal path contract check covers it. `pendingRoutes` in `test/contract/path_contract_test.go` is empty; it is for a route that exists only in an open backend PR (keyed by the literal, with the PR and the expected swagger path; the literal must match that path, and the test fails as soon as the vendored swagger has it). +- `GET /api/v1/stack-instances/:id/exec?chart=&container=` — `stack exec` (omattsson/stackctl#179, k8s-stack-manager#723, server PR #725, merged; k8s-stack-manager v0.15.0 or later, `client.ExecServerVersion`). Same dial, header auth and `{"error","code"}` refusals as port-forward (`WSHandshakeError` with `Op` "exec" and `Container`; exec hints in `execHint`: `container_not_allowed`, `cluster_exec_disabled`, `chart_not_found`, `container_not_found`, `no_ready_pod`, `exec_limit`, `service_account_token`, `token_check_forbidden`, 400 `invalid_chart` / `invalid_container` (the server checks the names; no DNS-1123 check of the user's names in stackctl)). `container` is left out without `-c`. Refusal hints: for `container_not_allowed`, `container_not_found` and `service_account_token` (not after an interrupt) `addExecHints` (`stack_exec.go`) builds a `bestEffortClient` (before the goroutine) and makes one `GetStackPods(id, true)` with it inside `bestEffortCtx` (so Ctrl-C does not wait), and sets `WSHandshakeError.ExecHints` (`client.ExecHintsForChart`: nil = unknown, the error stays as before). Only Kubernetes container names from the server count (`client.ValidContainerName`: DNS-1123 label, at most 63 characters; `client.ExecContainerNames` drops every other name and duplicates), so a suggestion is safe to paste into a shell without quoting; a chart with no valid name counts as an empty list. `execHintAdvice` then replaces the advice of the code: ". Use -c a, -c b or -c c." (the `-c` container left out, at most 8, then "or one of N more (see 'stackctl stack pods')"; nothing left → the old advice) or, for an empty list while another chart has one, ". The server lists no container of chart X that accepts exec; ask an administrator." (the server checks within a read budget and can leave out a container that works). Exit code stays 255. API-key pre-check: with `c.APIKey` set, `checkStreamAPIKey` (`stack_portforward.go`, after `checkStreamTransport`, before `resolveStackID`) reads `GET /api/v1/ui-config` through `publicBestEffortClient` (no API key, no token); `stream_api_keys_allowed` false → "exec not started: the server does not allow API keys for exec; " + `client.StreamAPIKeyAdvice` (the same advice as the `api_key_not_allowed` refusal), exit 255 (`beforeDial` keeps an interrupt at 130); nil, true or a failed read → dial as before; Ctrl-C during the check → `errExecNotStarted` (130) without a stack lookup. The command goes only in the first text frame `{"type":"start","command":[...]}` (`types.ExecStartMessage`; the server closes 4408 without it in 10 s). Fixed rules checked before the dial (`client.ValidateExecCommand`): 1 to 256 arguments, first not empty, no NUL; the length limit (`STACK_EXEC_MAX_COMMAND_LENGTH`, default 4096) is not exposed, so a long command gets close 4400. Server frames (`types.ExecServerMessage`): `started` (pod, container; the only thing `--debug` logs), binary frames with stream byte 1 stdout / 2 stderr (written unbuffered; unknown bytes, empty frames and unknown types ignored), `exit` (`exit_code`) + close 1000, or `error` (`message`) + 4502 (pod stream) / 1011 (internal). One reader goroutine, read limit 1 MiB, the keepalive of port-forward. Close codes: 4001 → `ErrExecTimeLimit` (no exit frame); others → `*ExecClosedError` (1008 unwraps to `WSClosedError` but has its own text: "token expired" never says "run the command again" while it can still run; 4400 "the server refused the command (too long or not valid)"; 1009 "the command is too long for the server"). The server sends `started` before it runs the command and its frames arrive in order, so a close frame without `started` or output (a binary frame) before it ends with "; the server did not start the command"; with `started` or output, and for a lost connection (code 0, gorilla 1006: plain "exec: connection lost; the command can still run on the server", no gorilla text) it ends with "; the command can still run on the server". A frame over the 1 MiB read limit (`websocket.ErrReadLimit`) has its own text. Ctx cancel: close 1000, wait up to 2 s (`CancelWait`) for an exit frame, else `*ExecCanceledError{Started}` (`errors.Is` matches `ctx.Err()`; `Started` false only for a cancel before, during or right after the dial (checked before the start frame, so the command is never sent) or a server close frame without `started` or output; `stack exec` then says "interrupted before the command started" (also for an interrupted run that ends with an `*ExecClosedError` with a close code and without `Started`, with the server text: `interruptedExit`), else "interrupted; the command can still run on the server", both exit 130). Stream dials (`dialStream`, also port-forward) close the connection when ctx ends during the handshake (`closeOnCancel`: gorilla v1.5.3 honours only the ctx deadline, so a held upgrade would wait for the 45 s handshake timeout). The second Ctrl-C writes "interrupted" from a goroutine to the raw stderr (never through the `syncWriter`, which the reader can hold in a blocked remote stderr write) and waits at most 100 ms (`writeNoteNoWait`) before `execForceExit(130)`. Remote output is passed through unchanged, terminal escape sequences included (help, README and agent guide say so). Never reconnects. The route is in the vendored swagger, so the normal path contract check covers it. `pendingRoutes` in `test/contract/path_contract_test.go` is empty; it is for a route that exists only in an open backend PR (keyed by the literal, with the PR and the expected swagger path; the literal must match that path, and the test fails as soon as the vendored swagger has it). +- `GET /api/v1/stack-instances/:id/pods?exec_hints=true` — `stack pods` and the exec refusal hints (`GetStackPods(id, execHints)`; never on `/status`: `GetStackStatus` does not send it, the hints cost server reads). k8s-stack-manager v0.15.0+ (#728) adds `exec_hints: true` and per chart `exec_containers` / `exec_default_container` (by `release_name`, the name the exec route takes; `ExecHintsForChart` falls back to `chart_name`, defensive: the server sets `chart_name` to the release name) only for a caller who may modify the stack, exec on, cluster `allow_exec` (API keys also need `STACK_STREAM_ALLOW_API_KEYS`); else the response is unchanged. A chart without a list while another chart has one: the server lists no container of it that accepts exec (it can still leave out one that works: the token check has a read budget). No list on any chart: ambiguous (none accepted, or not computed: not deployed, shared namespace, read failure, over 3 s). Conservative: may leave out a working container, never lists a refused one. `stack pods -q` sends no `exec_hints` (`GetStackPods(id, !printer.Quiet)`). The `stack pods` table adds `EXEC` as the last column only with `exec_hints` (`execContainersText`: `web*, worker`, only `client.ExecContainerNames`, `*` = a valid default, `-` = no valid name); the CHART, POD, PHASE and REASON cells and the namespace of "No pods found" are sanitized (`client.SanitizeServerText`). +- `GET /api/v1/ui-config` (public) — `UIConfig.StreamAPIKeysAllowed *bool` (`stream_api_keys_allowed`, v0.15.0+; nil = older server) drives the API-key pre-check of `stack exec` and `stack port-forward`. - `/api/v1/audit-logs` — list/export; `--action` and `--entity-type` are exact matches (`deploy`, `stack_instance`, …; v0.7.0+, see `auditFilterValuesHelp`). Older servers: HTTP method + last route segment (`create` / `deploy`) - `/api/v1/users` — list/delete/disable/enable/password (admin). `PUT /:id/role` `{"role"}` (k8s-stack-manager v0.8.0+, `user set-role `, user by ID or username via `resolveUserID`): response `id`, `old_role`, `new_role`, `changed`; 403 own role / caller not admin, 409 SSO user / last admin, 400 invalid role; a plain 404 (no route) maps to the v0.8.0 upgrade hint (`setRoleError`) - `/ws` — the server closes a socket with code 1008 and reason `session revoked` or `token expired`. `Client.Watch` returns a `WatchStream`; `Err()` gives a `*WSClosedError` ("Connection closed by the server: . Log in again."). `stack watch` exits non-zero on it; for `token expired` it calls `Client.RenewSession` (forced renewal, false without refresh token) and reconnects, at most once per 30 s (`stackWatchMinReconnect`), then with `--id` GETs each pending instance (`watchCatchUp`) so a terminal status in the gap counts. Ctrl-C during renewal or dial exits 0. Another connection loss: `--id` with pending instances exits non-zero ("connection lost before all instances reached a terminal status"), else a stderr note and exit 0. `StreamDeploymentLogs` returns the same `WSClosedError`; `--follow` (`followLogsCtx`, op deploy/stop/clean/rollback, "" for `stack logs -f`) uses the same `renewAfterClose`; it reads the instance after the first connect (the server sets deploying/stopping/cleaning before the 202; not for `logs -f`) and after each reconnect (`StreamOptions.AfterSubscribe`). A normal close (code 1000, stream status `unknown`) reads the instance (`followEndError`): a final status counts, else it fails like a lost connection. When it cannot follow to the end it says that the operation continues on the server and points to `stack watch --id` / `stack status` (never "run again"); errors name the operation ("rollback failed (status error): ..."). Terminal statuses live in `client.IsTerminalStatus` (running, stopped, draft, error, partial, failed) and `client.IsFailedStatus` (error, partial, failed → exit 1) -- `GET /api/v1/stack-instances/:id/port-forward?service=&port=` — `stack port-forward` (omattsson/stackctl#178, k8s-stack-manager#722). WebSocket upgrade, auth only by header (`dialWSOpts` with `stream`: no `?token=` retry, a 401 gets one forced `RenewSession` and one more dial; no `Origin` header). Refusals are HTTP statuses with `{"error","code"}` (`WSHandshakeError.Code`, `StreamCode*`): `FeatureDisabled()` (404 `feature_disabled`), `NoRoute()` (404 without a code: older server; hint `client.PortForwardServerVersion`), `UpgradeDropped()` (2xx, or 400 without a code or `upgrade_required`: a proxy), `Fatal()` (400, 401, 403, 404, 409 stop the command; 429, 5xx and network errors close one connection; a 429 `tunnel_limit` first gets 3 dial retries (`portForward429Delays`: 0.5, 1, 2 s, for tunnels that drain or close; the rate limiter's 429 is not retried); 3 connections in a row refused with 429, each while no other connection of the command dials or tunnels (`portForwarder.active`, decremented before the refused local connection closes; a refusal with others active resets `rateLimited`), stop the command (`portForwardMax429`), so a full server tunnel limit never closes working tunnels). After the upgrade: binary frames of at most 32 KiB both ways (server read limit 64 KiB), no half-close. `Client.Tunnel`: one reader and one writer goroutine, pings and the close through `WriteControl`; pings from their own goroutine every 30 s (a waiting ping cannot delay a cancel), read deadline 75 s moved by data, ping or pong; a local write deadline of 75 s; after a local EOF the tunnel copies server data to local until the server closes, a local write fails, or `DrainTimeout` (2 s) passes without server data, and only then sends close 1000; a server close 1000 does `CloseWrite` on the local socket. A 401 on the stream dial renews with the refused token (`renewSession(stale)`), so parallel 401s send one refresh. Close codes (`Close*` constants, `CloseCodeText`): 1001 shutdown, 1008 (`StreamClosedError.Fatal`, unwraps to `WSClosedError`; reason `session revoked` → login advice, `access revoked` (`CloseReasonAccessLost`) → stack stopped, deleted or cleaned, or access changed), 1011, 4001 time limit, 4002 idle, 4502 pod stream error. `--insecure`, and an `http://` API URL to a host that is not loopback (`plaintextRemoteURL`), are refused unless `--allow-insecure-stream` (`checkStreamTransport`, shared with `stack exec`; "insecure" is `effectiveInsecure()` in `root.go`, the same check as the TLS warning and `applyInsecureTLS`). At most 16 open tunnels (`portForwardConnLimit`); a further connection is closed at once. The non-loopback warning checks the bound address (`warnNonLoopback`). SIGTERM ends the command like Ctrl-C (`portForwardSignals`; `stack_portforward_signal_test.go` sends a real SIGTERM to a subprocess, not on Windows). A fatal tunnel error is not repeated in the per-connection close line. +- `GET /api/v1/stack-instances/:id/port-forward?service=&port=` — `stack port-forward` (omattsson/stackctl#178, k8s-stack-manager#722). WebSocket upgrade, auth only by header (`dialWSOpts` with `stream`: no `?token=` retry, a 401 gets one forced `RenewSession` and one more dial; no `Origin` header). Refusals are HTTP statuses with `{"error","code"}` (`WSHandshakeError.Code`, `StreamCode*`): `FeatureDisabled()` (404 `feature_disabled`), `NoRoute()` (404 without a code: older server; hint `client.PortForwardServerVersion`), `UpgradeDropped()` (2xx, or 400 without a code or `upgrade_required`: a proxy), `Fatal()` (400, 401, 403, 404, 409 stop the command; 429, 5xx and network errors close one connection; a 429 `tunnel_limit` first gets 3 dial retries (`portForward429Delays`: 0.5, 1, 2 s, for tunnels that drain or close; the rate limiter's 429 is not retried); 3 connections in a row refused with 429, each while no other connection of the command dials or tunnels (`portForwarder.active`, decremented before the refused local connection closes; a refusal with others active resets `rateLimited`), stop the command (`portForwardMax429`), so a full server tunnel limit never closes working tunnels). After the upgrade: binary frames of at most 32 KiB both ways (server read limit 64 KiB), no half-close. `Client.Tunnel`: one reader and one writer goroutine, pings and the close through `WriteControl`; pings from their own goroutine every 30 s (a waiting ping cannot delay a cancel), read deadline 75 s moved by data, ping or pong; a local write deadline of 75 s; after a local EOF the tunnel copies server data to local until the server closes, a local write fails, or `DrainTimeout` (2 s) passes without server data, and only then sends close 1000; a server close 1000 does `CloseWrite` on the local socket. A 401 on the stream dial renews with the refused token (`renewSession(stale)`), so parallel 401s send one refresh. Close codes (`Close*` constants, `CloseCodeText`): 1001 shutdown, 1008 (`StreamClosedError.Fatal`, unwraps to `WSClosedError`; reason `session revoked` → login advice, `access revoked` (`CloseReasonAccessLost`) → stack stopped, deleted or cleaned, or access changed), 1011, 4001 time limit, 4002 idle, 4502 pod stream error. `--insecure`, and an `http://` API URL to a host that is not loopback (`plaintextRemoteURL`), are refused unless `--allow-insecure-stream` (`checkStreamTransport`, shared with `stack exec`; "insecure" is `effectiveInsecure()` in `root.go`, the same check as the TLS warning and `applyInsecureTLS`). With an API key, `checkStreamAPIKey` (shared with `stack exec`) stops before the stack lookup when `/ui-config` says `stream_api_keys_allowed: false` ("port-forward not started: ...", exit 1); no call without an API key. Ctrl-C during the check or the stack lookup returns nil (exit 0, as in `serve`) before `bindPortForward`. At most 16 open tunnels (`portForwardConnLimit`); a further connection is closed at once. The non-loopback warning checks the bound address (`warnNonLoopback`). SIGTERM ends the command like Ctrl-C (`portForwardSignals`; `stack_portforward_signal_test.go` sends a real SIGTERM to a subprocess, not on Windows). A fatal tunnel error is not repeated in the per-connection close line. - `/api/v1/admin/orphaned-namespaces` — `orphaned list [--details]`; `DELETE /:ns?confirm=` is needed for a namespace without the label managed-by=k8s-stack-manager (409 without it; `orphaned delete --confirm`). `test/contract/path_contract_test.go` checks every `/api/v1` literal of `pkg/client` and `cmd` against the vendored swagger - `POST /api/v1/auth/logout-all` — `logout --all`: uses the session token of the context when a token file exists (also when an API key is set), else the API key; a 401 gets one forced `RenewSession` and one retry. On failure the command stops and keeps the token file; on success the normal logout follows. - `GET /api/v1/dashboard` — `dashboard`: covers all users; `failing_instances` has status error only, `expiring_soon` the next hour. For the role `user`, `clusters` has ID and name only (no health): the table hides clusters without `health_status`. diff --git a/README.md b/README.md index 07c706a..386e322 100644 --- a/README.md +++ b/README.md @@ -243,7 +243,7 @@ stackctl stack update my-app --ttl 0 # clear the expiry # Monitor stackctl stack status my-app -stackctl stack pods my-app # live pods, container reasons (CrashLoopBackOff) and Warning events +stackctl stack pods my-app # live pods, container reasons (CrashLoopBackOff), Warning events, containers for exec stackctl stack logs my-app stackctl stack recent # your 5 most recently updated stacks @@ -305,20 +305,22 @@ stackctl stack rollback my-app --target-log `--stop-at` (`stack create`, `clone`, `update`, `template quick-deploy`) sets a fixed stop time: the TTL becomes 0 and the stack stops at that time. `--stop-at` and `--until` accept RFC 3339 (`2026-10-17T18:00:00+02:00`) or a local time without a zone (`2026-10-17 18:00`). stackctl sends the time in UTC. `stack create --ttl 0` now sends 0 (no expiry); without `--ttl` the server applies its default TTL. `--ttl` and `--stop-at` together, a stop time in the past or a bad format are usage errors; stackctl sends no request. Before the request, stackctl also refuses a `--ttl`, a `--stop-at` or an `--until` above the TTL limit of the server (`max_instance_ttl_days` of `GET /api/v1/ui-config`; 30 days for an older server). `stack list` (column `EXPIRES`) and `stack get` show a fixed stop time as `stops at