Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -305,20 +305,22 @@ stackctl stack rollback my-app --target-log <log-id>

`--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 <time>`. A deploy after the stop time fails with 409: set a new stop time or a TTL with `stack update`.

`stack pods` asks the server for exec hints (`?exec_hints=true`; not with `-q`, which prints only the pod names). When the server sends them (k8s-stack-manager v0.15.0 or later, a user who may change the stack, exec turned on, a cluster that allows exec and, with an API key, a server that allows API keys for exec (`STACK_STREAM_ALLOW_API_KEYS`)), the table gets a last column `EXEC`: the containers of the chart that `stack exec -c` accepts, the one it uses without `-c` marked with `*` (`web*, worker`), or `-` when no container is listed (none accepts exec, or the server could not check them, for example a failed read or a check over 3 s). The hint can leave out a container that works, but never lists one that the server refuses. Only Kubernetes container names (lower-case letters, digits and `-`, at most 63 characters) are shown; stackctl leaves out any other name from the server, so a `-c` value from the column is safe to paste into a shell. Without hints the table has no `EXEC` column. `-o json|yaml` carry `exec_hints`, `exec_containers` and `exec_default_container` as received.

`stack get` shows `Following` and `Followers` (k8s-stack-manager v0.8.0 or later). `stack preview -q` prints the names of the charts whose values differ from the last deploy. `stack preview` writes the values-drift warning to stderr, so the YAML on stdout stays valid.

`stack port-forward <name|id> <service> [LOCAL:]REMOTE` opens a local port that forwards to a service of the stack through the server (`GET /api/v1/stack-instances/:id/port-forward`). You need no kubeconfig. REMOTE is the service port. LOCAL must be 0 (any free port) or from 1025 to 65535. Without LOCAL, the local port is REMOTE when REMOTE is above 1024, else REMOTE + 10000; when that port is in use, stackctl tries the next 20 ports, then any free port. An explicit LOCAL port that is in use stops the command. `--address` sets the local addresses (default `127.0.0.1`, comma-separated). stdout gets `Forwarding from 127.0.0.1:3306 -> mysql:3306` (`-q`: only `host:port`); stderr gets one line when a connection opens and one when it closes. Each local connection opens its own tunnel. At most 16 tunnels are open at the same time: stackctl closes a further local connection at once and writes a line to stderr. When the local client closes its side, the tunnel still passes server data to it until the server closes, a local write fails, or 2 s pass without server data. A local client that reads no data for 75 s loses its connection. Ctrl-C or SIGTERM closes all connections and exits 0.

- Errors that stop the command (exit 1): no access (401, 403), an unknown stack, service or port (404), the feature turned off on the server or on the cluster, a stack that is not deployed (409), and a close 1008 during a tunnel. Close 1008 with the reason `session revoked` means that the session ended: run `stackctl login`. The reason `access revoked` means that the stack was stopped, deleted or cleaned, or that your access to it changed. A 404 without an error code means that the server has no port-forward route: upgrade the server to k8s-stack-manager v0.15.0 or later. A status 200 without an upgrade means that a proxy dropped the WebSocket upgrade.
- Errors that close only one connection: the rate limit or the tunnel limit (429; the tunnel limit, code `tunnel_limit`, first gets 3 dial retries within 3.5 s, while a tunnel that closes or drains frees its server slot), no ready pod (503), a failed pod dial (502), the server time limit (close 4001), the idle timeout (4002), a pod stream error (4502) and network errors. The open tunnels stay open. 3 connections in a row refused with 429 while no other connection of the command is open stop the command (exit 1): wait and try again.
- The server can refuse API keys for port-forward: then run `stackctl login` and remove the API key from the context or the environment.
- The server can refuse API keys for port-forward: then run `stackctl login` and remove the API key from the context or the environment. With an API key, stackctl first reads `stream_api_keys_allowed` of the public `GET /api/v1/ui-config` (sent without credentials; k8s-stack-manager v0.15.0 or later) and stops at once (exit 1) when it is `false`. When the field is missing or the read fails, the server decides at the dial. Ctrl-C during that read or the stack lookup exits 0 without binding a port.
- Security: anyone who can reach the local port uses your access to the stack. On a shared machine, other local users can connect to it. Web pages in your browser can reach a forwarded HTTP service through DNS rebinding: stop the command when you do not need it. An address that is not loopback opens the port to the network (stackctl writes a warning). `--insecure` is refused, because a man in the middle could read the tunnel data. An `http://` API URL is refused for the same reason, except for `localhost`, `127.0.0.1` and `[::1]`. `--allow-insecure-stream` accepts that risk.

`stack exec <name|id> <chart> [-c CONTAINER] -- COMMAND [ARGS...]` runs one command in a container of a chart of the stack through the server (`GET /api/v1/stack-instances/:id/exec`). You need no kubeconfig. The server chooses the pod (the first running and ready pod of the chart) and, without `-c`, the first container that allows exec. The `--` is required: the words after it are the command and its arguments, sent as a list without a shell (stackctl reads no flags after it). Use `-- sh -c '...'` for pipes, variables or globs. The command gets no stdin and no TTY; its stdout and stderr go unchanged to stdout and stderr. `-o` and `-q` are ignored. stackctl never connects again: a lost connection ends stackctl (exit 255). The server must enable exec, and the cluster of the stack must allow it: an admin runs `stackctl cluster update <id> --allow-exec` (the same for port-forward with `--allow-port-forward`); `cluster get` shows both switches.

- Exit codes: the exit code of the remote command (0 to 255, no message); 124 when the server time limit ends the command (close 4001); 130 for Ctrl-C or SIGTERM; 255 for an error of stackctl or the server (stack, chart or container not found, no access, no ready pod, a refused command, a lost connection, the refusal of `--insecure` or of an `http://` API URL, an exit code outside 0 to 255, an exit frame without a valid `exit_code`; the message is on stderr); 1 for invalid flags or arguments (no `--`, no command, more than 256 arguments, an empty program, a NUL byte). A remote command can return any code, including 1, 124, 130 and 255; stackctl's own codes come with a stackctl message on stderr (remote stderr goes to the same stream, so read the message, not just its presence). stackctl itself uses 1 only for invalid flags or arguments.
- Ctrl-C closes the connection and exits 130 within a few seconds (a second Ctrl-C exits at once); when the command had already ended, stderr names its exit code. The remote process can keep running. Ctrl-C before the server started the command says "interrupted before the command started".
- Errors: a 403 names the reason (the container does not allow exec, the cluster does not allow exec, the container holds a service-account token, the server may not check the pod for one, API keys not allowed: run `stackctl login`); 400 a chart or container name that is not valid; 404 an unknown chart or container, or exec turned off on the server; 503 no ready pod; 429 too many open exec sessions. A close of the server before the command started ends with "the server did not start the command". Close 4400: the server refused the command (too long or not valid; the length limit is a server setting). A 404 without an error code means that the server has no exec route: upgrade the server to k8s-stack-manager v0.15.0 or later.
- Errors: a 403 names the reason (the container does not allow exec, the cluster does not allow exec, the container holds a service-account token, the server may not check the pod for one, API keys not allowed: run `stackctl login`); 400 a chart or container name that is not valid; 404 an unknown chart or container, or exec turned off on the server; 503 no ready pod; 429 too many open exec sessions. For `container_not_allowed`, `container_not_found` and `service_account_token`, stackctl reads the exec hints of the stack once (`GET /pods?exec_hints=true`, best effort, 5 s) and names the containers of the chart that accept exec instead of the general advice: `Use -c web or -c worker.` (at most 8, then `or one of N more (see 'stackctl stack pods')`; only Kubernetes container names), or `The server lists no container of chart backend that accepts exec; ask an administrator.` when the server lists containers for another chart of the stack but none for this one. When the hints are missing or the read fails, the error stays as without hints; the exit code is 255 either way. With an API key, stackctl first reads `stream_api_keys_allowed` of the public `GET /api/v1/ui-config` (without credentials) and stops at once (exit 255) when it is `false`; a missing field or a failed read lets the server decide; Ctrl-C during that read exits 130 ("interrupted before the command started"). A close of the server before the command started ends with "the server did not start the command". Close 4400: the server refused the command (too long or not valid; the length limit is a server setting). A 404 without an error code means that the server has no exec route: upgrade the server to k8s-stack-manager v0.15.0 or later.
- Security: the output can show secrets, for example from the environment of the container, and can end up in the terminal history or in CI logs. The output is passed through unchanged, so it can hold terminal escape sequences (colors, cursor moves, changes of the window title or the clipboard (OSC 52), hyperlinks); when you do not trust the container, pipe it through `| cat -v` or write it to a file. The server writes each command with its arguments to the audit log. `--insecure` is refused, because a man in the middle could read the commands and their output. An `http://` API URL is refused for the same reason, except for `localhost`, `127.0.0.1` and `[::1]`. `--allow-insecure-stream` accepts that risk.

`stack action list` and `stack action run` see the actions that apply to the stack: a server can limit an action to some templates, template categories, definitions or clusters (`applies_to`). An action of another stack gives "unknown action" with the actions of this stack. When the server could not check where an action applies, `stack action list` ends with "Some actions are not shown: the server could not check where they apply. Try again.", and `-o json` has `"scope_incomplete": true`.
Expand Down Expand Up @@ -703,6 +705,7 @@ These groups have no section above. Run `stackctl <group> --help` for the flags.
| Actions limited to some stacks (`applies_to`) and the `scope_incomplete` hint of `stack action list` (an older server lists every action for every stack) | the release after v0.14.0 (omattsson/k8s-stack-manager#720) |
| `stack port-forward` (an older server answers 404 without an error code: stackctl says "upgrade the server to k8s-stack-manager v0.15.0 or later") | v0.15.0 or later (omattsson/k8s-stack-manager#722) |
| `stack exec` (an older server answers 404 without an error code: stackctl says "upgrade the server to k8s-stack-manager v0.15.0 or later") | v0.15.0 or later (omattsson/k8s-stack-manager#723) |
| The `EXEC` column of `stack pods`, the containers in `stack exec` refusals, the API-key pre-check of `stack exec` and `stack port-forward` (an older server sends no hints and no `stream_api_keys_allowed`: stackctl behaves as before) | v0.15.0 or later (omattsson/k8s-stack-manager#728) |

## Output Formats

Expand Down
15 changes: 15 additions & 0 deletions cli/cmd/agent_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ stackctl template quick-deploy <template-id> --name my-app --ttl 480 -o json
# Inspect
stackctl stack status my-app # pods
stackctl stack pods my-app # live pods and warning events
# With exec hints from the server (k8s-stack-manager v0.15.0+, a user who may change the stack, exec on, the cluster
# allows exec and, with an API key, the server allows API keys for exec: STACK_STREAM_ALLOW_API_KEYS) the table has a
# last column EXEC: the containers that `stack exec -c` accepts, "*" marks the one used without -c ("web*, worker"),
# "-" none listed (none accepts exec, or the server could not check them, for example a failed read or a check over
# 3 s). Only Kubernetes container names are shown. Without hints there is no EXEC column; -q asks for no hints.
# Agents: read `stackctl stack pods my-app -o json` instead: exec_hints (true), per chart exec_containers and
# exec_default_container (as received from the server).
stackctl stack logs my-app # latest deployment log
stackctl stack values my-app # merged Helm values
stackctl stack preview my-app # values that the next deploy uses
Expand All @@ -75,6 +82,8 @@ stackctl stack port-forward my-app mysql 0:3306 -q # stdout: 127.0.0.1:<port>;
# 3 refused connections in a row while no other connection is open end it with exit 1: wait and try again.
# Needs k8s-stack-manager v0.15.0 or later (an older server: "upgrade the server to k8s-stack-manager v0.15.0 or later").
# An http:// API URL to a host that is not loopback is refused (add --allow-insecure-stream to accept the risk).
# With an API key it first reads stream_api_keys_allowed of the public /api/v1/ui-config: false ends it at once with
# exit 1 ("the server does not allow API keys for port-forward"): use `stackctl login` and no API key.

# Run one command in a container of a chart (no stdin, no TTY, no shell; for a shell: -- sh -c '...')
stackctl stack exec my-app backend -c php -- bin/console cache:clear
Expand All @@ -88,6 +97,12 @@ stackctl stack exec my-app backend -c php -- bin/console cache:clear
# The remote process can keep running. Never retried: after 124, 130 or a lost connection the command can still
# run on the server, so check its effect before you run it again ("the server did not start the command" and
# "interrupted before the command started" mean that it did not run).
# A refused container (container_not_allowed, container_not_found, service_account_token) names the containers that
# accept exec when the server sends hints ("Use -c web or -c worker"; more than 8: "or one of N more (see 'stackctl
# stack pods')"), or says "The server lists no container of chart X that accepts exec; ask an administrator.": pick a
# -c from that list (or exec_containers / exec_default_container of `stack pods -o json`), do not guess (exit 255).
# With an API key it first reads stream_api_keys_allowed of /api/v1/ui-config: false ends it at once with exit 255
# ("the server does not allow API keys for exec"): use `stackctl login` and no API key.
# Needs k8s-stack-manager v0.15.0 or later (an older server: "upgrade the server to k8s-stack-manager v0.15.0 or later").

# Change values, then deploy again
Expand Down
31 changes: 31 additions & 0 deletions cli/cmd/stack.go
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,7 @@ func bestEffortClient(c *client.Client) *client.Client {
q.Token = c.Token
q.Debug = c.Debug
q.DebugWriter = c.DebugWriter
q.WarnWriter = c.WarnWriter
q.RetryBackoff = []time.Duration{} // non-nil and empty: no retry
q.HTTPClient = &http.Client{Timeout: bestEffortTimeout}
if c.HTTPClient != nil {
Expand All @@ -877,6 +878,36 @@ func bestEffortClient(c *client.Client) *client.Client {
return q
}

// publicBestEffortClient is bestEffortClient without credentials, for an
// optional call of a public route (for example GET /api/v1/ui-config).
func publicBestEffortClient(c *client.Client) *client.Client {
q := bestEffortClient(c)
q.APIKey = ""
q.Token = ""
return q
}

// bestEffortCtx runs call (an optional extra call) and returns its result.
// ok is false when the call fails or ctx ends first; the call then ends on
// its own within its timeout.
func bestEffortCtx[T any](ctx context.Context, call func() (T, error)) (res T, ok bool) {
type result struct {
val T
err error
}
done := make(chan result, 1)
go func() {
val, err := call()
done <- result{val, err}
}()
select {
case r := <-done:
return r.val, r.err == nil
case <-ctx.Done():
return res, false
}
}

// printStatusTable prints the per-chart pod table of a stack status.
func printStatusTable(status *types.InstanceStatus) error {
printer.PrintMessage("Status: %s", printer.StatusColor(status.Status))
Expand Down
Loading
Loading