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: 3 additions & 3 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ stackctl is a Go CLI tool (Cobra) for managing Kubernetes stack deployments. It
## Architecture

- All code lives under `cli/` — the Go module root (`cli/go.mod`). Run Go commands from `cli/`.
- Go version: see `cli/go.mod` (1.26.10 or later). CI uses `go-version-file: cli/go.mod`.
- Go version: see `cli/go.mod` (1.27.3 or later). CI uses `go-version-file: cli/go.mod`.
- Commands: `cli/cmd/` (one file per command group, subcommands registered in `init()`)
- HTTP client: `cli/pkg/client/client.go` (REST), `cli/pkg/client/auth.go` (session renewal), `cli/pkg/client/websocket.go` (deployment log streaming)
- HTTP client: `cli/pkg/client/client.go` (REST), `cli/pkg/client/auth.go` (session renewal), `cli/pkg/client/websocket.go` (deployment log streaming, stream dial, close codes, keepalive), `cli/pkg/client/portforward.go` (port-forward tunnel)
- Types: `cli/pkg/types/types.go` (client-side structs matching API responses)
- Output: `cli/pkg/output/output.go` (table, JSON, YAML, quiet formatters)
- Config: `cli/pkg/config/config.go` (YAML via `gopkg.in/yaml.v3`, `~/.stackmanager/config.yaml`), `cli/pkg/config/token.go` (token files), `lock_unix.go`, `lock_windows.go` and `lock_other.go` (file locks)
Expand All @@ -22,7 +22,7 @@ stackctl is a Go CLI tool (Cobra) for managing Kubernetes stack deployments. It
| `config` | set, get, list, use-context, current-context, delete-context | config.go |
| `login`/`logout`/`whoami` | top-level; supports password + OIDC loopback flow; `logout --all` ends every session of the user | login.go |
| `auth` | register | login.go |
| `stack` | list, get, create, update, deploy, stop, clean, delete, status, logs, clone, extend, values, compare, history, rollback, history-values, watch, preview, pods, recent, follow, unfollow, action (list, run, log) | stack.go; preview/pods/recent: stack_inspect.go; follow/unfollow: stack_followers.go; action: stack_action.go; TTL and stop-time helpers: ttl.go |
| `stack` | list, get, create, update, deploy, stop, clean, delete, status, logs, clone, extend, values, compare, history, rollback, history-values, watch, preview, pods, recent, follow, unfollow, action (list, run, log), port-forward | stack.go; preview/pods/recent: stack_inspect.go; follow/unfollow: stack_followers.go; action: stack_action.go; port-forward: stack_portforward.go; TTL and stop-time helpers: ttl.go |
| `template` | list, get, create, update, delete, clone, instantiate, quick-deploy, publish, unpublish, update-chart, add-chart, remove-chart, versions (list, get, diff) | template.go; add-chart/remove-chart: chart_changes.go |
| `definition` | list, get, create, update, delete, export, import, update-chart, add-chart, remove-chart, check-upgrade, upgrade, link-template | definition.go; add-chart/remove-chart/check-upgrade/upgrade/link-template: chart_changes.go |
| `override` | list, get, set, unset, delete, branch (list/get/set/delete), quota (get/set/delete) | override.go |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
cache-dependency-path: cli/go.sum

- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
run: go install golang.org/x/vuln/cmd/govulncheck@v1.9.0

- name: Run govulncheck
working-directory: cli
Expand Down
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

Go CLI tool (Cobra + yaml.v3) for managing Kubernetes stack deployments. Pure API client — talks to the [k8s-stack-manager](https://github.com/omattsson/k8s-stack-manager) backend. No backend logic, no frontend, no database, no direct K8s interaction.

Go version: see `cli/go.mod` (1.26.10 or later); the Dockerfile builder image may be newer.
Go version: see `cli/go.mod` (1.27.3 or later); the Dockerfile builder image may be newer.

## Project Structure

Expand Down Expand Up @@ -32,6 +32,7 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl
stack_inspect.go # stack preview/pods/recent (deploy-preview, live pods and events, 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
ttl.go # --ttl/--stop-at/--until parsing, TTL limit check (GET /api/v1/ui-config, 30-day fallback), expiry text
server.go # server info (ping, ui-config; public routes, no credentials)
template.go # template list/get/create/update/clone/publish/unpublish/versions/instantiate/quick-deploy/delete
Expand Down Expand Up @@ -62,7 +63,8 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl
client/
client.go # HTTP client wrapper (auth headers, base URL, error handling)
auth.go # Session renewal (refresh token, 401 retry), login/refresh/logout calls
websocket.go # WebSocket client (watch, deploy log streaming)
websocket.go # WebSocket client (watch, deploy log streaming); stream dial without ?token= (dialWSOpts), handshake error codes, close codes, keepalive
portforward.go # DialPortForward and Tunnel (byte copy, half-close handling, keepalive)
types/
types.go # Client-side structs matching API responses
config/
Expand Down Expand Up @@ -192,6 +194,7 @@ All API calls go to `/api/v1/*`. Key route groups:
- `/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> <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: <reason>. 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; 3 handshake 429s in a row, with no successful upgrade between them (`portForwardMax429`, one counter for all tunnels), stop the command). 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`. 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=<ns>` 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`.
Expand Down
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,11 @@ stackctl stack recent # your 5 most recently updated stacks
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:<port>

# 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
Expand Down Expand Up @@ -298,6 +303,13 @@ stackctl stack rollback my-app --target-log <log-id>

`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), 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. 3 refusals with 429 in a row, with no successful tunnel between them, 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.
- 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 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`.

`stack action run`:
Expand Down Expand Up @@ -669,6 +681,7 @@ These groups have no section above. Run `stackctl <group> --help` for the flags.
| `definition link-template` | v0.11.0 or later |
| `--stop-at`, `stack extend --until`, the TTL limit of `server info` and of the local check (an older server ignores `--stop-at` and `--until`: stackctl shows the result and exits with code 1; it uses 30 days as the limit) | v0.12.0 or later |
| 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) |

## Output Formats

Expand Down Expand Up @@ -733,7 +746,7 @@ stackctl completion powershell | Out-String | Invoke-Expression

### Prerequisites

- Go version: see `cli/go.mod` (1.26.10 or later)
- 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
Expand Down
10 changes: 6 additions & 4 deletions cli/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS = -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE)

STATICCHECK_VERSION ?= 2026.1
STATICCHECK_VERSION ?= 2026.2.2
# Use the binary that `make tools` installs, so lint works when GOBIN is not on PATH.
STATICCHECK := $(or $(shell go env GOBIN),$(shell go env GOPATH)/bin)/staticcheck

# Detect Windows for .exe extension
ifdef GOOS
Expand Down Expand Up @@ -44,11 +46,11 @@ test:

lint:
go vet ./...
@if ! command -v staticcheck >/dev/null 2>&1; then \
echo "Error: staticcheck is not installed. Run 'make tools' to install staticcheck@$(STATICCHECK_VERSION)"; \
@if ! $(STATICCHECK) -version 2>/dev/null | grep -qF "staticcheck $(STATICCHECK_VERSION) "; then \
echo "Error: $(STATICCHECK) is missing or not version $(STATICCHECK_VERSION). Run 'make tools'"; \
exit 1; \
fi
staticcheck ./...
$(STATICCHECK) ./...

coverage:
go test ./... -coverprofile=coverage.out
Expand Down
7 changes: 7 additions & 0 deletions cli/cmd/addrinuse_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
//go:build !windows

package cmd

func isAddrInUseOS(error) bool {
return false
}
15 changes: 15 additions & 0 deletions cli/cmd/addrinuse_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//go:build windows

package cmd

import (
"errors"

"golang.org/x/sys/windows"
)

// isAddrInUseOS reports WSAEADDRINUSE, which syscall.EADDRINUSE does not
// match on Windows.
func isAddrInUseOS(err error) bool {
return errors.Is(err, windows.WSAEADDRINUSE)
}
10 changes: 9 additions & 1 deletion cli/cmd/agent_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,14 @@ stackctl stack values my-app # merged Helm values
stackctl stack preview my-app # values that the next deploy uses
stackctl stack history my-app

# Reach a service of the stack: runs until interrupted, so start it in the background
stackctl stack port-forward my-app mysql 0:3306 -q # stdout: 127.0.0.1:<port>; exit 1 on 403/404/409
# SIGTERM or Ctrl-C ends it with exit 0. At most 16 open connections; more are closed at once.
# After a local EOF the tunnel passes server data until the server closes or 2 s pass without data.
# 3 refusals with 429 in a row 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).

# Change values, then deploy again
stackctl override set my-app my-chart --set image.tag=1.2.3
stackctl override branch set my-app my-chart feature/x
Expand Down Expand Up @@ -106,7 +114,7 @@ When `--follow` or `watch --id` stops early for another reason (lost connection,
- `Conflict: ...`: the state does not allow the change (for example a name in use; the message lists free names).
- `Rate limited`: wait and try once more.
- `Server error`: report it; the server logs have the cause.
- `upgrade k8s-stack-manager to vX or later`: the server is too old for this feature. Tell the user.
- `upgrade k8s-stack-manager to vX or later` or `upgrade the server to k8s-stack-manager vX or later`: the server is too old for this feature. Tell the user.
- `Warning: ...` on stderr: a notice from the server (for example a deprecation). The command still worked.

## More
Expand Down
Loading
Loading