diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 2782321..2e5dfe5 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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 | 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 | stack.go; preview/pods/recent: stack_inspect.go; follow/unfollow: stack_followers.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) | 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, versions (list, get, diff) | template.go | | `definition` | list, get, create, update, delete, export, import, update-chart | definition.go | | `override` | list, get, set, unset, delete, branch (list/set/delete), quota (get/set/delete) | override.go | diff --git a/CLAUDE.md b/CLAUDE.md index d218058..c571f78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl 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) + 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) 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) diff --git a/README.md b/README.md index 21c54c3..23e1cce 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,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 +# Custom actions of the hooks config (for example a database refresh) +stackctl stack action list my-app +stackctl stack action run my-app refresh-db --param market=dk --yes --wait +stackctl stack action log my-app refresh-db --follow + # Get the in-app notifications of a stack (not the --follow flag, which streams a log) stackctl stack follow my-app stackctl stack unfollow my-app @@ -280,6 +285,22 @@ stackctl stack rollback my-app --target-log `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 action run`: + +- Checks the declared parameters before the request: a `bool` takes `true` or `false`, an `enum` takes one of its options, and a required parameter needs a value. +- Gives a declared parameter without `--param` its default. Undeclared parameters go to the subscriber as strings. +- Asks for confirmation when the action has a confirmation text. `--yes` skips the question. +- Exits with code 1 when the subscriber answers with status 400 or higher. + +`--wait` (and `stack action log --follow`) follows the job log of an asynchronous action: + +- It writes the job ID to stderr first. The job continues on the server when the command stops. +- It polls every 3 s and prints the new log text. +- On 429, 502, 503, 504 and network errors it writes a note to stderr and waits longer: `Retry-After` (at most 5 minutes), else a doubling delay up to 30 s. +- It stops with exit code 1 on a failed final status (a status other than `succeeded`, `success`, `completed`, `done`, `ok` and similar), on other errors (for example 401, 403 or 404), and after `--timeout` (default 60m). +- With `-o json|yaml`, the log text goes to stderr and the result has `job_status`. +- In log text, a line feed and a tab stay. A carriage return and a bidi control are removed. Other control characters (for example ESC) become a space. + `stack update --name` must be a DNS label (lowercase a-z, 0-9 and `-`, at most 50 characters). The namespace does not change. When another stack on the cluster has the name, stackctl prints the server message and the free names, for example `Conflict: A stack named "my-app" already exists on this cluster (owner: alice). Free names: my-app-2, my-app-3`. `stack create`, `clone` and `template quick-deploy` print the same message for a name conflict. `--reset-ttl M` (deprecated) keeps the old behaviour: the TTL becomes M minutes and the expiry now + M minutes, which can be earlier. `--yes` has no effect and is accepted for older scripts. @@ -652,7 +673,7 @@ cli/ 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 ttl.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 diff --git a/cli/cmd/stack_action.go b/cli/cmd/stack_action.go new file mode 100644 index 0000000..1273a34 --- /dev/null +++ b/cli/cmd/stack_action.go @@ -0,0 +1,586 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "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" +) + +// Job log polling, as the web UI does it: a poll every 3 s, at once again +// after a truncated chunk, and a doubling backoff up to 30 s on 429, 502, +// 503, 504 and network errors (Retry-After when the server sends it). +const ( + jobPollInterval = 3 * time.Second + jobMaxBackoff = 30 * time.Second + jobMaxRetryAfter = 5 * time.Minute + jobDefaultWait = 60 * time.Minute + actionLogHintFmt = "stackctl stack action log %s %s %s --follow" +) + +// jobSleep waits between job log polls. Tests replace it. +var jobSleep = time.Sleep + +// jobSuccessStatuses are the final job statuses that count as success. +// Another final status (for example failed or recovery_required) gives a +// non-zero exit code. An empty status counts as success: the subscriber +// does not report one. +var jobSuccessStatuses = map[string]bool{ + "": true, "succeeded": true, "success": true, "successful": true, + "completed": true, "complete": true, "done": true, "ok": true, "finished": true, +} + +var stackActionCmd = &cobra.Command{ + Use: "action", + Short: "List, run and follow the custom actions of a stack", + Long: `The custom actions come from the hooks config of the server +(hooks.actions). An action calls a webhook of the operator, for example to +refresh a database. An asynchronous action returns a job ID, and the server +proxies its job log. + +Every user who can view a stack can list its actions. Only the owner of the +stack, a devops user and an admin can run an action and read a job log.`, +} + +var stackActionListCmd = &cobra.Command{ + Use: "list ", + Short: "List the custom actions of a stack", + Long: `List the custom actions of a stack with their parameters. + +PARAMETERS shows each declared parameter as name:type, with the options of +an enum, "*" for a required parameter and "=value" for a default. CAN RUN +says whether you can run the action on this stack. JOB LOG says whether the +server proxies the job log of the action. + +Examples: + stackctl stack action list my-stack + stackctl stack action list my-stack -o json`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + id, err := resolveStackID(c, args[0]) + if err != nil { + return err + } + list, err := c.ListStackActions(id) + if err != nil { + return err + } + + if printer.Quiet { + for _, a := range list.Actions { + fmt.Fprintln(printer.Writer, a.Name) + } + return nil + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(list) + case output.FormatYAML: + return printer.PrintYAML(list) + } + if len(list.Actions) == 0 { + printer.PrintMessage("The server has no custom actions.") + return nil + } + rows := make([][]string, len(list.Actions)) + for i, a := range list.Actions { + rows[i] = []string{ + client.SanitizeServerText(a.Name), + client.SanitizeServerText(a.Label), + yesNo(a.CanInvoke), + yesNo(a.HasJobLog), + client.SanitizeServerText(parameterSummary(a.Parameters)), + } + } + return printer.PrintTable([]string{"NAME", "LABEL", "CAN RUN", "JOB LOG", "PARAMETERS"}, rows) + }, +} + +// parameterSummary returns the parameters of an action for the table, for +// example "env:enum(se|dk)*=se, dry_run:bool". +func parameterSummary(params []types.ActionParameter) string { + if len(params) == 0 { + return "-" + } + parts := make([]string, len(params)) + for i, p := range params { + s := p.Name + ":" + p.Type + if p.Type == "enum" && len(p.Options) > 0 { + s = p.Name + ":enum(" + strings.Join(p.Options, "|") + ")" + } + if p.Required { + s += "*" + } + if p.Default != nil { + s += "=" + fmt.Sprint(p.Default) + } + parts[i] = s + } + return strings.Join(parts, ", ") +} + +var stackActionRunCmd = &cobra.Command{ + Use: "run ", + Short: "Run a custom action on a stack", + Long: `Run a custom action on a stack. + +--param key=value sets a parameter (repeat the flag for more parameters). +The command checks the declared parameters before the request: a bool takes +true or false, an enum takes one of its options, and a required parameter +must have a value. A declared parameter without --param gets its default. +Parameters that the action does not declare go to the subscriber as strings. + +When the action has a confirmation text, the command asks for confirmation. +--yes skips the question. + +The command prints the status of the subscriber and its result. A status of +400 or higher gives a non-zero exit code. + +An asynchronous action returns a job ID. --wait then follows the job log +until the job ends: it polls every 3 s and prints the new log text. The exit +code is non-zero when the job ends with a failed status, on 401, 403 or 404, +and after --timeout (default 60m). The job continues on the server after +the command stops. With -o json or -o yaml, the log text goes to stderr and +the result has job_status. + +Examples: + stackctl stack action list my-stack + stackctl stack action run my-stack refresh-db --yes + stackctl stack action run my-stack refresh-db --param market=dk --wait + stackctl stack action run my-stack restart --param dry_run=true -o json`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + rawParams, _ := cmd.Flags().GetStringArray("param") + wait, _ := cmd.Flags().GetBool("wait") + timeout, _ := cmd.Flags().GetDuration("timeout") + if timeout <= 0 { + return fmt.Errorf("--timeout must be a positive duration") + } + given, err := parseParamFlags(rawParams) + if err != nil { + return err + } + + c, err := newClient() + if err != nil { + return err + } + id, err := resolveStackID(c, args[0]) + if err != nil { + return err + } + list, err := c.ListStackActions(id) + if err != nil { + return err + } + action, err := findAction(list, args[1]) + if err != nil { + return err + } + if !action.CanInvoke { + return fmt.Errorf("you cannot run actions on stack %s: only the owner, a devops user and an admin can", args[0]) + } + params, err := buildActionParams(action.Parameters, given) + if err != nil { + return err + } + + if action.Confirm != "" { + ok, err := confirmAction(cmd, client.SanitizeServerText(action.Confirm)+" (y/n): ") + if err != nil { + return err + } + if !ok { + fmt.Fprintln(cmd.ErrOrStderr(), "Aborted.") + return nil + } + } + + res, err := c.RunStackAction(id, action.Name, params) + if err != nil { + return err + } + failed := actionFailed(res) + + if wait && res.JobID != "" && failed == nil { + // The job ID goes to stderr first: after Ctrl-C the user can + // still follow the job with "stack action log". + fmt.Fprintf(cmd.ErrOrStderr(), "Action %s: status %d. Job ID: %s. Following the job log; the job continues on the server if you stop the command.\n", + res.Action, res.StatusCode, res.JobID) + status, waitErr := followJobLog(cmd, c, id, action.Name, res.JobID, 0, timeout, printer.Format != output.FormatTable || printer.Quiet) + res.JobStatus = status + if waitErr == nil || status != "" { + if err := printActionResult(cmd, res, true); err != nil { + return err + } + } + return waitErr + } + + if err := printActionResult(cmd, res, false); err != nil { + return err + } + if failed != nil { + return failed + } + if wait && res.JobID == "" { + fmt.Fprintln(cmd.ErrOrStderr(), "Note: the action returned no job ID; there is no job log to follow.") + } + return nil + }, +} + +// parseParamFlags reads the --param key=value flags. +func parseParamFlags(raw []string) (map[string]string, error) { + out := map[string]string{} + for _, kv := range raw { + k, v, ok := strings.Cut(kv, "=") + k = strings.TrimSpace(k) + if !ok || k == "" { + return nil, fmt.Errorf("--param %q: use key=value", kv) + } + if _, dup := out[k]; dup { + return nil, fmt.Errorf("--param %s is set more than once", k) + } + out[k] = v + } + return out, nil +} + +// findAction returns the action with the given name. +func findAction(list *types.ActionList, name string) (types.ActionInfo, error) { + names := make([]string, 0, len(list.Actions)) + for _, a := range list.Actions { + if a.Name == name { + return a, nil + } + names = append(names, a.Name) + } + if len(names) == 0 { + return types.ActionInfo{}, fmt.Errorf("unknown action %q: the server has no custom actions", name) + } + return types.ActionInfo{}, fmt.Errorf("unknown action %q (actions: %s)", name, strings.Join(names, ", ")) +} + +// buildActionParams checks the given parameters against the declared ones +// and converts them to their types. A declared parameter that is not given +// gets its default. Undeclared parameters stay strings. +func buildActionParams(declared []types.ActionParameter, given map[string]string) (map[string]any, error) { + params := map[string]any{} + known := map[string]bool{} + for _, p := range declared { + known[p.Name] = true + raw, ok := given[p.Name] + if !ok { + if p.Default != nil { + params[p.Name] = p.Default + } else if p.Required { + return nil, fmt.Errorf("parameter %q is required: use --param %s=", p.Name, p.Name) + } + continue + } + switch p.Type { + case "bool": + b, err := strconv.ParseBool(raw) + if err != nil { + return nil, fmt.Errorf("parameter %q must be true or false, not %q", p.Name, raw) + } + params[p.Name] = b + case "enum": + if !containsString(p.Options, raw) { + return nil, fmt.Errorf("parameter %q must be one of %s, not %q", p.Name, strings.Join(p.Options, ", "), raw) + } + params[p.Name] = raw + default: + if p.Required && strings.TrimSpace(raw) == "" { + return nil, fmt.Errorf("parameter %q is required and cannot be empty", p.Name) + } + params[p.Name] = raw + } + } + extra := make([]string, 0, len(given)) + for k := range given { + if !known[k] { + extra = append(extra, k) + } + } + sort.Strings(extra) + for _, k := range extra { + params[k] = given[k] + } + return params, nil +} + +func containsString(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false +} + +// actionFailed returns an error when the subscriber answered with a status +// of 400 or higher. +func actionFailed(res *types.ActionResult) error { + if res.StatusCode < 400 { + return nil + } + msg := client.SanitizeServerText(res.Message) + if msg == "" { + msg = http.StatusText(res.StatusCode) + } + return fmt.Errorf("action %s failed: status %d: %s", res.Action, res.StatusCode, msg) +} + +// printActionResult prints the envelope of "stack action run". With +// waited, the job log is already printed (table mode). +func printActionResult(cmd *cobra.Command, res *types.ActionResult, waited bool) error { + if printer.Quiet { + if res.JobID != "" { + fmt.Fprintln(printer.Writer, res.JobID) + } + return nil + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(res) + case output.FormatYAML: + return printer.PrintYAML(res) + } + + if waited { + printer.PrintMessage("Job %s ended with status %s", res.JobID, statusText(res.JobStatus)) + return nil + } + printer.PrintMessage("Action %s: status %d", res.Action, res.StatusCode) + if msg := client.SanitizeServerText(res.Message); msg != "" { + printer.PrintMessage("Message: %s", msg) + } + if res.Result != nil { + var data bytes.Buffer + enc := json.NewEncoder(&data) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(res.Result); err != nil { + return err + } + printer.PrintMessage("Result:\n%s", strings.TrimRight(client.SanitizeLogText(data.String()), "\n")) + } + if res.JobID != "" { + printer.PrintMessage("Job ID: %s", res.JobID) + fmt.Fprintf(cmd.ErrOrStderr(), "Follow the job log: "+actionLogHintFmt+"\n", res.InstanceID, res.Action, res.JobID) + } + return nil +} + +// statusText returns the status, or "unknown" for an empty status. +func statusText(s string) string { + if s == "" { + return "unknown" + } + return s +} + +var stackActionLogCmd = &cobra.Command{ + Use: "log ", + Short: "Show the job log of an action", + Long: `Show the job log of an asynchronous action from --offset (bytes, default +0). The log text goes to stdout, the job status to stderr. + +--follow polls until the job ends, as "stack action run --wait" does. The +exit code is non-zero when the job ends with a failed status, on 401, 403 +or 404, and after --timeout (default 60m). + +-o json and -o yaml print one log chunk with status, next_offset, done and +truncated (not with --follow). + +Examples: + stackctl stack action log my-stack refresh-db job-1a2b3c + stackctl stack action log my-stack refresh-db job-1a2b3c --follow + stackctl stack action log my-stack refresh-db job-1a2b3c -o json`, + Args: cobra.ExactArgs(3), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + follow, _ := cmd.Flags().GetBool("follow") + offset, _ := cmd.Flags().GetInt64("offset") + timeout, _ := cmd.Flags().GetDuration("timeout") + if offset < 0 { + return fmt.Errorf("--offset must be 0 or more") + } + if timeout <= 0 { + return fmt.Errorf("--timeout must be a positive duration") + } + if follow && printer.Format != output.FormatTable { + return fmt.Errorf("--follow prints log text; it cannot be used with -o %s", printer.Format) + } + + c, err := newClient() + if err != nil { + return err + } + id, err := resolveStackID(c, args[0]) + if err != nil { + return err + } + action, jobID := args[1], args[2] + + if follow { + status, err := followJobLog(cmd, c, id, action, jobID, offset, timeout, false) + if err != nil { + return err + } + fmt.Fprintf(cmd.ErrOrStderr(), "Job %s ended with status %s\n", jobID, statusText(status)) + return nil + } + + chunk, err := c.GetActionJobLog(id, action, jobID, offset) + if err != nil { + return err + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(chunk) + case output.FormatYAML: + return printer.PrintYAML(chunk) + } + fmt.Fprint(printer.Writer, client.SanitizeLogText(chunk.Log)) + state := "running" + if chunk.Done { + state = "done" + } + fmt.Fprintf(cmd.ErrOrStderr(), "Status: %s (%s, next offset %d)\n", statusText(client.SanitizeServerText(chunk.Status)), state, chunk.NextOffset) + return nil + }, +} + +// followJobLog polls the job log from offset until the job is done and +// returns the final status. The log text goes to stdout, or to stderr when +// logToStderr is set (JSON, YAML and quiet output). A failed final status, +// 401, 403, 404 and the timeout give an error. +func followJobLog(cmd *cobra.Command, c *client.Client, id, action, jobID string, offset int64, timeout time.Duration, logToStderr bool) (string, error) { + logOut := printer.Writer + if logToStderr { + logOut = cmd.ErrOrStderr() + } + // The poll loop has its own backoff: no retry inside the client. + c.RetryBackoff = []time.Duration{} + deadline := nowFunc().Add(timeout) + backoff := time.Duration(0) + hint := fmt.Sprintf(actionLogHintFmt, id, action, jobID) + + for { + chunk, err := c.GetActionJobLog(id, action, jobID, offset) + if err != nil { + if !transientJobLogError(err) { + return "", fmt.Errorf("reading the job log: %w", err) + } + backoff = nextBackoff(backoff, err) + if !nowFunc().Add(backoff).Before(deadline) { + return "", fmt.Errorf("timed out after %s; the job continues on the server. Run: %s", timeout, hint) + } + fmt.Fprintf(cmd.ErrOrStderr(), "The job log is not available (%s). Next try in %s.\n", shortError(err), backoff) + jobSleep(backoff) + continue + } + backoff = 0 + if chunk.Log != "" { + fmt.Fprint(logOut, client.SanitizeLogText(chunk.Log)) + } + if chunk.NextOffset > offset { + offset = chunk.NextOffset + } + if chunk.Done { + status := client.SanitizeServerText(chunk.Status) + if !jobSuccessStatuses[strings.ToLower(status)] { + return status, fmt.Errorf("job %s ended with status %s", jobID, status) + } + return status, nil + } + if chunk.Truncated { + if !nowFunc().Before(deadline) { + return "", fmt.Errorf("timed out after %s; the job continues on the server. Run: %s", timeout, hint) + } + continue + } + if !nowFunc().Add(jobPollInterval).Before(deadline) { + return "", fmt.Errorf("timed out after %s; the job continues on the server. Run: %s", timeout, hint) + } + jobSleep(jobPollInterval) + } +} + +// nextBackoff returns the next wait after a transient error: Retry-After +// when the server sends it, else a doubling delay from 3 s, at most 30 s. +func nextBackoff(prev time.Duration, err error) time.Duration { + var apiErr *client.APIError + if errors.As(err, &apiErr) { + if ra := apiErr.RetryAfter(); ra > 0 { + return min(ra, jobMaxRetryAfter) + } + } + if prev == 0 { + return jobPollInterval + } + return min(prev*2, jobMaxBackoff) +} + +// transientJobLogError reports whether a job log error is worth a retry: +// 429, 502, 503 and 504, and network errors. Other API errors (401, 403, +// 404) and errors such as a response that is not JSON stop the poll. +func transientJobLogError(err error) bool { + var apiErr *client.APIError + if errors.As(err, &apiErr) { + switch apiErr.StatusCode { + case http.StatusTooManyRequests, http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout: + return true + } + return false + } + var netErr net.Error + var urlErr *url.Error + return errors.As(err, &netErr) || errors.As(err, &urlErr) +} + +// shortError returns the HTTP status of an API error, else the error text. +func shortError(err error) string { + var apiErr *client.APIError + if errors.As(err, &apiErr) { + return fmt.Sprintf("status %d", apiErr.StatusCode) + } + return client.SanitizeServerText(err.Error()) +} + +func init() { + stackActionRunCmd.Flags().StringArray("param", nil, "Parameter as key=value (repeat for more parameters)") + stackActionRunCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation question") + stackActionRunCmd.Flags().Bool("wait", false, "Follow the job log of an asynchronous action until the job ends") + stackActionRunCmd.Flags().Duration("timeout", jobDefaultWait, "Maximum time to follow the job log (with --wait)") + + stackActionLogCmd.Flags().BoolP("follow", "f", false, "Poll the job log until the job ends") + stackActionLogCmd.Flags().Int64("offset", 0, "Byte offset to read from") + stackActionLogCmd.Flags().Duration("timeout", jobDefaultWait, "Maximum time to follow the job log (with --follow)") + + stackActionCmd.AddCommand(stackActionListCmd) + stackActionCmd.AddCommand(stackActionRunCmd) + stackActionCmd.AddCommand(stackActionLogCmd) + stackCmd.AddCommand(stackActionCmd) +} diff --git a/cli/cmd/stack_action_test.go b/cli/cmd/stack_action_test.go new file mode 100644 index 0000000..cdf3534 --- /dev/null +++ b/cli/cmd/stack_action_test.go @@ -0,0 +1,445 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "github.com/omattsson/stackctl/cli/pkg/client" + "github.com/omattsson/stackctl/cli/pkg/output" + "github.com/omattsson/stackctl/cli/pkg/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const actionListBody = `{"instance_id":"42","can_invoke":true,"actions":[ + {"name":"refresh-db","label":"Refresh DB","confirm":"Replace the database?","has_job_log":true,"can_invoke":true, + "parameters":[{"name":"market","label":"Market","type":"enum","options":["se","dk"],"default":"se","required":true}, + {"name":"dry_run","label":"Dry run","type":"bool"}, + {"name":"note","label":"Note","type":"string","required":true}]}, + {"name":"ping","label":"Ping","has_job_log":false,"can_invoke":true,"parameters":[]}]}` + +// logStep is one answer of the job log route: a chunk or an error status. +type logStep struct { + status int + retryAfter string + raw string // a raw 200 body instead of chunk + chunk types.ActionJobLog +} + +// actionServer emulates the action routes of one stack (ID 42). +type actionServer struct { + list string + runCode int // status code of the subscriber in the envelope + runJobID string // job_id of the envelope + runMsg string + steps []logStep // answers of the job log route, in order; the last one repeats + runs []map[string]any + offsets []string + logCalls int +} + +func (as *actionServer) start(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/stack-instances/42/actions": + _, _ = io.WriteString(w, as.list) + case r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/stack-instances/42/actions/"): + body := map[string]any{} + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + as.runs = append(as.runs, body) + name := strings.TrimPrefix(r.URL.Path, "/api/v1/stack-instances/42/actions/") + code := as.runCode + if code == 0 { + code = http.StatusAccepted + } + res := types.ActionResult{Action: name, InstanceID: "42", StatusCode: code, Result: map[string]any{"ok": code < 400}, JobID: as.runJobID, Message: as.runMsg} + json.NewEncoder(w).Encode(res) + case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/log"): + as.offsets = append(as.offsets, r.URL.Query().Get("offset")) + i := as.logCalls + if i >= len(as.steps) { + i = len(as.steps) - 1 + } + as.logCalls++ + st := as.steps[i] + if st.raw != "" { + _, _ = io.WriteString(w, st.raw) + return + } + if st.status != 0 { + if st.retryAfter != "" { + w.Header().Set("Retry-After", st.retryAfter) + } + w.WriteHeader(st.status) + _, _ = io.WriteString(w, `{"error":"upstream"}`) + return + } + json.NewEncoder(w).Encode(st.chunk) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusTeapot) + } + })) + t.Cleanup(srv.Close) + return srv +} + +// fakeJobClock makes jobSleep advance nowFunc and records the waits. Each +// read of the clock also advances it by 100 ms, as time passes during the +// requests, so a loop without a sleep still reaches its deadline. +func fakeJobClock(t *testing.T) *[]time.Duration { + t.Helper() + now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC) + var waits []time.Duration + prevNow, prevSleep := nowFunc, jobSleep + nowFunc = func() time.Time { + now = now.Add(100 * time.Millisecond) + return now + } + jobSleep = func(d time.Duration) { + waits = append(waits, d) + now = now.Add(d) + } + t.Cleanup(func() { nowFunc, jobSleep = prevNow, prevSleep }) + return &waits +} + +func setupActionRun(t *testing.T, as *actionServer) (*bytes.Buffer, *bytes.Buffer) { + t.Helper() + srv := as.start(t) + buf := setupStackTestCmd(t, srv.URL) + stderr := stderrOf(t, stackActionRunCmd) + resetCmdFlags(t, stackActionRunCmd, "param", "yes", "wait", "timeout") + t.Cleanup(func() { stackActionRunCmd.SetIn(nil) }) + return buf, stderr +} + +func setParams(t *testing.T, params ...string) { + t.Helper() + for _, p := range params { + require.NoError(t, stackActionRunCmd.Flags().Set("param", p)) + } +} + +// ---------- stack action list ---------- + +func TestStackActionListCmd(t *testing.T) { + as := &actionServer{list: actionListBody} + srv := as.start(t) + + t.Run("table", func(t *testing.T) { + buf := setupStackTestCmd(t, srv.URL) + require.NoError(t, stackActionListCmd.RunE(stackActionListCmd, []string{"42"})) + out := buf.String() + assert.Regexp(t, `refresh-db\s+Refresh DB\s+yes\s+yes\s+market:enum\(se\|dk\)\*=se, dry_run:bool, note:string\*`, out) + assert.Regexp(t, `ping\s+Ping\s+yes\s+no\s+-`, out) + }) + t.Run("quiet", func(t *testing.T) { + buf := setupStackTestCmd(t, srv.URL) + printer.Quiet = true + require.NoError(t, stackActionListCmd.RunE(stackActionListCmd, []string{"42"})) + assert.Equal(t, "refresh-db\nping\n", buf.String()) + }) + t.Run("json", func(t *testing.T) { + buf := setupStackTestCmd(t, srv.URL) + printer.Format = output.FormatJSON + require.NoError(t, stackActionListCmd.RunE(stackActionListCmd, []string{"42"})) + var got types.ActionList + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + require.Len(t, got.Actions, 2) + assert.Equal(t, []string{"se", "dk"}, got.Actions[0].Parameters[0].Options) + }) + t.Run("no actions", func(t *testing.T) { + empty := &actionServer{list: `{"instance_id":"42","actions":[]}`} + buf := setupStackTestCmd(t, empty.start(t).URL) + require.NoError(t, stackActionListCmd.RunE(stackActionListCmd, []string{"42"})) + assert.Equal(t, "The server has no custom actions.\n", buf.String()) + }) +} + +// ---------- stack action run: parameters and confirmation ---------- + +func TestStackActionRunCmd_ParametersAreTyped(t *testing.T) { + as := &actionServer{list: actionListBody} + buf, stderr := setupActionRun(t, as) + require.NoError(t, stackActionRunCmd.Flags().Set("yes", "true")) + setParams(t, "dry_run=true", "note=hello", "extra=1") + + require.NoError(t, stackActionRunCmd.RunE(stackActionRunCmd, []string{"42", "refresh-db"})) + require.Len(t, as.runs, 1) + assert.Equal(t, map[string]any{"parameters": map[string]any{ + "market": "se", "dry_run": true, "note": "hello", "extra": "1", + }}, as.runs[0], "default, bool conversion and an undeclared string") + assert.Contains(t, buf.String(), "Action refresh-db: status 202") + assert.Empty(t, stderr.String()) +} + +func TestStackActionRunCmd_UsageErrors(t *testing.T) { + tests := []struct { + name string + action string + params []string + wantErr string + }{ + {"bad bool", "refresh-db", []string{"note=x", "dry_run=maybe"}, `parameter "dry_run" must be true or false, not "maybe"`}, + {"bad enum", "refresh-db", []string{"note=x", "market=no"}, `parameter "market" must be one of se, dk, not "no"`}, + {"required missing", "refresh-db", nil, `parameter "note" is required: use --param note=`}, + {"required empty", "refresh-db", []string{"note= "}, `parameter "note" is required and cannot be empty`}, + {"no equals sign", "refresh-db", []string{"note"}, `--param "note": use key=value`}, + {"duplicate", "refresh-db", []string{"note=a", "note=b"}, "--param note is set more than once"}, + {"unknown action", "nope", nil, `unknown action "nope" (actions: refresh-db, ping)`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + as := &actionServer{list: actionListBody} + _, _ = setupActionRun(t, as) + require.NoError(t, stackActionRunCmd.Flags().Set("yes", "true")) + setParams(t, tt.params...) + + err := stackActionRunCmd.RunE(stackActionRunCmd, []string{"42", tt.action}) + require.Error(t, err) + assert.Equal(t, tt.wantErr, err.Error()) + assert.Empty(t, as.runs, "no run request") + }) + } +} + +func TestStackActionRunCmd_CannotInvoke(t *testing.T) { + as := &actionServer{list: strings.ReplaceAll(actionListBody, `"can_invoke":true`, `"can_invoke":false`)} + _, _ = setupActionRun(t, as) + + err := stackActionRunCmd.RunE(stackActionRunCmd, []string{"42", "ping"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "only the owner, a devops user and an admin can") + assert.Empty(t, as.runs) +} + +func TestStackActionRunCmd_Confirmation(t *testing.T) { + tests := []struct { + name string + stdin string + wantRun bool + wantErr string + }{ + {"yes", "y\n", true, ""}, + {"no", "n\n", false, ""}, + {"no answer", "", false, "use --yes to confirm"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + as := &actionServer{list: actionListBody} + _, stderr := setupActionRun(t, as) + setParams(t, "note=x") + stackActionRunCmd.SetIn(strings.NewReader(tt.stdin)) + + err := stackActionRunCmd.RunE(stackActionRunCmd, []string{"42", "refresh-db"}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + } else { + require.NoError(t, err) + } + assert.Contains(t, stderr.String(), "Replace the database? (y/n): ") + assert.Equal(t, tt.wantRun, len(as.runs) == 1) + }) + } +} + +func TestStackActionRunCmd_NoConfirmationText(t *testing.T) { + as := &actionServer{list: actionListBody, runCode: http.StatusOK} + buf, _ := setupActionRun(t, as) + stackActionRunCmd.SetIn(strings.NewReader("")) // a prompt would fail + + require.NoError(t, stackActionRunCmd.RunE(stackActionRunCmd, []string{"42", "ping"})) + assert.Equal(t, map[string]any{}, as.runs[0], "no parameters: an empty body") + assert.Contains(t, buf.String(), "Action ping: status 200") +} + +func TestStackActionRunCmd_SubscriberRefusal(t *testing.T) { + as := &actionServer{list: actionListBody, runCode: http.StatusConflict, runMsg: "already in flight"} + buf, _ := setupActionRun(t, as) + + err := stackActionRunCmd.RunE(stackActionRunCmd, []string{"42", "ping"}) + require.Error(t, err) + assert.Equal(t, "action ping failed: status 409: already in flight", err.Error()) + assert.Contains(t, buf.String(), "Message: already in flight", "the result is shown before the error") +} + +func TestStackActionRunCmd_JobIDHint(t *testing.T) { + as := &actionServer{list: actionListBody, runJobID: "job-1"} + buf, stderr := setupActionRun(t, as) + require.NoError(t, stackActionRunCmd.Flags().Set("yes", "true")) + setParams(t, "note=x") + + require.NoError(t, stackActionRunCmd.RunE(stackActionRunCmd, []string{"42", "refresh-db"})) + assert.Contains(t, buf.String(), "Job ID: job-1") + assert.Equal(t, "Follow the job log: stackctl stack action log 42 refresh-db job-1 --follow\n", stderr.String()) + assert.Zero(t, as.logCalls, "no polling without --wait") +} + +// ---------- stack action run --wait ---------- + +func TestStackActionRunCmd_WaitFollowsTheLog(t *testing.T) { + waits := fakeJobClock(t) + as := &actionServer{list: actionListBody, runJobID: "job-1", steps: []logStep{ + {chunk: types.ActionJobLog{Status: "running", Log: "step 1\n", NextOffset: 7}}, + {status: http.StatusServiceUnavailable, retryAfter: "7"}, + {status: http.StatusBadGateway}, + {chunk: types.ActionJobLog{Status: "running", Log: "step\x1b[2J 2\r\n", NextOffset: 20, Truncated: true}}, + {chunk: types.ActionJobLog{Status: "succeeded", Log: "done\n", NextOffset: 25, Done: true}}, + }} + buf, stderr := setupActionRun(t, as) + require.NoError(t, stackActionRunCmd.Flags().Set("yes", "true")) + require.NoError(t, stackActionRunCmd.Flags().Set("wait", "true")) + setParams(t, "note=x") + + require.NoError(t, stackActionRunCmd.RunE(stackActionRunCmd, []string{"42", "refresh-db"})) + assert.Equal(t, "step 1\nstep [2J 2\ndone\nJob job-1 ended with status succeeded\n", buf.String()) + assert.Equal(t, "Action refresh-db: status 202. Job ID: job-1. Following the job log; the job continues on the server if you stop the command.\n"+ + "The job log is not available (status 503). Next try in 7s.\n"+ + "The job log is not available (status 502). Next try in 14s.\n", stderr.String()) + assert.Equal(t, []string{"0", "7", "7", "7", "20"}, as.offsets) + assert.Equal(t, []time.Duration{3 * time.Second, 7 * time.Second, 14 * time.Second}, *waits, + "poll interval, Retry-After, then double the last wait; no wait after a truncated chunk") +} + +func TestStackActionRunCmd_WaitFailures(t *testing.T) { + tests := []struct { + name string + steps []logStep + timeout string + wantErr string + }{ + {"failed status", []logStep{{chunk: types.ActionJobLog{Status: "failed", Done: true}}}, "60m", "job job-1 ended with status failed"}, + {"not found", []logStep{{status: http.StatusNotFound}}, "60m", "reading the job log: Resource not found"}, + {"forbidden", []logStep{{status: http.StatusForbidden}}, "60m", "Permission denied."}, + {"timeout", []logStep{{chunk: types.ActionJobLog{Status: "running"}}}, "10s", "timed out after 10s; the job continues on the server. Run: stackctl stack action log 42 refresh-db job-1 --follow"}, + {"timeout on errors", []logStep{{status: http.StatusBadGateway}}, "1m", "timed out after 1m0s"}, + {"timeout on truncated chunks", []logStep{{chunk: types.ActionJobLog{Status: "running", Log: "x", Truncated: true}}}, "5s", "timed out after 5s"}, + {"not JSON", []logStep{{raw: "proxy"}}, "60m", "reading the job log: decoding response"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _ = fakeJobClock(t) + as := &actionServer{list: actionListBody, runJobID: "job-1", steps: tt.steps} + buf, _ := setupActionRun(t, as) + require.NoError(t, stackActionRunCmd.Flags().Set("yes", "true")) + require.NoError(t, stackActionRunCmd.Flags().Set("wait", "true")) + require.NoError(t, stackActionRunCmd.Flags().Set("timeout", tt.timeout)) + setParams(t, "note=x") + + err := stackActionRunCmd.RunE(stackActionRunCmd, []string{"42", "refresh-db"}) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + if tt.name != "failed status" { + assert.NotContains(t, buf.String(), "ended with status", "the job did not end") + } + }) + } +} + +func TestStackActionRunCmd_WaitJSON(t *testing.T) { + _ = fakeJobClock(t) + as := &actionServer{list: actionListBody, runJobID: "job-1", steps: []logStep{ + {chunk: types.ActionJobLog{Status: "succeeded", Log: "all done\n", Done: true}}, + }} + buf, stderr := setupActionRun(t, as) + printer.Format = output.FormatJSON + require.NoError(t, stackActionRunCmd.Flags().Set("yes", "true")) + require.NoError(t, stackActionRunCmd.Flags().Set("wait", "true")) + setParams(t, "note=x") + + require.NoError(t, stackActionRunCmd.RunE(stackActionRunCmd, []string{"42", "refresh-db"})) + var got types.ActionResult + require.NoError(t, json.Unmarshal(buf.Bytes(), &got), "stdout is only the JSON result") + assert.Equal(t, "succeeded", got.JobStatus) + assert.Equal(t, "job-1", got.JobID) + assert.Contains(t, stderr.String(), "Job ID: job-1") + assert.True(t, strings.HasSuffix(stderr.String(), "all done\n"), "the log goes to stderr") +} + +// ---------- stack action log ---------- + +func setupActionLog(t *testing.T, as *actionServer) (*bytes.Buffer, *bytes.Buffer) { + t.Helper() + srv := as.start(t) + buf := setupStackTestCmd(t, srv.URL) + stderr := stderrOf(t, stackActionLogCmd) + resetCmdFlags(t, stackActionLogCmd, "follow", "offset", "timeout") + return buf, stderr +} + +func TestStackActionLogCmd_OneChunk(t *testing.T) { + as := &actionServer{steps: []logStep{{chunk: types.ActionJobLog{Status: "running", Log: "line\n", Offset: 5, NextOffset: 10}}}} + buf, stderr := setupActionLog(t, as) + require.NoError(t, stackActionLogCmd.Flags().Set("offset", "5")) + + require.NoError(t, stackActionLogCmd.RunE(stackActionLogCmd, []string{"42", "refresh-db", "job-1"})) + assert.Equal(t, "line\n", buf.String()) + assert.Equal(t, "Status: running (running, next offset 10)\n", stderr.String()) + assert.Equal(t, []string{"5"}, as.offsets) +} + +func TestStackActionLogCmd_Follow(t *testing.T) { + _ = fakeJobClock(t) + as := &actionServer{steps: []logStep{ + {chunk: types.ActionJobLog{Status: "running", Log: "a\n", NextOffset: 2}}, + {chunk: types.ActionJobLog{Status: "recovery_required", Log: "b\n", NextOffset: 4, Done: true}}, + }} + buf, _ := setupActionLog(t, as) + require.NoError(t, stackActionLogCmd.Flags().Set("follow", "true")) + + err := stackActionLogCmd.RunE(stackActionLogCmd, []string{"42", "refresh-db", "job-1"}) + require.Error(t, err) + assert.Equal(t, "job job-1 ended with status recovery_required", err.Error()) + assert.Equal(t, "a\nb\n", buf.String()) +} + +func TestStackActionLogCmd_FollowWithJSONIsAUsageError(t *testing.T) { + as := &actionServer{} + _, _ = setupActionLog(t, as) + printer.Format = output.FormatJSON + require.NoError(t, stackActionLogCmd.Flags().Set("follow", "true")) + + err := stackActionLogCmd.RunE(stackActionLogCmd, []string{"42", "refresh-db", "job-1"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot be used with -o json") + assert.Zero(t, as.logCalls) +} + +// ---------- helpers ---------- + +func TestNextBackoff(t *testing.T) { + plain := errors.New("connection refused") + assert.Equal(t, 3*time.Second, nextBackoff(0, plain)) + assert.Equal(t, 6*time.Second, nextBackoff(3*time.Second, plain)) + assert.Equal(t, 30*time.Second, nextBackoff(24*time.Second, plain)) + assert.Equal(t, 30*time.Second, nextBackoff(30*time.Second, plain)) +} + +func TestTransientJobLogError(t *testing.T) { + assert.True(t, transientJobLogError(fmt.Errorf("making request: %w", &url.Error{Op: "Get", URL: "x", Err: errors.New("refused")}))) + assert.True(t, transientJobLogError(&client.APIError{StatusCode: http.StatusGatewayTimeout})) + assert.False(t, transientJobLogError(&client.APIError{StatusCode: http.StatusNotFound})) + assert.False(t, transientJobLogError(errors.New("decoding response: invalid character"))) +} + +func TestParameterSummary(t *testing.T) { + assert.Equal(t, "-", parameterSummary(nil)) + assert.Equal(t, "a:string*, b:enum(x|y)=x, c:bool=false", parameterSummary([]types.ActionParameter{ + {Name: "a", Type: "string", Required: true}, + {Name: "b", Type: "enum", Options: []string{"x", "y"}, Default: "x"}, + {Name: "c", Type: "bool", Default: false}, + })) +} diff --git a/cli/cmd/stack_expiry_test.go b/cli/cmd/stack_expiry_test.go index 0ae7ed1..be7ece4 100644 --- a/cli/cmd/stack_expiry_test.go +++ b/cli/cmd/stack_expiry_test.go @@ -100,6 +100,12 @@ func resetCmdFlags(t *testing.T, cmd *cobra.Command, names ...string) { } reset := func() { cmd.Flags().VisitAll(func(f *pflag.Flag) { + // Set appends to a slice or array flag: replace its value. + if sv, ok := f.Value.(pflag.SliceValue); ok { + require.NoError(t, sv.Replace(nil)) + f.Changed = false + return + } resetFlag(t, cmd.Flags(), f.Name, f.DefValue) }) } diff --git a/cli/pkg/client/auth.go b/cli/pkg/client/auth.go index 0737544..b1e1ca8 100644 --- a/cli/pkg/client/auth.go +++ b/cli/pkg/client/auth.go @@ -156,6 +156,10 @@ func isSessionPath(path string) bool { // another process replaces the file on Windows) is not an error: the request // continues, and a 401 starts a renewal that reads the file under the lock. func (c *Client) renewIfExpiring() error { + // Read the current token before the file. In the other order, another + // goroutine of this client can renew between the two reads: the file + // is then old, the token new, and renew(new) sends a second refresh. + current := c.currentToken() st, err := c.loadSession() if err != nil { c.debugf("✗ reading token file: %v\n", err) @@ -165,7 +169,6 @@ func (c *Client) renewIfExpiring() error { return nil // nothing to renew } - current := c.currentToken() if !expiresWithin(st.ExpiresAt, RenewMargin) { // Another process can have renewed the session since this client // loaded its token. diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index 83ac305..bbd8ebc 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -86,6 +86,11 @@ func (e *APIError) Error() string { return e.UserFacingError() } +// RetryAfter returns the Retry-After of the response (0 when not set). +func (e *APIError) RetryAfter() time.Duration { + return e.retryAfter +} + // UserFacingError returns a user-friendly error message based on the status code. // When the server provides a message, it is appended for context. func (e *APIError) UserFacingError() string { @@ -146,6 +151,27 @@ func SanitizeServerText(msg string) string { return sanitizeServerMessage(msg) } +// SanitizeLogText makes multi-line log text from a server or a webhook +// safe for a terminal. It keeps line feeds and tabs, removes carriage +// returns and bidi controls, and replaces other control characters (C0, +// DEL, C1, for example ESC) with a space. It does not trim or shorten. +func SanitizeLogText(text string) string { + var b strings.Builder + b.Grow(len(text)) + for _, r := range text { + switch { + case r == '\n' || r == '\t': + b.WriteRune(r) + case r == '\r' || isBidiControl(r): + case unicode.IsControl(r): + b.WriteByte(' ') + default: + b.WriteRune(r) + } + } + return b.String() +} + // sanitizeServerMessage cleans up a server error message for safe display. // It trims whitespace, replaces control characters, collapses runs of // whitespace, and truncates to maxServerMessageLen runes (with "..." appended @@ -624,6 +650,42 @@ func (c *Client) ListStacks(params map[string]string) (*types.ListResponse[types return &resp, nil } +// ListStackActions returns the custom actions that can run on a stack +// instance (GET /api/v1/stack-instances/:id/actions). +func (c *Client) ListStackActions(id string) (*types.ActionList, error) { + var list types.ActionList + if err := c.Get(fmt.Sprintf("/api/v1/stack-instances/%s/actions", id), &list); err != nil { + return nil, err + } + return &list, nil +} + +// RunStackAction runs a custom action on a stack instance. The server +// checks the declared parameters. A 200 answer can carry a subscriber error +// (ActionResult.StatusCode >= 400). +func (c *Client) RunStackAction(id, name string, params map[string]any) (*types.ActionResult, error) { + body := map[string]any{} + if len(params) > 0 { + body["parameters"] = params + } + var res types.ActionResult + if err := c.Post(fmt.Sprintf("/api/v1/stack-instances/%s/actions/%s", id, url.PathEscape(name)), body, &res); err != nil { + return nil, err + } + return &res, nil +} + +// GetActionJobLog returns the job log of an action from offset (GET +// /api/v1/stack-instances/:id/actions/:name/jobs/:job_id/log). +func (c *Client) GetActionJobLog(id, name, jobID string, offset int64) (*types.ActionJobLog, error) { + var chunk types.ActionJobLog + path := fmt.Sprintf("/api/v1/stack-instances/%s/actions/%s/jobs/%s/log", id, url.PathEscape(name), url.PathEscape(jobID)) + if err := c.GetWithQuery(path, map[string]string{"offset": strconv.FormatInt(offset, 10)}, &chunk); err != nil { + return nil, err + } + return &chunk, nil +} + // FollowStack makes the caller follow a stack instance (idempotent). func (c *Client) FollowStack(id string) (*types.FollowState, error) { var state types.FollowState diff --git a/cli/pkg/client/stack_inspect_test.go b/cli/pkg/client/stack_inspect_test.go index a59b2ca..a8bf573 100644 --- a/cli/pkg/client/stack_inspect_test.go +++ b/cli/pkg/client/stack_inspect_test.go @@ -62,3 +62,41 @@ func TestGetStackPodsAndRecent(t *testing.T) { require.NoError(t, err) assert.Len(t, items, 2) } + +func TestSanitizeLogText(t *testing.T) { + t.Parallel() + assert.Equal(t, "a\tb\nc [2J d\n", SanitizeLogText("a\tb\r\nc\x1b[2J d\u202e\n")) +} + +func TestRunStackActionAndJobLog(t *testing.T) { + t.Parallel() + var gotBody, gotPath, gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + gotBody, gotPath, gotQuery = string(data), r.URL.EscapedPath(), r.URL.RawQuery + if r.Method == http.MethodPost { + _, _ = io.WriteString(w, `{"action":"a b","status_code":202,"job_id":"j1","result":{"x":1}}`) + return + } + _, _ = io.WriteString(w, `{"log":"hi","next_offset":2,"done":true,"status":"succeeded"}`) + })) + t.Cleanup(srv.Close) + c := New(srv.URL) + + res, err := c.RunStackAction("42", "a b", nil) + require.NoError(t, err) + assert.Equal(t, "/api/v1/stack-instances/42/actions/a%20b", gotPath) + assert.JSONEq(t, `{}`, gotBody, "no parameters: an empty object") + assert.Equal(t, "j1", res.JobID) + assert.Equal(t, map[string]any{"x": float64(1)}, res.Result) + + _, err = c.RunStackAction("42", "a", map[string]any{"k": true}) + require.NoError(t, err) + assert.JSONEq(t, `{"parameters":{"k":true}}`, gotBody) + + chunk, err := c.GetActionJobLog("42", "a", "j.1", 7) + require.NoError(t, err) + assert.Equal(t, "/api/v1/stack-instances/42/actions/a/jobs/j.1/log", gotPath) + assert.Equal(t, "offset=7", gotQuery) + assert.True(t, chunk.Done) +} diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index c7f0446..081c032 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -50,6 +50,68 @@ type StackInstance struct { FollowerCount *int64 `json:"follower_count,omitempty" yaml:"follower_count,omitempty"` } +// ActionList is the response of GET /api/v1/stack-instances/:id/actions: +// the custom actions of the hooks config, without URLs or secrets. +type ActionList struct { + InstanceID string `json:"instance_id" yaml:"instance_id"` + Actions []ActionInfo `json:"actions" yaml:"actions"` + CanInvoke bool `json:"can_invoke" yaml:"can_invoke"` +} + +// ActionInfo is one custom action with its UI metadata. +type ActionInfo struct { + Name string `json:"name" yaml:"name"` + Label string `json:"label" yaml:"label"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Confirm string `json:"confirm,omitempty" yaml:"confirm,omitempty"` + Parameters []ActionParameter `json:"parameters" yaml:"parameters"` + HasJobLog bool `json:"has_job_log" yaml:"has_job_log"` + CanInvoke bool `json:"can_invoke" yaml:"can_invoke"` +} + +// ActionParameter is one declared parameter of an action. Type is +// "string", "bool" or "enum". +type ActionParameter struct { + Default any `json:"default,omitempty" yaml:"default,omitempty"` + Name string `json:"name" yaml:"name"` + Label string `json:"label" yaml:"label"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Type string `json:"type" yaml:"type"` + Options []string `json:"options,omitempty" yaml:"options,omitempty"` + Required bool `json:"required" yaml:"required"` +} + +// ActionResult is the envelope of POST /api/v1/stack-instances/:id/actions/:name. +// The server answers 200 also when the subscriber answered with an error: +// StatusCode is the status of the subscriber, and Message its reason for a +// status of 400 or higher. JobID is set for an asynchronous action with a +// job log. +type ActionResult struct { + Action string `json:"action" yaml:"action"` + InstanceID string `json:"instance_id" yaml:"instance_id"` + StatusCode int `json:"status_code" yaml:"status_code"` + Result any `json:"result" yaml:"result"` + JobID string `json:"job_id,omitempty" yaml:"job_id,omitempty"` + Message string `json:"message,omitempty" yaml:"message,omitempty"` + // JobStatus is not sent by the server. "stack action run --wait" sets + // it to the final status of the job. + JobStatus string `json:"job_status,omitempty" yaml:"job_status,omitempty"` +} + +// ActionJobLog is one chunk of a job log (GET +// /api/v1/stack-instances/:id/actions/:name/jobs/:job_id/log). +type ActionJobLog struct { + Action string `json:"action" yaml:"action"` + InstanceID string `json:"instance_id" yaml:"instance_id"` + JobID string `json:"job_id" yaml:"job_id"` + Status string `json:"status" yaml:"status"` + Log string `json:"log" yaml:"log"` + Offset int64 `json:"offset" yaml:"offset"` + NextOffset int64 `json:"next_offset" yaml:"next_offset"` + Done bool `json:"done" yaml:"done"` + Truncated bool `json:"truncated" yaml:"truncated"` +} + // FollowState is the response of POST and DELETE // /api/v1/stack-instances/:id/follow. type FollowState struct {