diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 7484c2d..5da7dba 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -20,20 +20,21 @@ stackctl is a Go CLI tool (Cobra) for managing Kubernetes stack deployments. It | Group | Subcommands | File | |-------|------------|------| | `config` | set, get, list, use-context, current-context, delete-context | config.go | -| `login`/`logout`/`whoami` | top-level; supports password + OIDC loopback flow | login.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 | | `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/set/delete), quota (get/set/delete) | override.go | +| `override` | list, get, set, unset, delete, branch (list/get/set/delete), quota (get/set/delete) | override.go | | `bulk` | deploy, stop, clean, delete, template (publish, unpublish, delete) | bulk.go | | `git` | providers, branches, validate | git.go | -| `cluster` | list, get, create, update, delete, set-default, test-connection, health, nodes, namespaces, utilization, quota (get/set/delete), shared-values (list/set/delete) | cluster.go | +| `cluster` | list, get, create, update, delete, set-default, test-connection, health, nodes, namespaces, utilization, quota (get/set/delete), shared-values (list/set/update/delete) | cluster.go | | `analytics` | overview, templates, users | analytics.go | | `apikey` | list, create, revoke | apikey.go | | `audit` | log, list, export | audit.go | | `cleanup-policy` | list, get, create, update, delete, run | cleanup_policy.go | -| `favorite` | list, add, remove | favorite.go | +| `favorite` | list, add, remove, check | favorite.go | +| `dashboard` | top-level | dashboard.go | | `notification` | list, count, read, read-all, prefs (get/set), channel (list, get, create, update, delete, events, subscriptions, subscribe, test, logs) | notification.go; channel: notification_channel.go | | `user` | list, delete, disable, enable, reset-password, set-role | user.go | | `orphaned` | list, delete — namespaces with the stack-manager label but no DB record | orphaned.go | diff --git a/CLAUDE.md b/CLAUDE.md index c4780cb..a1e3d47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl root.go # Root cobra command, global flags, config loading, flag > env > config (resolveAPIURL, resolveAPIKey) config.go # config set/get/list/use-context/current-context/delete-context version.go # Version info (build-time ldflags) - login.go # login, logout, whoami, auth register + login.go # login, logout (--all: logout-all first), whoami, auth register token.go # Token helpers (save/load/delete session, fresh token for plugins) stack.go # stack list/get/create/update/deploy/stop/clean/delete/status/logs/clone/extend/values/compare/history/rollback/history-values/watch stack_inspect.go # stack preview/pods/recent (deploy-preview, live pods and events, recent) @@ -36,15 +36,16 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl template.go # template list/get/create/update/clone/publish/unpublish/versions/instantiate/quick-deploy/delete definition.go # definition list/get/create/update/delete/export/import/update-chart chart_changes.go # definition add-chart/remove-chart/check-upgrade/upgrade/link-template, template add-chart/remove-chart - override.go # override list/get/set/unset/delete, branch overrides, quota overrides (quota set merges with GET, --replace) + override.go # override list/get/set/unset/delete, branch overrides (list/get/set/delete), quota overrides (quota set merges with GET, --replace) bulk.go # bulk deploy/stop/clean/delete, bulk template publish/unpublish/delete (--ids flag or positional args) git.go # git providers/branches/validate - cluster.go # cluster list/get/create/update/delete/set-default/test-connection/health/nodes/namespaces/utilization, quota get/set/delete, shared-values list/set/delete + cluster.go # cluster list/get/create/update/delete/set-default/test-connection/health/nodes/namespaces/utilization, quota get/set/delete, shared-values list/set/update/delete (update: GET list, merge, PUT the full record) analytics.go # analytics overview/templates/users apikey.go # apikey list/create/revoke audit.go # audit log list/export cleanup_policy.go # cleanup-policy list/get/create/update/delete/run - favorite.go # favorite list/add/remove + favorite.go # favorite list/add/remove/check + dashboard.go # dashboard (GET /api/v1/dashboard: failing, expiring, recent deployments, clusters) notification.go # notification list/count/read/read-all/prefs notification_channel.go # notification channel list/get/create/update/delete/events/subscriptions/subscribe/test/logs (webhook URL masked unless --show-url) orphaned.go # orphaned list/delete @@ -187,6 +188,10 @@ All API calls go to `/api/v1/*`. Key route groups: - `/api/v1/users` — list/delete/disable/enable/password (admin). `PUT /:id/role` `{"role"}` (k8s-stack-manager v0.8.0+, `user set-role `, user by ID or username via `resolveUserID`): response `id`, `old_role`, `new_role`, `changed`; 403 own role / caller not admin, 409 SSO user / last admin, 400 invalid role; a plain 404 (no route) maps to the v0.8.0 upgrade hint (`setRoleError`) - `/ws` — the server closes a socket with code 1008 and reason `session revoked` or `token expired`. `Client.Watch` returns a `WatchStream`; `Err()` gives a `*WSClosedError` ("Connection closed by the server: . Log in again."). `stack watch` exits non-zero on it; for `token expired` it calls `Client.RenewSession` (forced renewal, false without refresh token) and reconnects, at most once per 30 s (`stackWatchMinReconnect`), then with `--id` GETs each pending instance (`watchCatchUp`) so a terminal status in the gap counts. Ctrl-C during renewal or dial exits 0. Another connection loss: `--id` with pending instances exits non-zero ("connection lost before all instances reached a terminal status"), else a stderr note and exit 0. `StreamDeploymentLogs` returns the same `WSClosedError`; `--follow` (`followLogsCtx`, op deploy/stop/clean/rollback, "" for `stack logs -f`) uses the same `renewAfterClose`; it reads the instance after the first connect (the server sets deploying/stopping/cleaning before the 202; not for `logs -f`) and after each reconnect (`StreamOptions.AfterSubscribe`). 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) - `/api/v1/admin/orphaned-namespaces` — `orphaned list [--details]`; `DELETE /:ns?confirm=` is needed for a namespace without the label managed-by=k8s-stack-manager (409 without it; `orphaned delete --confirm`). `test/contract/path_contract_test.go` checks every `/api/v1` literal of `pkg/client` and `cmd` against the vendored swagger +- `POST /api/v1/auth/logout-all` — `logout --all`: uses the session token of the context when a token file exists (also when an API key is set), else the API key; a 401 gets one forced `RenewSession` and one retry. On failure the command stops and keeps the token file; on success the normal logout follows. +- `GET /api/v1/dashboard` — `dashboard`: covers all users; `failing_instances` has status error only, `expiring_soon` the next hour. For the role `user`, `clusters` has ID and name only (no health): the table hides clusters without `health_status`. +- `GET /api/v1/favorites/check?entity_type=&entity_id=` — `favorite check` prints yes or no (exit 0). +- `PUT /api/v1/clusters/:id/shared-values/:valueId` replaces the full record (no GET for one entry): `shared-values update` reads the list, merges and sends name, description, values and priority. `--set` re-marshals the stored YAML (comments and key order are lost; a stderr note says so). - `/api/v1/admin/notification-channels` (devops or admin) — `notification channel ...`. The responses carry the full `webhook_url` (it can hold a token): every output mode masks it to scheme and host (`maskWebhookURL`) unless `--show-url`. The server never returns the secret; the CLI reads it from `--secret-env NAME` or `--secret-stdin` only. The URL can also come from `--url-env NAME`; error texts never contain the URL. `PUT /:id` changes only the fields sent (`secret: ""` removes the secret; `filters` replaces all filters, `{}` removes them). `PUT /:id/subscriptions` replaces all event types (400 for an unknown one). `POST /:id/test` answers 200 with `success`; the command exits 1 when it is false. Create and update return `warnings` (unknown filter IDs) → stderr. - `/api/v1/notifications/preferences` — `notification prefs set` warns for an event type not in `notificationEventTypes` (`notifier.AllEventTypes()` of k8s-stack-manager); v0.8.0+ servers return 400 - `/api/v1/stack-definitions` — CRUD + export/import diff --git a/README.md b/README.md index 08f30a8..2ec2467 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,7 @@ Set `STACKCTL_USERNAME` and `STACKCTL_PASSWORD`, or use the flags `--username` a 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`. @@ -192,7 +193,7 @@ The server rotates the refresh token on each renewal. stackctl holds a lock on ` 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` 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 @@ -443,6 +444,7 @@ stackctl override delete my-app my-chart # Per-chart branch overrides stackctl override branch set my-app my-chart feature/hotfix +stackctl override branch get my-app my-chart # exit code 1 when the chart has no branch override # Quota overrides stackctl override quota get my-app @@ -609,6 +611,9 @@ stackctl cluster get 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e stackctl cluster shared-values list 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e stackctl cluster shared-values set 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e --name "local-dev-defaults" --file values.yaml stackctl cluster shared-values set 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e --name "local-dev-defaults" --set persistence.storageClass=local-path --priority 10 +# Update an entry (ID or name): the ID and the merge order stay; --set changes single keys, --file replaces the values +stackctl cluster shared-values update 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e local-dev-defaults --set persistence.storageClass=fast +stackctl cluster shared-values update 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e local-dev-defaults --priority 20 stackctl cluster shared-values delete 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e 6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b ``` @@ -626,12 +631,13 @@ These groups have no section above. Run `stackctl --help` for the flags. - `apikey`: `list`, `create`, `revoke` - `analytics`: `overview`, `templates`, `users` - `cleanup-policy`: `list`, `get`, `create`, `update`, `delete`, `run` -- `favorite` (aliases `favorites`, `fav`): `list`, `add`, `remove` +- `favorite` (aliases `favorites`, `fav`): `list`, `add`, `remove`, `check` (prints `yes` or `no`) +- `dashboard`: failing stacks (status error), stacks that expire within the next hour, recent deployments and (devops and admin) cluster health. It covers the stacks of all users. `-q` prints the IDs of the failing and expiring stacks - `notification` (alias `notifications`): `list`, `count`, `read`, `read-all`, `prefs get`, `prefs set`, `channel ...` (see Notification Channels) -- `cluster`: `list`, `get`, `create`, `update`, `delete`, `set-default`, `test-connection`, `health`, `nodes`, `namespaces`, `utilization`, `quota get|set|delete`, `shared-values list|set|delete` +- `cluster`: `list`, `get`, `create`, `update`, `delete`, `set-default`, `test-connection`, `health`, `nodes`, `namespaces`, `utilization`, `quota get|set|delete`, `shared-values list|set|update|delete` - `git`: `providers`, `branches`, `validate` - `template`: also `clone`, `unpublish`, `versions list|get|diff` -- `override`: also `list`, `unset`, `branch list|set|delete`, `quota get|set|delete` +- `override`: also `list`, `unset`, `branch list|get|set|delete`, `quota get|set|delete` - `user`: `list`, `delete`, `disable`, `enable`, `reset-password`, `set-role` - `orphaned`: `list`, `delete` - `server`: `info` (server URL, ping, default TTL and TTL limit; no login needed) @@ -723,7 +729,7 @@ cli/ 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 + 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 diff --git a/cli/cmd/cluster.go b/cli/cmd/cluster.go index 78fd475..0f754c7 100644 --- a/cli/cmd/cluster.go +++ b/cli/cmd/cluster.go @@ -186,7 +186,7 @@ Examples: var clusterSharedValuesCmd = &cobra.Command{ Use: "shared-values", Short: "Manage cluster-level shared Helm values", - Long: "List, create, and delete shared Helm values that apply to all deployments on a cluster.", + Long: "List, create, update and delete shared Helm values that apply to all deployments on a cluster.", } var clusterSharedValuesListCmd = &cobra.Command{ @@ -255,8 +255,10 @@ Examples: var clusterSharedValuesSetCmd = &cobra.Command{ Use: "set ", - Short: "Create or update shared values for a cluster", - Long: `Create or update shared Helm values for a cluster. + Short: "Create a shared values entry for a cluster", + Long: `Create a shared Helm values entry for a cluster. Each call creates a new +entry, also when the name exists. To change an entry, use +'stackctl cluster shared-values update'. Values are provided via --file (JSON or YAML) and/or --set key=value flags, following the same syntax as 'override set'. @@ -345,6 +347,140 @@ Examples: }, } +var clusterSharedValuesUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a shared values entry of a cluster", + Long: `Update a shared values entry of a cluster (admin). is the +ID or the name of the entry. The ID stays the same. The merge order follows +the priority, then the name. + +--file replaces the values with the file (JSON or YAML). --set changes +single keys of the values (after --file when both are given), with the +same syntax as 'override set'. --name, --description and --priority +replace these fields. The other fields keep their values: the command +reads the entry first and sends the full record. The read and the write +are not atomic: a change by another user between them is overwritten. + +--set writes the stored values again as YAML. Comments, the key order, +anchors and a second YAML document are then not kept; the command writes a +note to stderr when this changes the text. --name, --description and +--priority alone do not change the values text. + +Examples: + stackctl cluster shared-values update 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e local-dev-defaults --set persistence.storageClass=fast + stackctl cluster shared-values update 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e local-dev-defaults --file values.yaml + stackctl cluster shared-values update 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e 6e7f8a9b-0c1d-4e2f-8a3b-4c5d6e7f8a9b --priority 20`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + clusterID, err := parseID(args[0]) + if err != nil { + return err + } + file, _ := cmd.Flags().GetString("file") + setFlags, _ := cmd.Flags().GetStringArray("set") + changed := file != "" || len(setFlags) > 0 + for _, f := range []string{"name", "description", "priority"} { + changed = changed || cmd.Flags().Changed(f) + } + if !changed { + return fmt.Errorf("nothing to update: set --name, --description, --priority, --file or --set") + } + if err := checkNoPathTraversal(file); err != nil { + return err + } + sets, err := parseSetFlags(setFlags) + if err != nil { + return err + } + + c, err := newClient() + if err != nil { + return err + } + list, err := c.ListSharedValues(clusterID) + if err != nil { + return err + } + var current *types.SharedValues + for i := range list { + if strings.EqualFold(list[i].ID, args[1]) || list[i].Name == args[1] { + if current != nil { + return fmt.Errorf("more than one shared values entry is named %q: use the ID", args[1]) + } + current = &list[i] + } + } + if current == nil { + return fmt.Errorf("shared values %q not found on cluster %s", args[1], clusterID) + } + + req := &types.UpdateSharedValuesRequest{ + Name: current.Name, Description: current.Description, Values: current.Values, Priority: current.Priority, + } + if cmd.Flags().Changed("name") { + req.Name, _ = cmd.Flags().GetString("name") + } + if cmd.Flags().Changed("description") { + req.Description, _ = cmd.Flags().GetString("description") + } + if cmd.Flags().Changed("priority") { + req.Priority, _ = cmd.Flags().GetInt("priority") + } + if file != "" || len(sets) > 0 { + values := map[string]interface{}{} + source := []byte(current.Values) + if file != "" { + file = filepath.Clean(file) + if source, err = os.ReadFile(file); err != nil { + return readFileErr(file, err) + } + } + if strings.TrimSpace(string(source)) != "" { + if values, err = parseValuesDocument(source); err != nil { + if file != "" { + return fmt.Errorf("invalid JSON/YAML in file %s: %w", file, err) + } + return fmt.Errorf("the stored values of %q are not a YAML mapping (%v): use --file to replace them", current.Name, err) + } + } + if file == "" { + // Tell the user when the YAML round trip changes the stored + // text (comments, key order, anchors, more documents). + if same, err := yaml.Marshal(values); err == nil && strings.TrimSpace(string(same)) != strings.TrimSpace(current.Values) { + fmt.Fprintln(cmd.ErrOrStderr(), "Note: --set writes the stored values again as YAML; comments, the key order and anchors are not kept.") + } + } + for _, kv := range sets { + setNestedPath(values, kv.path, kv.value) + } + out, err := yaml.Marshal(values) + if err != nil { + return fmt.Errorf("serializing values to YAML: %w", err) + } + req.Values = string(out) + } + + sv, err := c.UpdateSharedValues(clusterID, current.ID, req) + if err != nil { + return err + } + if printer.Quiet { + fmt.Fprintln(printer.Writer, sv.ID) + return nil + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(sv) + case output.FormatYAML: + return printer.PrintYAML(sv) + default: + printer.PrintMessage("Updated shared values %q (%s) on cluster %s", sv.Name, sv.ID, clusterID) + return nil + } + }, +} + var clusterSharedValuesDeleteCmd = &cobra.Command{ Use: "delete ", Short: "Delete shared values from a cluster", @@ -1172,6 +1308,12 @@ func init() { clusterSharedValuesSetCmd.Flags().Int("priority", 0, "Merge priority (higher = applied later)") _ = clusterSharedValuesSetCmd.MarkFlagRequired("name") + clusterSharedValuesUpdateCmd.Flags().String("name", "", "New name") + clusterSharedValuesUpdateCmd.Flags().String("description", "", "New description") + clusterSharedValuesUpdateCmd.Flags().String("file", "", "JSON or YAML file that replaces the values") + clusterSharedValuesUpdateCmd.Flags().StringArray("set", nil, "Change a value (key=value, dots for nested keys, \\. for a literal dot), repeatable") + clusterSharedValuesUpdateCmd.Flags().Int("priority", 0, "New merge priority (higher = applied later)") + // shared-values delete flags clusterSharedValuesDeleteCmd.Flags().BoolP("yes", "y", false, flagDescSkipConfirm) clusterSharedValuesDeleteCmd.Flags().Bool("dry-run", false, "Show what would happen without executing") @@ -1198,6 +1340,7 @@ func init() { // Wire up shared-values subcommands clusterSharedValuesCmd.AddCommand(clusterSharedValuesListCmd) clusterSharedValuesCmd.AddCommand(clusterSharedValuesSetCmd) + clusterSharedValuesCmd.AddCommand(clusterSharedValuesUpdateCmd) clusterSharedValuesCmd.AddCommand(clusterSharedValuesDeleteCmd) // cluster quota set flags diff --git a/cli/cmd/dashboard.go b/cli/cmd/dashboard.go new file mode 100644 index 0000000..a9d4abe --- /dev/null +++ b/cli/cmd/dashboard.go @@ -0,0 +1,167 @@ +package cmd + +import ( + "fmt" + "strconv" + "time" + + "github.com/omattsson/stackctl/cli/pkg/client" + "github.com/omattsson/stackctl/cli/pkg/output" + "github.com/omattsson/stackctl/cli/pkg/types" + "github.com/spf13/cobra" +) + +var dashboardCmd = &cobra.Command{ + Use: "dashboard", + Short: "Show the overview: failing and expiring stacks, recent deployments, clusters", + Long: `Show the overview of the landing page of the web UI (GET /api/v1/dashboard). +It covers the stacks of all users, not only your own: + + - Failing stacks (status error) with the error message. + - Stacks that expire within the next hour, with the time left. + - Recent deployments (deploy, stop, clean, rollback). + - Cluster health (devops and admin only). + +An empty section is not shown. -q prints the IDs of the failing stacks and +of the stacks that expire soon, one per line. These stacks can belong to +other users. -o json and -o yaml print the full response. + +Examples: + stackctl dashboard + stackctl dashboard -o json + stackctl dashboard -q`, + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + d, err := c.GetDashboard() + if err != nil { + return err + } + if printer.Quiet { + seen := map[string]bool{} + for _, f := range d.FailingInstances { + if !seen[f.ID] { + seen[f.ID] = true + fmt.Fprintln(printer.Writer, f.ID) + } + } + for _, e := range d.ExpiringSoon { + if !seen[e.ID] { + seen[e.ID] = true + fmt.Fprintln(printer.Writer, e.ID) + } + } + return nil + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(d) + case output.FormatYAML: + return printer.PrintYAML(d) + default: + return printDashboard(d) + } + }, +} + +// printDashboard prints the sections of the dashboard that have entries. +func printDashboard(d *types.Dashboard) error { + now := nowFunc() + s := client.SanitizeServerText + printed := false + section := func(title string) { + if printed { + printer.PrintMessage("") + } + printer.PrintMessage("%s", title) + printed = true + } + + if len(d.FailingInstances) > 0 { + section("Failing stacks:") + rows := make([][]string, len(d.FailingInstances)) + for i, f := range d.FailingInstances { + rows[i] = []string{f.ID, s(f.Name), printer.StatusColor(s(f.Status)), valueOrDash(s(f.ErrorMessage))} + } + if err := printer.PrintTable([]string{"ID", "NAME", "STATUS", "ERROR"}, rows); err != nil { + return err + } + } + if len(d.ExpiringSoon) > 0 { + section("Expiring soon:") + rows := make([][]string, len(d.ExpiringSoon)) + for i, e := range d.ExpiringSoon { + rows[i] = []string{e.ID, s(e.Name), printer.StatusColor(s(e.Status)), formatTime(e.ExpiresAt), timeLeft(e.ExpiresAt, now)} + } + if err := printer.PrintTable([]string{"ID", "NAME", "STATUS", "EXPIRES AT", "LEFT"}, rows); err != nil { + return err + } + } + if len(d.RecentDeployments) > 0 { + section("Recent deployments:") + rows := make([][]string, len(d.RecentDeployments)) + for i, r := range d.RecentDeployments { + start := r.StartedAt + rows[i] = []string{ + formatAge(&start, now), s(r.InstanceName), s(r.Action), printer.StatusColor(s(r.Status)), valueOrDash(s(r.OwnerUsername)), + } + } + if err := printer.PrintTable([]string{"AGE", "STACK", "ACTION", "STATUS", "OWNER"}, rows); err != nil { + return err + } + } + // The role user gets the clusters without health data: no section then. + var clusters []types.DashboardCluster + for _, cl := range d.Clusters { + if cl.HealthStatus != "" { + clusters = append(clusters, cl) + } + } + if len(clusters) > 0 { + section("Clusters:") + rows := make([][]string, len(clusters)) + for i, cl := range clusters { + nodes := "-" + if cl.NodeCount != nil { + ready := "?" + if cl.ReadyNodeCount != nil { + ready = strconv.Itoa(*cl.ReadyNodeCount) + } + nodes = ready + "/" + strconv.Itoa(*cl.NodeCount) + } + namespaces := "-" + if cl.NamespaceCount != nil { + namespaces = strconv.Itoa(*cl.NamespaceCount) + } + rows[i] = []string{s(cl.Name), printer.StatusColor(s(cl.HealthStatus)), nodes, namespaces} + } + if err := printer.PrintTable([]string{"CLUSTER", "HEALTH", "NODES READY", "NAMESPACES"}, rows); err != nil { + return err + } + } + if !printed { + printer.PrintMessage("Nothing to show: no failing or expiring stacks and no recent deployments.") + } + return nil +} + +// timeLeft returns the time until t in kubectl style (45s, 12m, 3h, 2d), +// "expired" for a time in the past, or "-". +func timeLeft(t *time.Time, now time.Time) string { + if t == nil { + return "-" + } + if !t.After(now) { + return "expired" + } + start := now.Add(-t.Sub(now)) + return formatAge(&start, now) +} + +func init() { + rootCmd.AddCommand(dashboardCmd) +} diff --git a/cli/cmd/favorite.go b/cli/cmd/favorite.go index a44018d..73dc208 100644 --- a/cli/cmd/favorite.go +++ b/cli/cmd/favorite.go @@ -18,6 +18,7 @@ authenticated user. list — show all favorites (default table; --quiet prints entity IDs) add — favorite an entity by --type/--id (idempotent server-side) remove — un-favorite an entity by --type/--id (idempotent server-side) + check — show whether an entity is a favorite (yes or no) The "entity type" must be one of: definition, instance, template. Favorites are scoped to the calling user; no admin gate.`, @@ -34,8 +35,49 @@ var ( favoriteAddID string favoriteRemoveType string favoriteRemoveID string + favoriteCheckType string + favoriteCheckID string ) +var favoriteCheckCmd = &cobra.Command{ + Use: "check", + Short: "Show whether an entity is a favorite", + Long: `Show whether an entity (--type and --id) is one of your favorites. The +command prints "yes" or "no" (also with -q). -o json and -o yaml print +{"is_favorite": true|false}. The exit code is 0 in both cases. + +Examples: + stackctl favorite check --type instance --id 550e8400-e29b-41d4-a716-446655440000 + stackctl favorite check --type template --id 8c9d0e1f-2a3b-4c5d-9e6f-7a8b9c0d1e2f -o json`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateFavoriteType(favoriteCheckType); err != nil { + return err + } + id, err := parseID(favoriteCheckID) + if err != nil { + return err + } + c, err := newClient() + if err != nil { + return err + } + fav, err := c.CheckFavorite(favoriteCheckType, id) + if err != nil { + return err + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(map[string]bool{"is_favorite": fav}) + case output.FormatYAML: + return printer.PrintYAML(map[string]bool{"is_favorite": fav}) + default: + fmt.Fprintln(printer.Writer, yesNo(fav)) + return nil + } + }, +} + // favoriteEntityTypes mirrors the backend allowlist // (backend/internal/models/user_favorite.go validFavoriteEntityTypes). // Duplicated client-side so an invalid --type fails before any API call. @@ -201,6 +243,11 @@ func init() { favoriteCmd.AddCommand(favoriteListCmd) favoriteCmd.AddCommand(favoriteAddCmd) favoriteCmd.AddCommand(favoriteRemoveCmd) + + favoriteCheckCmd.Flags().StringVar(&favoriteCheckType, "type", "", "Entity type (definition, instance, template) — required") + favoriteCheckCmd.Flags().StringVar(&favoriteCheckID, "id", "", "Entity ID — required") + _ = favoriteCheckCmd.MarkFlagRequired("id") + favoriteCmd.AddCommand(favoriteCheckCmd) rootCmd.AddCommand(favoriteCmd) } @@ -212,6 +259,8 @@ func ResetFavoriteFlagsForTest() { favoriteAddID = "" favoriteRemoveType = "" favoriteRemoveID = "" + favoriteCheckType = "" + favoriteCheckID = "" } // Internal alias for cmd-package test files. diff --git a/cli/cmd/login.go b/cli/cmd/login.go index d235d4b..a27b8a7 100644 --- a/cli/cmd/login.go +++ b/cli/cmd/login.go @@ -4,6 +4,7 @@ import ( "bufio" "fmt" "io" + "net/http" "os" "strings" "time" @@ -162,10 +163,26 @@ blocks the access token and revokes the refresh token. When the server cannot be reached, stackctl prints a warning and still removes the local token. -Example: - stackctl logout`, +--all first ends every session of your user on the server (POST +/api/v1/auth/logout-all: the server revokes all refresh tokens of the +user), for example after a lost laptop. Sessions on other machines then end +when their access token expires. When this call fails, the command stops +with an error and keeps the local token, so you can try again. --all uses +the login session of the context; without a session it uses the API key +and ends the sessions of the user of the key. The key itself stays valid +(use 'stackctl apikey revoke'). + +Examples: + stackctl logout + stackctl logout --all`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { + if all, _ := cmd.Flags().GetBool("all"); all { + if err := logoutAllSessions(); err != nil { + return err + } + printer.PrintMessage("Ended all sessions of your user on the server") + } if err := logoutSession(cmd.ErrOrStderr()); err != nil { return err } @@ -385,6 +402,40 @@ func bestEffortLogout(apiURL, token, refreshToken string) { _ = c.Logout(refreshToken) } +// logoutAllSessions calls logout-all for the user of the login session of +// the context, or for the user of the API key when there is no session. An +// expired access token gets one renewal and one more try. +func logoutAllSessions() error { + c, err := newClient() + if err != nil { + return err + } + if c.APIKey != "" { + // newClient prefers the API key; the session user is the one to log + // out when a token file exists. + if token, _, _ := loadToken(); token != "" { + c.APIKey = "" + c.Token = token + if store := tokenStore(); store != nil { + c.Tokens = store + } + } + } + if c.APIKey == "" && c.Token == "" { + return fmt.Errorf("not logged in: run 'stackctl login' first, or set an API key") + } + err = c.LogoutAll() + if isStatus(err, http.StatusUnauthorized) { + if renewed, rerr := c.RenewSession(); rerr == nil && renewed { + err = c.LogoutAll() + } + } + if err != nil { + return fmt.Errorf("ending all sessions on the server: %w", err) + } + return nil +} + // logoutSession ends the stored session on the server and removes the // local token. It holds the token lock, so no other stackctl process renews // the session at the same time. A server error is written to warn as a @@ -540,6 +591,7 @@ func init() { authCmd.AddCommand(authRegisterCmd) rootCmd.AddCommand(loginCmd) + logoutCmd.Flags().Bool("all", false, "End every session of your user on the server (all devices) first") rootCmd.AddCommand(logoutCmd) rootCmd.AddCommand(whoamiCmd) rootCmd.AddCommand(authCmd) diff --git a/cli/cmd/override.go b/cli/cmd/override.go index 5e01e49..995582b 100644 --- a/cli/cmd/override.go +++ b/cli/cmd/override.go @@ -588,6 +588,57 @@ Examples: }, } +var overrideBranchGetCmd = &cobra.Command{ + Use: "get ", + Short: "Show the branch override of a chart", + Long: `Show the branch override of one chart in a stack instance. + + is a chart name or a chart ID of the stack's definition. A chart +without an override gives an error (exit code 1); the chart then uses the +branch of the stack. + +Examples: + stackctl override branch get my-stack my-chart + stackctl override branch get my-stack my-chart -o json`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + instanceID, err := resolveStackID(c, args[0]) + if err != nil { + return err + } + chartID, err := resolveChartID(c, instanceID, args[1]) + if err != nil { + return err + } + override, err := c.GetBranchOverride(instanceID, chartID) + if err != nil { + return overrideError(err, "branch", args[1], args[0]) + } + if printer.Quiet { + fmt.Fprintln(printer.Writer, override.ChartConfigID) + return nil + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(override) + case output.FormatYAML: + return printer.PrintYAML(override) + default: + return printer.PrintSingle(override, []output.KeyValue{ + {Key: "Chart ID", Value: override.ChartConfigID}, + {Key: "Instance ID", Value: override.StackInstanceID}, + {Key: "Branch", Value: override.Branch}, + {Key: "Updated At", Value: override.UpdatedAt.Format("2006-01-02T15:04:05Z07:00")}, + }) + } + }, +} + var overrideBranchSetCmd = &cobra.Command{ Use: "set ", Short: "Set a branch override for a chart", @@ -1006,6 +1057,7 @@ func init() { // Wire up branch subcommands overrideBranchCmd.AddCommand(overrideBranchListCmd) + overrideBranchCmd.AddCommand(overrideBranchGetCmd) overrideBranchCmd.AddCommand(overrideBranchSetCmd) overrideBranchCmd.AddCommand(overrideBranchDeleteCmd) diff --git a/cli/cmd/small_gaps_test.go b/cli/cmd/small_gaps_test.go new file mode 100644 index 0000000..dae22ba --- /dev/null +++ b/cli/cmd/small_gaps_test.go @@ -0,0 +1,336 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/omattsson/stackctl/cli/pkg/output" + "github.com/omattsson/stackctl/cli/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ---------- cluster shared-values update ---------- + +const ( + svCluster = "5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e" + svPath = "/api/v1/clusters/" + svCluster + "/shared-values" + svList = `[{"id":"sv1","cluster_id":"` + svCluster + `","name":"defaults","description":"base values","values":"persistence:\n storageClass: slow\nreplicas: 1\n","priority":5}]` +) + +func TestClusterSharedValuesUpdateCmd(t *testing.T) { + file := filepath.Join(t.TempDir(), "values.yaml") + require.NoError(t, os.WriteFile(file, []byte("image: new\n"), 0o600)) + tests := []struct { + name string + arg string + flags []string + want map[string]any + wantNote bool + }{ + {"set changes one key and keeps the rest", "defaults", []string{"set", "persistence.storageClass=fast"}, + map[string]any{"name": "defaults", "description": "base values", "priority": float64(5), + "values": "persistence:\n storageClass: fast\nreplicas: 1\n"}, true}, + {"file replaces the values", "defaults", []string{"file", file}, + map[string]any{"name": "defaults", "description": "base values", "priority": float64(5), "values": "image: new\n"}, false}, + {"priority and description only, ID in upper case", "SV1", []string{"priority", "0", "description", ""}, + map[string]any{"name": "defaults", "description": "", "priority": float64(0), + "values": "persistence:\n storageClass: slow\nreplicas: 1\n"}, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs, out := setupChannelCmd(t, clusterSharedValuesUpdateCmd, map[string]route{ + "GET " + svPath: {body: svList}, + "PUT " + svPath + "/sv1": {body: `{"id":"sv1","name":"defaults"}`}, + }) + setFlags(t, clusterSharedValuesUpdateCmd, tt.flags...) + + require.NoError(t, clusterSharedValuesUpdateCmd.RunE(clusterSharedValuesUpdateCmd, []string{svCluster, tt.arg})) + assert.Equal(t, tt.want, rs.bodies["PUT "+svPath+"/sv1"]) + stdout, stderr := out() + assert.Equal(t, `Updated shared values "defaults" (sv1) on cluster `+svCluster+"\n", stdout) + assert.Equal(t, tt.wantNote, strings.Contains(stderr, "Note: --set writes the stored values again as YAML")) + }) + } +} + +func TestClusterSharedValuesUpdateCmd_Errors(t *testing.T) { + dup := `[{"id":"a","name":"x"},{"id":"b","name":"x"}]` + tests := []struct { + name string + list string + arg string + flags []string + wantErr string + }{ + {"nothing to update", svList, "defaults", nil, "nothing to update"}, + {"unknown entry", svList, "nope", []string{"priority", "1"}, `shared values "nope" not found on cluster`}, + {"duplicate name", dup, "x", []string{"priority", "1"}, `more than one shared values entry is named "x": use the ID`}, + {"bad set", svList, "defaults", []string{"set", "novalue"}, "novalue"}, + {"stored values are a list", `[{"id":"l","name":"list","values":"- a\n- b\n"}]`, "list", []string{"set", "x=1"}, + `the stored values of "list" are not a YAML mapping`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs, _ := setupChannelCmd(t, clusterSharedValuesUpdateCmd, map[string]route{"GET " + svPath: {body: tt.list}}) + setFlags(t, clusterSharedValuesUpdateCmd, tt.flags...) + err := clusterSharedValuesUpdateCmd.RunE(clusterSharedValuesUpdateCmd, []string{svCluster, tt.arg}) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.NotContains(t, strings.Join(rs.requests, ","), "PUT") + }) + } +} + +// ---------- override branch get ---------- + +func TestOverrideBranchGetCmd(t *testing.T) { + const stackPath = "/api/v1/stack-instances/42" + instance := `{"id":"42","name":"my-stack","stack_definition_id":"7"}` + def := `{"id":"7","charts":[{"id":"c1","chart_name":"api"}]}` + + t.Run("override", func(t *testing.T) { + _, out := setupChannelCmd(t, overrideBranchGetCmd, map[string]route{ + "GET " + stackPath: {body: instance}, + "GET /api/v1/stack-definitions/7": {body: def}, + "GET " + stackPath + "/branches/c1": {body: `{"stack_instance_id":"42","chart_config_id":"c1","branch":"feature/x","updated_at":"2026-10-19T08:00:00Z"}`}, + }) + require.NoError(t, overrideBranchGetCmd.RunE(overrideBranchGetCmd, []string{"42", "api"})) + stdout, _ := out() + assert.Regexp(t, `Branch:\s+feature/x`, stdout) + }) + t.Run("no override", func(t *testing.T) { + _, _ = setupChannelCmd(t, overrideBranchGetCmd, map[string]route{ + "GET " + stackPath: {body: instance}, + "GET /api/v1/stack-definitions/7": {body: def}, + "GET " + stackPath + "/branches/c1": {http.StatusNotFound, `{"error":"Branch override not found"}`}, + }) + err := overrideBranchGetCmd.RunE(overrideBranchGetCmd, []string{"42", "api"}) + require.Error(t, err) + assert.Equal(t, "chart api on stack 42 has no branch override", err.Error()) + }) +} + +// ---------- logout --all ---------- + +func TestLogoutCmd_All(t *testing.T) { + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + paths = append(paths, r.URL.Path+" "+r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"message":"ok"}`)) + })) + defer server.Close() + + buf := setupLoginTestCmd(t, server.URL) + require.NoError(t, saveSession("jwt-1", "refresh-1", "alice", time.Now().Add(time.Hour))) + require.NoError(t, logoutCmd.Flags().Set("all", "true")) + t.Cleanup(func() { resetFlag(t, logoutCmd.Flags(), "all", "false") }) + + require.NoError(t, logoutCmd.RunE(logoutCmd, nil)) + assert.Equal(t, []string{"/api/v1/auth/logout-all Bearer jwt-1", "/api/v1/auth/logout Bearer jwt-1"}, paths) + assert.Contains(t, buf.String(), "Ended all sessions of your user on the server") + assert.Contains(t, buf.String(), "Logged out") + assert.Nil(t, readStored(t), "token file removed") +} + +func TestLogoutCmd_AllRenewsAnExpiredToken(t *testing.T) { + newJWT := testJWT(t, "renewed", time.Now().Add(15*time.Minute)) + var calls []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.URL.Path+" "+r.Header.Get("Authorization")) + w.Header().Set("Content-Type", "application/json") + switch { + case r.URL.Path == "/api/v1/auth/logout-all" && r.Header.Get("Authorization") == "Bearer jwt-1": + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"token expired"}`)) + case r.URL.Path == "/api/v1/auth/refresh": + http.SetCookie(w, &http.Cookie{Name: "refresh_token", Value: "refresh-2", Path: "/api/v1/auth"}) + _ = json.NewEncoder(w).Encode(map[string]string{"token": newJWT}) + default: + _, _ = w.Write([]byte(`{"message":"ok"}`)) + } + })) + defer server.Close() + + _ = setupLoginTestCmd(t, server.URL) + require.NoError(t, saveSession("jwt-1", "refresh-1", "alice", time.Now().Add(time.Hour))) + require.NoError(t, logoutCmd.Flags().Set("all", "true")) + t.Cleanup(func() { resetFlag(t, logoutCmd.Flags(), "all", "false") }) + + require.NoError(t, logoutCmd.RunE(logoutCmd, nil)) + assert.Equal(t, []string{ + "/api/v1/auth/logout-all Bearer jwt-1", + "/api/v1/auth/refresh ", + "/api/v1/auth/logout-all Bearer " + newJWT, + "/api/v1/auth/logout Bearer " + newJWT, + }, calls) + assert.Nil(t, readStored(t)) +} + +func TestLogoutCmd_AllCredentials(t *testing.T) { + tests := []struct { + name string + session bool + wantAuth string + }{ + {"session wins over the API key", true, "Bearer jwt-1"}, + {"API key without a session", false, "key:sk_test"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotAuth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/auth/logout-all" { + gotAuth = r.Header.Get("Authorization") + if k := r.Header.Get("X-API-Key"); k != "" { + gotAuth = "key:" + k + } + } + _, _ = w.Write([]byte(`{"message":"ok"}`)) + })) + defer server.Close() + + _ = setupLoginTestCmd(t, server.URL) + if tt.session { + require.NoError(t, saveSession("jwt-1", "refresh-1", "alice", time.Now().Add(time.Hour))) + } + flagAPIKey = "sk_test" + require.NoError(t, logoutCmd.Flags().Set("all", "true")) + t.Cleanup(func() { resetFlag(t, logoutCmd.Flags(), "all", "false") }) + + require.NoError(t, logoutCmd.RunE(logoutCmd, nil)) + assert.Equal(t, tt.wantAuth, gotAuth) + }) + } +} + +func TestLogoutCmd_AllFailsKeepsToken(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/auth/logout-all", r.URL.Path, "logout must not run after a failed logout-all") + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte(`{"error":"Internal server error"}`)) + })) + defer server.Close() + + _ = setupLoginTestCmd(t, server.URL) + require.NoError(t, saveSession("jwt-1", "refresh-1", "alice", time.Now().Add(time.Hour))) + require.NoError(t, logoutCmd.Flags().Set("all", "true")) + t.Cleanup(func() { resetFlag(t, logoutCmd.Flags(), "all", "false") }) + + err := logoutCmd.RunE(logoutCmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "ending all sessions on the server: Server error") + assert.NotNil(t, readStored(t), "the local token stays so the user can try again") +} + +func TestLogoutCmd_AllNotLoggedIn(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected %s", r.URL.Path) + })) + defer server.Close() + _ = setupLoginTestCmd(t, server.URL) + require.NoError(t, logoutCmd.Flags().Set("all", "true")) + t.Cleanup(func() { resetFlag(t, logoutCmd.Flags(), "all", "false") }) + + err := logoutCmd.RunE(logoutCmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "not logged in") +} + +// ---------- favorite check ---------- + +func TestFavoriteCheckCmd(t *testing.T) { + const id = "550e8400-e29b-41d4-a716-446655440000" + tests := []struct { + name string + body string + format output.Format + want string + }{ + {"yes", `{"is_favorite":true}`, output.FormatTable, "yes\n"}, + {"no", `{"is_favorite":false}`, output.FormatTable, "no\n"}, + {"json", `{"is_favorite":true}`, output.FormatJSON, `"is_favorite": true`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var query string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/favorites/check", r.URL.Path) + query = r.URL.RawQuery + _, _ = w.Write([]byte(tt.body)) + })) + defer server.Close() + buf := setupStackTestCmd(t, server.URL) + printer.Format = tt.format + favoriteCheckType, favoriteCheckID = "instance", id + t.Cleanup(ResetFavoriteFlagsForTest) + + require.NoError(t, favoriteCheckCmd.RunE(favoriteCheckCmd, nil)) + assert.Equal(t, "entity_id="+id+"&entity_type=instance", query) + assert.Contains(t, buf.String(), tt.want) + }) + } +} + +func TestFavoriteCheckCmd_BadType(t *testing.T) { + favoriteCheckType, favoriteCheckID = "cluster", "1" + t.Cleanup(ResetFavoriteFlagsForTest) + err := favoriteCheckCmd.RunE(favoriteCheckCmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), `--type "cluster" is invalid`) +} + +// ---------- dashboard ---------- + +const dashboardBody = `{ + "failing_instances":[{"id":"f1","name":"broken","status":"error","error_message":"helm failed\u001b[2J","updated_at":"2026-10-19T10:00:00Z"}], + "expiring_soon":[{"id":"e1","name":"soon","status":"running","expires_at":"2026-10-15T12:30:00Z","ttl_minutes":60}, + {"id":"f1","name":"broken","status":"error","expires_at":"2026-10-15T11:00:00Z","ttl_minutes":60}], + "recent_deployments":[{"id":"d1","stack_instance_id":"e1","instance_name":"soon","action":"deploy","status":"success","started_at":"2026-10-15T11:00:00Z","owner_username":"alice"}], + "clusters":[{"id":"c1","name":"dev","health_status":"healthy","node_count":3,"ready_node_count":2,"namespace_count":12}]}` + +func TestDashboardCmd(t *testing.T) { + useExpiryClock(t) // 2026-10-15T12:00Z + t.Run("table", func(t *testing.T) { + _, out := setupChannelCmd(t, dashboardCmd, map[string]route{"GET /api/v1/dashboard": {body: dashboardBody}}) + require.NoError(t, dashboardCmd.RunE(dashboardCmd, nil)) + stdout, _ := out() + assert.Regexp(t, `Failing stacks:\n`, stdout) + assert.Regexp(t, `f1\s+broken\s+error\s+helm failed \[2J`, stdout) + assert.Regexp(t, `e1\s+soon\s+running\s+2026-10-15T12:30:00Z\s+30m`, stdout) + assert.Regexp(t, `f1\s+broken\s+error\s+2026-10-15T11:00:00Z\s+expired`, stdout) + assert.Regexp(t, `1h\s+soon\s+deploy\s+success\s+alice`, stdout) + assert.Regexp(t, `dev\s+healthy\s+2/3\s+12`, stdout) + assert.NotContains(t, stdout, "\x1b[2J") + }) + t.Run("quiet lists unique IDs", func(t *testing.T) { + _, out := setupChannelCmd(t, dashboardCmd, map[string]route{"GET /api/v1/dashboard": {body: dashboardBody}}) + printer.Quiet = true + require.NoError(t, dashboardCmd.RunE(dashboardCmd, nil)) + stdout, _ := out() + assert.Equal(t, "f1\ne1\n", stdout) + }) + t.Run("empty for the role user: clusters without health data", func(t *testing.T) { + _, out := setupChannelCmd(t, dashboardCmd, map[string]route{"GET /api/v1/dashboard": {body: `{"clusters":[{"id":"c1","name":"dev","health_status":""}],"recent_deployments":[],"expiring_soon":[],"failing_instances":[]}`}}) + require.NoError(t, dashboardCmd.RunE(dashboardCmd, nil)) + stdout, _ := out() + assert.Equal(t, "Nothing to show: no failing or expiring stacks and no recent deployments.\n", stdout) + }) + t.Run("json", func(t *testing.T) { + _, out := setupChannelCmd(t, dashboardCmd, map[string]route{"GET /api/v1/dashboard": {body: dashboardBody}}) + printer.Format = output.FormatJSON + require.NoError(t, dashboardCmd.RunE(dashboardCmd, nil)) + stdout, _ := out() + var d types.Dashboard + require.NoError(t, json.Unmarshal([]byte(stdout), &d)) + assert.Len(t, d.ExpiringSoon, 2) + require.NotNil(t, d.Clusters[0].ReadyNodeCount) + }) +} diff --git a/cli/pkg/client/auth.go b/cli/pkg/client/auth.go index b1e1ca8..f6380b4 100644 --- a/cli/pkg/client/auth.go +++ b/cli/pkg/client/auth.go @@ -367,6 +367,13 @@ func (c *Client) Logout(refreshToken string) error { return resp.Body.Close() } +// LogoutAll ends every session of the caller: POST /api/v1/auth/logout-all +// revokes all refresh tokens of the user. It uses the API key or the +// current access token, and never renews (an auth endpoint). +func (c *Client) LogoutAll() error { + return c.Post(pathLogoutAll, nil, nil) +} + // sendSession sends a request without a body to a session endpoint with an // optional bearer token and an optional refresh token cookie. It never // renews and never retries. diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index 3b0b0b0..ea714f8 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -1456,6 +1456,16 @@ func (c *Client) DeleteValueOverride(instanceID, chartID string) error { return c.Delete(fmt.Sprintf(pathOverride, instanceID, chartID)) } +// GetBranchOverride returns the branch override of one chart of a stack +// instance. A chart without an override gives a 404 "override not found". +func (c *Client) GetBranchOverride(instanceID, chartID string) (*types.BranchOverride, error) { + var o types.BranchOverride + if err := c.Get(fmt.Sprintf("/api/v1/stack-instances/%s/branches/%s", instanceID, chartID), &o); err != nil { + return nil, err + } + return &o, nil +} + // ListBranchOverrides returns all branch overrides for a stack instance. func (c *Client) ListBranchOverrides(instanceID string) ([]types.BranchOverride, error) { var overrides []types.BranchOverride @@ -1789,6 +1799,36 @@ func (c *Client) SetSharedValues(clusterID string, req *types.SetSharedValuesReq return &sv, nil } +// UpdateSharedValues replaces a shared values entry of a cluster (admin). +func (c *Client) UpdateSharedValues(clusterID, sharedValuesID string, req *types.UpdateSharedValuesRequest) (*types.SharedValues, error) { + var sv types.SharedValues + if err := c.Put(fmt.Sprintf(pathSharedValuesID, clusterID, sharedValuesID), req, &sv); err != nil { + return nil, err + } + return &sv, nil +} + +// GetDashboard returns the landing page overview (GET /api/v1/dashboard). +func (c *Client) GetDashboard() (*types.Dashboard, error) { + var d types.Dashboard + if err := c.Get("/api/v1/dashboard", &d); err != nil { + return nil, err + } + return &d, nil +} + +// CheckFavorite reports whether an entity is a favorite of the caller. +func (c *Client) CheckFavorite(entityType, entityID string) (bool, error) { + var resp struct { + IsFavorite bool `json:"is_favorite"` + } + params := map[string]string{"entity_type": entityType, "entity_id": entityID} + if err := c.GetWithQuery("/api/v1/favorites/check", params, &resp); err != nil { + return false, err + } + return resp.IsFavorite, nil +} + // DeleteSharedValues deletes shared values from a cluster. func (c *Client) DeleteSharedValues(clusterID, sharedValuesID string) error { return c.Delete(fmt.Sprintf(pathSharedValuesID, clusterID, sharedValuesID)) diff --git a/cli/pkg/client/small_gaps_test.go b/cli/pkg/client/small_gaps_test.go new file mode 100644 index 0000000..1cb318a --- /dev/null +++ b/cli/pkg/client/small_gaps_test.go @@ -0,0 +1,65 @@ +package client + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/omattsson/stackctl/cli/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSmallGapRoutes(t *testing.T) { + t.Parallel() + type call struct{ method, uri, body, auth string } + var got []call + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + got = append(got, call{r.Method, r.URL.RequestURI(), string(data), r.Header.Get("X-API-Key")}) + switch r.URL.Path { + case "/api/v1/favorites/check": + _, _ = io.WriteString(w, `{"is_favorite":true}`) + case "/api/v1/dashboard": + _, _ = io.WriteString(w, `{"failing_instances":[{"id":"f1"}]}`) + case "/api/v1/auth/logout-all": + _, _ = io.WriteString(w, `{"message":"ok"}`) + default: + _, _ = io.WriteString(w, `{"id":"x","branch":"b"}`) + } + })) + t.Cleanup(srv.Close) + c := New(srv.URL) + c.APIKey = "sk_test" + + _, err := c.UpdateSharedValues("c1", "sv1", &types.UpdateSharedValuesRequest{Name: "n", Priority: 0}) + require.NoError(t, err) + o, err := c.GetBranchOverride("42", "ch1") + require.NoError(t, err) + assert.Equal(t, "b", o.Branch) + require.NoError(t, c.LogoutAll()) + d, err := c.GetDashboard() + require.NoError(t, err) + assert.Equal(t, "f1", d.FailingInstances[0].ID) + fav, err := c.CheckFavorite("template", "t1") + require.NoError(t, err) + assert.True(t, fav) + + want := []call{ + {http.MethodPut, "/api/v1/clusters/c1/shared-values/sv1", `{"name":"n","description":"","values":"","priority":0}`, "sk_test"}, + {http.MethodGet, "/api/v1/stack-instances/42/branches/ch1", "", "sk_test"}, + {http.MethodPost, "/api/v1/auth/logout-all", "", "sk_test"}, + {http.MethodGet, "/api/v1/dashboard", "", "sk_test"}, + {http.MethodGet, "/api/v1/favorites/check?entity_id=t1&entity_type=template", "", "sk_test"}, + } + require.Len(t, got, len(want)) + for i, w := range want { + assert.Equal(t, w.method, got[i].method, i) + assert.Equal(t, w.uri, got[i].uri, i) + assert.Equal(t, w.auth, got[i].auth, i) + if w.body != "" { + assert.JSONEq(t, w.body, got[i].body, i) + } + } +} diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index 2ba001b..26134be 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -1406,10 +1406,80 @@ type SetQuotaOverrideRequest struct { // SharedValues represents cluster-level shared Helm values. type SharedValues struct { Base - ClusterID string `json:"cluster_id" yaml:"cluster_id"` - Name string `json:"name" yaml:"name"` - Values string `json:"values" yaml:"values"` - Priority int `json:"priority" yaml:"priority"` + ClusterID string `json:"cluster_id" yaml:"cluster_id"` + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Values string `json:"values" yaml:"values"` + Priority int `json:"priority" yaml:"priority"` +} + +// UpdateSharedValuesRequest is the body of PUT +// /api/v1/clusters/:id/shared-values/:valueId. The server replaces every +// field, so the caller sends the full record. +type UpdateSharedValuesRequest struct { + Name string `json:"name"` + Description string `json:"description"` + Values string `json:"values"` + Priority int `json:"priority"` +} + +// Dashboard is the response of GET /api/v1/dashboard. It covers the +// stacks of all users. For the role user, Clusters has only the ID and the +// name of each cluster (no health data). +type Dashboard struct { + Clusters []DashboardCluster `json:"clusters" yaml:"clusters"` + RecentDeployments []DashboardDeployment `json:"recent_deployments" yaml:"recent_deployments"` + ExpiringSoon []DashboardExpiring `json:"expiring_soon" yaml:"expiring_soon"` + FailingInstances []DashboardFailing `json:"failing_instances" yaml:"failing_instances"` +} + +// DashboardCluster is the health of one cluster on the dashboard. +type DashboardCluster struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + HealthStatus string `json:"health_status" yaml:"health_status"` + NodeCount *int `json:"node_count,omitempty" yaml:"node_count,omitempty"` + ReadyNodeCount *int `json:"ready_node_count,omitempty" yaml:"ready_node_count,omitempty"` + TotalCPU string `json:"total_cpu,omitempty" yaml:"total_cpu,omitempty"` + TotalMemory string `json:"total_memory,omitempty" yaml:"total_memory,omitempty"` + AllocatableCPU string `json:"allocatable_cpu,omitempty" yaml:"allocatable_cpu,omitempty"` + AllocatableMemory string `json:"allocatable_memory,omitempty" yaml:"allocatable_memory,omitempty"` + NamespaceCount *int `json:"namespace_count,omitempty" yaml:"namespace_count,omitempty"` +} + +// DashboardDeployment is one recent deploy, stop, clean or rollback. +type DashboardDeployment struct { + ID string `json:"id" yaml:"id"` + StackInstanceID string `json:"stack_instance_id" yaml:"stack_instance_id"` + InstanceName string `json:"instance_name" yaml:"instance_name"` + Action string `json:"action" yaml:"action"` + Status string `json:"status" yaml:"status"` + StartedAt time.Time `json:"started_at" yaml:"started_at"` + CompletedAt *time.Time `json:"completed_at,omitempty" yaml:"completed_at,omitempty"` + OwnerUsername string `json:"owner_username,omitempty" yaml:"owner_username,omitempty"` +} + +// DashboardExpiring is a stack that expires soon. +type DashboardExpiring struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + Namespace string `json:"namespace" yaml:"namespace"` + OwnerID string `json:"owner_id" yaml:"owner_id"` + Status string `json:"status" yaml:"status"` + ExpiresAt *time.Time `json:"expires_at" yaml:"expires_at"` + TTLMinutes int `json:"ttl_minutes" yaml:"ttl_minutes"` + ClusterID string `json:"cluster_id,omitempty" yaml:"cluster_id,omitempty"` +} + +// DashboardFailing is a stack with status error or partial. +type DashboardFailing struct { + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + Namespace string `json:"namespace" yaml:"namespace"` + Status string `json:"status" yaml:"status"` + ErrorMessage string `json:"error_message" yaml:"error_message"` + ClusterID string `json:"cluster_id,omitempty" yaml:"cluster_id,omitempty"` + UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` } // SetSharedValuesRequest is the request body for creating/updating shared values. diff --git a/cli/test/contract/response_contract_test.go b/cli/test/contract/response_contract_test.go index d26231c..923c818 100644 --- a/cli/test/contract/response_contract_test.go +++ b/cli/test/contract/response_contract_test.go @@ -45,6 +45,13 @@ func TestResponseSchemas_MatchBackend(t *testing.T) { {name: "GitBranch", goType: types.GitBranch{}, swaggerDef: "gitprovider.Branch"}, {name: "Cluster", goType: types.Cluster{}, swaggerDef: "models.Cluster"}, {name: "UIConfig", goType: types.UIConfig{}, swaggerDef: "handlers.UIConfigResponse"}, + {name: "Dashboard", goType: types.Dashboard{}, swaggerDef: "handlers.DashboardResponse"}, + {name: "DashboardCluster", goType: types.DashboardCluster{}, swaggerDef: "handlers.DashboardCluster"}, + {name: "DashboardDeployment", goType: types.DashboardDeployment{}, swaggerDef: "handlers.DashboardDeployment"}, + {name: "DashboardExpiring", goType: types.DashboardExpiring{}, swaggerDef: "handlers.DashboardExpiring"}, + {name: "DashboardFailing", goType: types.DashboardFailing{}, swaggerDef: "handlers.DashboardFailing"}, + {name: "SharedValues", goType: types.SharedValues{}, swaggerDef: "models.SharedValues", excludeGoTags: baseOnly}, + {name: "UpdateSharedValuesRequest", goType: types.UpdateSharedValuesRequest{}, swaggerDef: "models.SharedValues"}, {name: "FollowState", goType: types.FollowState{}, swaggerDef: "handlers.followStateResponse"}, {name: "DeployPreview", goType: types.DeployPreview{}, swaggerDef: "handlers.DeployPreviewResponse"}, {name: "ChartDeployPreview", goType: types.ChartDeployPreview{}, swaggerDef: "handlers.ChartDeployPreview"},