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
6 changes: 5 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: <text>` (`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`)
Expand Down
97 changes: 97 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -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`)
Loading
Loading