From 94f9cf073cfcae290cadcf3f2f3b60d61cd1589e Mon Sep 17 00:00:00 2001 From: omattsson Date: Sat, 31 Oct 2026 20:07:42 +0100 Subject: [PATCH] docs(readme): split the README into docs/ and lead with what stackctl is for The README had 867 lines; 460 of them were command examples that repeat --help. It never said that stackctl needs a K8s Stack Manager server, and its strongest points (no cluster credentials, scripting, the agent guide, plugins) were spread over the page. - README (112 lines): what stackctl is and that it needs a server, five highlights, Homebrew and the install script, a quick start that ends with port-forward and exec, AI agents and scripts, plugins, a documentation map, License. - docs/installation.md: all install methods (moved word for word). - docs/configuration.md: contexts, authentication, precedence (moved). - docs/commands.md: the examples of every command group, the command reference, output formats, confirmations, global flags, completion (moved). - docs/server-versions.md: short rows plus notes on older servers; the applies_to row now says v0.15.0 (was "the release after v0.14.0"). - CONTRIBUTING.md: the Contributing section (moved). - assets/*.svg: regenerated with freeze from stackctl 0.10.0, so the images show agent-guide, exec and port-forward. - CLAUDE.md: the new files in the project structure. The #installation anchor that k8s-stack-manager links to stays. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 +- CONTRIBUTING.md | 97 +++++ README.md | 842 ++------------------------------------ assets/stackctl-help.svg | 6 +- assets/stackctl-stack.svg | 6 +- docs/commands.md | 534 ++++++++++++++++++++++++ docs/configuration.md | 84 ++++ docs/installation.md | 61 +++ docs/server-versions.md | 21 + 9 files changed, 851 insertions(+), 806 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 docs/commands.md create mode 100644 docs/configuration.md create mode 100644 docs/installation.md create mode 100644 docs/server-versions.md diff --git a/CLAUDE.md b/CLAUDE.md index 6178677..394d49f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,6 +17,10 @@ Go version: see `cli/go.mod` (1.27.3 or later); the Dockerfile builder image may Dockerfile # Container image install.sh # Install script (honours INSTALL_DIR) LICENSE # MIT license +README.md # Overview, install, quick start, documentation map (keep it short) +CONTRIBUTING.md # Development setup, structure, tests, pull requests +docs/ # installation.md, configuration.md, commands.md (examples per command group, output formats, flags), server-versions.md +assets/ # Terminal images of the README (made with `freeze --execute "stackctl --help" --theme dracula --window`) entities.json, mempalace.yaml # Mempalace mining config cli/ # Go module root (github.com/omattsson/stackctl/cli); run go commands here main.go # Entry point @@ -196,7 +200,7 @@ 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 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/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, docs/commands.md 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`) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..07af1bb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,97 @@ +# Contributing to stackctl + +Thanks for helping. The project rules for code, tests and documentation are in [CLAUDE.md](CLAUDE.md). The plugin contract is in [EXTENDING.md](EXTENDING.md). + +## Prerequisites + +- Go version: see `cli/go.mod` (1.27.3 or later) +- A running [k8s-stack-manager](https://github.com/omattsson/k8s-stack-manager) backend for integration/e2e tests (`make dev` in that repo) + +## Getting Started + +```bash +git clone https://github.com/omattsson/stackctl.git +cd stackctl/cli +make build +``` + +## Project Structure + +``` +cli/ + main.go + Makefile # build, build-all, test, lint, coverage, install + cmd/ # Cobra commands, one file per command group + root.go resolve.go paging.go plugins.go browser.go token.go values.go + config.go login.go version.go completion.go server.go + stack.go stack_inspect.go stack_followers.go stack_action.go ttl.go + template.go definition.go override.go bulk.go + cluster.go git.go orphaned.go user.go apikey.go audit.go + analytics.go cleanup_policy.go favorite.go notification.go notification_channel.go chart_changes.go dashboard.go + testdata/ # golden files + pkg/ + client/ # HTTP client, auth renewal, WebSocket + config/ # config file, tokens, lock files + output/ # table, JSON, YAML, quiet formatters + types/ # structs for API responses + test/ + integration/ # tests with a mock server + e2e/ # tests that run the built binary + contract/ # tests against the server swagger file + live/ # tests against a running server (build tag `live`) +``` + +## Development Workflow + +Run these commands in `cli/`. + +```bash +make tools # install staticcheck (needed once for make lint) +make build # build bin/stackctl with version information +make test # go test ./... -v +make lint # go vet and staticcheck (CI runs this target) +make coverage # test with a coverage profile and a summary + +# Unit tests only (skip integration and e2e tests) +go test ./... -short + +# One package +go test ./pkg/client/ -v + +# Live tests against a running server (build tag live) +STACKCTL_LIVE_URL=http://localhost:8081 STACKCTL_LIVE_API_KEY=... \ + go test -tags live ./test/live/... + +# Refresh the server swagger file for the contract tests +./test/contract/refresh-swagger.sh +``` + +The live tests skip when no auth is set. Set `STACKCTL_LIVE_API_KEY`, or `STACKCTL_LIVE_USER` and `STACKCTL_LIVE_PASS`. CI runs them in `.github/workflows/live-tests.yml` against a new server. + +## Writing Tests + +- **Unit tests** go next to the code they test (`foo_test.go` next to `foo.go`). +- **Integration tests** go in `test/integration/`. `-short` skips them. +- **E2E tests** go in `test/e2e/`. They build and run the binary. `-short` skips them. +- **Contract tests** in `test/contract/` check the client paths and responses against the server swagger file. +- Use `testify/assert` and `testify/require`. +- Use table-driven tests. Tests in `pkg/` use `t.Parallel()`. Tests in `cmd/` that change package globals (`cfg`, `printer`, flag variables) must not use `t.Parallel()`. Tests that use `t.Setenv` cannot use it either. +- Use mock HTTP servers (`httptest.NewServer`) for client tests. Never call a real API in unit tests. +- CI fails below 70% total coverage. Aim for 80% or more on `pkg/` packages. + +## Adding a New Command + +1. Add types to `pkg/types/types.go` if the API returns new structs +2. Add client methods to `pkg/client/client.go` +3. Create a command file in `cmd/` with `Use`, `Short`, `Long`, `RunE` +4. Register flags and add to the parent command in `init()` +5. Use `pkg/output` for all formatted output +6. Write tests covering success, error, and output format cases + +## Pull Request Guidelines + +- Branch from `main` with a descriptive name (e.g., `feature/stack-commands`, `fix/token-expiry`) +- Include tests for new functionality +- Run `make test` and `make lint` in `cli/` before you push +- Keep PRs focused — one feature or fix per PR +- Reference the relevant GitHub issue in the PR description (e.g., `Closes #3`) diff --git a/README.md b/README.md index 386e322..e16a0db 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # stackctl -Command-line interface for [K8s Stack Manager](https://github.com/omattsson/k8s-stack-manager) — create, deploy, monitor, and manage Helm-based application stacks across Kubernetes clusters. +**The command-line client for [K8s Stack Manager](https://github.com/omattsson/k8s-stack-manager): deploy and operate per-developer Helm stacks on Kubernetes from your terminal, scripts, CI and AI agents, without a kubeconfig.** + +stackctl talks to the K8s Stack Manager API, so you need a K8s Stack Manager server. To try one on your machine, run `make demo` in the [k8s-stack-manager](https://github.com/omattsson/k8s-stack-manager) repository. + +- **No cluster credentials.** Deploy, follow the logs, `stack port-forward` and `stack exec` go through the API, with your login and your role. +- **Built for scripts and CI.** `-o json|yaml`, `-q` for IDs, defined exit codes, `--follow` and `stack watch` to wait for a result, `--yes` for confirmations, and API keys for CI. +- **Built for AI agents.** `stackctl agent-guide` prints a guide that matches the installed version. +- **Plugins in any language.** An executable `stackctl-` on `PATH` becomes `stackctl `. +- **Several environments.** Named contexts, username and password or SSO login, and automatic session renewal.

stackctl CLI @@ -8,84 +16,40 @@ Command-line interface for [K8s Stack Manager](https://github.com/omattsson/k8s- ## Installation -### Quick install (Linux / macOS) - -```bash -curl -fsSL https://raw.githubusercontent.com/omattsson/stackctl/main/install.sh | sudo bash -``` - -The script supports Linux and macOS, on amd64 and arm64. It installs to `/usr/local/bin` by default. Set `INSTALL_DIR` to use another directory. This needs no `sudo` when you own the directory: - -```bash -curl -fsSL https://raw.githubusercontent.com/omattsson/stackctl/main/install.sh | INSTALL_DIR=$HOME/.local/bin bash -``` - -### Homebrew (macOS / Linux) - ```bash +# Homebrew (macOS, Linux) brew install --cask omattsson/tap/stackctl -``` - -No compiler is needed. The cask removes the macOS quarantine attribute from the binary, because the binary is not signed or notarized. The cask needs Homebrew 6.0.13 or later (`brew update`). -If you installed the old formula, remove it once before the first cask install: - -```bash -brew uninstall --formula stackctl && brew install --cask omattsson/tap/stackctl +# Or the install script (Linux, macOS; amd64 and arm64) +curl -fsSL https://raw.githubusercontent.com/omattsson/stackctl/main/install.sh | sudo bash ``` -Homebrew installs stable releases only. To install a release candidate (for example `vX.Y.Z-rc.1`), use the release binaries below. - -### From release binaries - -Download the latest binary for your platform from [Releases](https://github.com/omattsson/stackctl/releases), then: - -```bash -tar -xzf stackctl_*.tar.gz -sudo install -m 755 stackctl /usr/local/bin/stackctl -``` +Release binaries with checksums, release candidates, building from source and the container image: see [docs/installation.md](docs/installation.md). -Archive names are `stackctl___`. Linux and macOS archives are `.tar.gz`. Windows archives are `.zip`. Each release also has a `checksums.txt` file with SHA-256 checksums. Verify a download before you install it: +## Quick start ```bash -sha256sum --check --ignore-missing checksums.txt # macOS: shasum -a 256 --check --ignore-missing checksums.txt -``` - -Release candidates (for example `vX.Y.Z-rc.1`) are GitHub pre-releases. They are not "Latest", and the quick install script and Homebrew do not install them. Download the archive from the release page of the tag, for example `https://github.com/omattsson/stackctl/releases/tag/vX.Y.Z-rc.1`. On macOS, remove the quarantine attribute before the first run: `xattr -d com.apple.quarantine stackctl`. +# Point stackctl at your server and log in +stackctl config use-context my-company +stackctl config set api-url https://stacks.example.com +stackctl login # or: stackctl login --sso -### From source +# Start a stack from a template and follow its deploy +stackctl template list +stackctl template quick-deploy --name my-stack +stackctl stack logs my-stack -f -```bash -git clone https://github.com/omattsson/stackctl.git -cd stackctl/cli -make build -sudo cp bin/stackctl /usr/local/bin/ +# Work with it +stackctl stack pods my-stack +stackctl stack port-forward my-stack web 8080:80 +stackctl stack exec my-stack backend -- php -v ``` -`make build` sets the version, commit and date with linker flags. A plain `go build -o bin/stackctl .` also works, but then `stackctl version` prints development values. - -A container image is available at `ghcr.io/omattsson/stackctl`. It is published for each `v*` tag. - -## Quick Start - -```bash -# 1. Configure a context -stackctl config use-context local -stackctl config set api-url http://localhost:8081 - -# 2. Verify your setup -stackctl version -stackctl config list +See [docs/commands.md](docs/commands.md) for every command group, and [docs/configuration.md](docs/configuration.md) for contexts and login. -# 3. Authenticate -stackctl login -stackctl whoami - -# 4. Browse templates and deploy -stackctl template list -stackctl template quick-deploy 8c9d0e1f-2a3b-4c5d-9e6f-7a8b9c0d1e2f --name my-stack -stackctl stack list --mine -``` +

+ stackctl stack commands +

## Use with AI agents and scripts @@ -125,742 +89,22 @@ Hello! API=http://... args=world hello Plugin: hello ``` -```bash -``` - -The plugin inherits the user's full environment. stackctl also sets the effective settings of the current context and of the global flags as environment variables: `STACKCTL_API_URL`, `STACKCTL_API_KEY` or `STACKCTL_TOKEN` (stackctl sets `STACKCTL_TOKEN` only when no API key resolves), `STACKCTL_CONTEXT`, `STACKCTL_INSECURE`, `STACKCTL_CONFIG_DIR`, and with the flag `STACKCTL_OUTPUT`, `STACKCTL_QUIET`, `STACKCTL_NO_COLOR` (and `NO_COLOR`), `STACKCTL_DEBUG`. Put the global flags before the plugin name. Example: `stackctl --no-color -o json refresh-db status my-stack` gives the plugin the arguments `status my-stack` and the flag values in the environment. Arguments after the plugin name go to the plugin unchanged. See [EXTENDING.md](EXTENDING.md#what-plugins-receive) for the full contract. Built-in subcommands always win on name collisions. This prevents a malicious `stackctl-config` on `PATH` from intercepting credentials. +Plugins get the user's environment plus the settings of the current context and the global flags as `STACKCTL_*` variables (see [EXTENDING.md](EXTENDING.md#what-plugins-receive)). Put the global flags before the plugin name. Built-in subcommands always win on name collisions. This prevents a malicious `stackctl-config` on `PATH` from intercepting credentials. **[Full guide: EXTENDING.md](EXTENDING.md)** — tutorial, recipes in bash/Python/Go, best practices, and how plugins pair with [server-side action webhooks](https://github.com/omattsson/k8s-stack-manager/blob/main/EXTENDING.md) for end-to-end custom operations. -## Configuration - -stackctl uses named contexts to manage multiple environments. The config file is `config.yaml` in the config directory. The default directory is `~/.stackmanager`. stackctl finds the directory in this order: - -1. `STACKCTL_CONFIG_DIR` -2. `$XDG_CONFIG_HOME/stackmanager` -3. `~/.stackmanager` - -Tokens (`tokens/.json`) and lock files (`tokens/.lock`) are in the same directory. - -### Contexts - -```bash -# Create and switch to a context -stackctl config use-context local -stackctl config set api-url http://localhost:8081 - -# Add a production context -stackctl config use-context production -stackctl config set api-url https://stackmanager.example.com -stackctl config set api-key sk_prod_... - -# Switch between contexts -stackctl config use-context local - -# Show current context -stackctl config current-context - -# List all contexts -stackctl config list - -# Delete a context -stackctl config delete-context staging -``` - -`config set` accepts these keys: `api-url`, `api-key` and `insecure`. `config get ` prints one value of the current context. `config list` shows the contexts and the settings of the current context. stackctl stores `api-key` in plain text in `config.yaml`. Restrict access to the file, or use the `STACKCTL_API_KEY` environment variable instead. - -### Authentication - -stackctl supports two authentication methods: - -- **JWT token** — `stackctl login` prompts for credentials and stores the token in `tokens/.json` in the config directory -- **API key** — `stackctl config set api-key sk_...` for non-interactive / CI use - -API key takes precedence when both are configured. - -#### Non-interactive login - -Set `STACKCTL_USERNAME` and `STACKCTL_PASSWORD`, or use the flags `--username` and `--password`, to log in without a prompt. Use `--sso` to log in through the browser. - -```bash -STACKCTL_USERNAME=alice STACKCTL_PASSWORD=... stackctl login -stackctl whoami # show the current user -stackctl logout # end the session and remove the token file -stackctl logout --all # end every session of your user (all devices), for example after a lost laptop -``` - -`stackctl auth register` creates a user. Its flags are `--username`, `--display-name`, `--role`, `--service-account` and `--password-stdin`. Only an admin can set `--role` and `--service-account`. - -#### Session renewal - -By default, the server gives a username/password login a short-lived access token (15 minutes) and a refresh token. `stackctl login` stores both in the token file (mode `0600`). stackctl renews the access token automatically: - -- before a request, when the access token expires within 60 seconds; -- after a `401` response, once, and then it retries the request once; -- before it starts a plugin, so the plugin gets a valid `STACKCTL_TOKEN`. - -Renewal works until the session ends. By default, the server ends a session 12 hours after the login, or after 30 minutes without use. Then stackctl removes the stored token and shows `Not authenticated. Run 'stackctl login' first.` A network or server error during renewal keeps the stored token. - -The server rotates the refresh token on each renewal. stackctl holds a lock on `tokens/.lock` during a renewal, so parallel stackctl commands renew only once. - -An SSO login (`stackctl login --sso`) gets a token without a refresh token. By default, the server sets its life time to 24 hours (`JWT_EXPIRATION`). stackctl does not renew it. API keys have no session. - -`stackctl logout` sends the access token and the refresh token to the server, which ends the session, and then removes the token file. If the server cannot be reached, stackctl prints a warning and still removes the file. `stackctl logout --all` first asks the server to revoke all refresh tokens of your user (the user of the login session, else of the API key); when that call fails, the command stops and keeps the token file, so you can try again. - -### Precedence - -Configuration values are resolved in this order (highest priority first): - -1. Command-line flags (`--api-url`, `--api-key`) -2. Environment variables (`STACKCTL_API_URL`, `STACKCTL_API_KEY`) -3. Config file (`config.yaml` in the config directory) - -## Usage - -### Stack Instances - -

- stackctl stack commands -

- -All stack commands that take `` accept a **name or ID**. Example: `stackctl stack deploy my-app` or `stackctl stack deploy 6a1d2c3b-4e5f-4a6b-8c7d-9e0f1a2b3c4d`. - -```bash -# List instances -stackctl stack list -stackctl stack list --mine --status running -stackctl stack list --cluster 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e -o json - -# Create and deploy -stackctl stack create --definition example-dev --name my-app --branch feature/xyz --ttl 480 -stackctl stack create --definition example-dev --name my-app --stop-at "2026-10-17 18:00" # fixed stop time -stackctl stack deploy my-app - -# Rename, change the branch, the TTL or the stop time (only the given flags change) -stackctl stack update my-app --name my-app-2 -stackctl stack update my-app --branch feature/abc -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), Warning events, containers for exec -stackctl stack logs my-app -stackctl stack recent # your 5 most recently updated stacks - -# Helm values that the next deploy uses (YAML on stdout, warnings on stderr) -stackctl stack preview my-app -stackctl stack preview my-app --chart backend > backend-values.yaml - -# Forward a local port to a service of the stack (runs until Ctrl-C) -stackctl stack port-forward my-app mysql 3306 # then: mysql -h 127.0.0.1 -P 3306 -stackctl stack port-forward my-app frontend 80 # local port 10080 -stackctl stack port-forward my-app mysql 0:3306 -q # any free port; stdout: 127.0.0.1: - -# Run one command in a container of a chart (exit code: the code of the command) -stackctl stack exec my-app backend -- bin/console cache:clear -stackctl stack exec my-app backend -c php -- sh -c 'ls -la /var/www | head' - -# Custom actions of the hooks config (for example a database refresh) -stackctl stack action list my-app -stackctl stack action run my-app refresh-db --param market=dk --yes --wait -stackctl stack action log my-app refresh-db --follow - -# Get the in-app notifications of a stack (not the --follow flag, which streams a log) -stackctl stack follow my-app -stackctl stack unfollow my-app -stackctl stack watch --id 6a1d2c3b-4e5f-4a6b-8c7d-9e0f1a2b3c4d # watch uses --id, not a name argument - -# Lifecycle -stackctl stack stop my-app -stackctl stack clean my-app -stackctl stack delete my-app - -# Clone an existing instance -stackctl stack clone my-app -stackctl stack clone my-app --ttl 240 - -# Add 120 minutes to the stack's expiry (never shortens it); prints the old and the new expiry -stackctl stack extend my-app --minutes 120 -# Set the expiry to a later time -stackctl stack extend my-app --until "2026-10-17 18:00" - -# Server URL, reachability, default TTL and TTL limit (no login needed) -stackctl server info - -# Deployment history and rollback -stackctl stack history my-app -stackctl stack history-values my-app -stackctl stack rollback my-app --target-log -``` - -`stack rollback --target-log` does not change the stored overrides. When the stored overrides produce other values than the target deploy, the server sets `values_drift` and stackctl writes a warning to stderr: the next deploy applies the stored overrides again. `stack get` writes the same warning while the drift exists. - -`stack get -o json` and `-o yaml` include `values_drift` and `warning`. `stack rollback -o json|yaml` prints the rollback response, with `values_drift` and `warning`. `stack clone` writes the server warning to stderr when the quota override of the source is not copied, for example because it is above the cluster quota. - -`stack list`, `stack get`, `definition list/get` and `template get` show the owner, cluster and definition names when the server sends them (`owner_username`, `cluster_name`, `definition_name`). Otherwise they show the IDs. The `stack get` table shows `Cluster` and `Definition` (before: `Cluster ID` and `Definition ID`) as "name (id)" or the ID. - -`stack extend --minutes N` adds N minutes to the current expiry, or to now when the stack has expired. It never makes the expiry earlier and does not change the TTL of the stack. The server caps the new expiry at the TTL limit of the server (30 days by default) from now. An older server ignores `--minutes` and resets the expiry to now + TTL (see Server version requirements). stackctl detects this: it prints the old and the new expiry, then fails (exit code 1) with "the server did not add N minutes ... upgrade k8s-stack-manager to v0.6.0 or later". - -`stack extend --until TIME` sets the expiry to TIME. TIME must be after the current expiry and at most the TTL limit of the server from now. - -`--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