diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e5fddde..7484c2d 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -34,7 +34,7 @@ stackctl is a Go CLI tool (Cobra) for managing Kubernetes stack deployments. It | `audit` | log, list, export | audit.go | | `cleanup-policy` | list, get, create, update, delete, run | cleanup_policy.go | | `favorite` | list, add, remove | favorite.go | -| `notification` | list, count, read, read-all, prefs (get/set) | notification.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 | | `completion` | bash, zsh, fish, powershell | completion.go | diff --git a/CLAUDE.md b/CLAUDE.md index 38fb1e8..c4780cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ cli/ # Go module root (github.com/omattsson/stackctl/cl cleanup_policy.go # cleanup-policy list/get/create/update/delete/run favorite.go # favorite list/add/remove 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 user.go # user list/delete/disable/enable/reset-password/set-role values.go # --set value parsing helpers (key paths, scalar types) for stack and override commands @@ -186,6 +187,7 @@ 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 +- `/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 - `POST /:id/charts` and `DELETE /:id/charts/:chartId` (devops or admin; `definition add-chart`, `remove-chart`). A linked definition cannot lose a chart that the template requires (409 "Cannot delete required chart: X"). diff --git a/README.md b/README.md index e40f529..08f30a8 100644 --- a/README.md +++ b/README.md @@ -551,6 +551,35 @@ stackctl user set-role alice devops - a change when you are no longer an enabled admin (403); - removing the admin role from the last enabled admin (409). +### Notification Channels + +Outbound webhooks for stack events. Needs the devops or admin role. The commands take a channel name or ID. + +```bash +# The event types, then a channel for failed deploys of PR stacks, in Teams format +stackctl notification channel events +# --url-env reads the URL from an environment variable: it stays out of the shell history +stackctl notification channel create --name pr-alerts --url-env TEAMS_WORKFLOW_URL --format teams \ + --filter-name 'pr-*' --event deployment.failed + +# Sign the requests (X-StackManager-Signature): the secret comes from an environment variable or stdin +STACKCTL_CHANNEL_SECRET=... stackctl notification channel update pr-alerts --secret-env STACKCTL_CHANNEL_SECRET + +stackctl notification channel list +stackctl notification channel subscribe pr-alerts --event deployment.failed --event deployment.success +stackctl notification channel test pr-alerts +stackctl notification channel logs pr-alerts --limit 50 +stackctl notification channel update pr-alerts --disable +stackctl notification channel delete pr-alerts +``` + +- The webhook URL can hold a token (for example the signature of a Teams workflow URL). The commands show only the scheme and the host, also with `-o json` and `-o yaml`. `--show-url` shows the full URL. Give the URL with `--url-env NAME` to keep it out of the shell history and the process list. +- The secret is never a flag value: use `--secret-env NAME` or `--secret-stdin` (hidden input on a terminal). The server does not return it. `update --clear-secret` removes it. +- `create --event` checks the event types before the channel is created. When the subscription still fails, the channel exists: the error gives the `subscribe` command. +- `update` changes only the given flags. A `--filter-*` flag replaces all filters; `--clear-filters` removes them. `subscribe` replaces all subscriptions; `--none` removes them. +- `create` and `update` write the server warnings to stderr, for example a filter ID that does not exist. +- `test` exits with code 1 when the webhook does not accept the test event. + ### Scripting Examples ```bash @@ -598,7 +627,7 @@ These groups have no section above. Run `stackctl --help` for the flags. - `analytics`: `overview`, `templates`, `users` - `cleanup-policy`: `list`, `get`, `create`, `update`, `delete`, `run` - `favorite` (aliases `favorites`, `fav`): `list`, `add`, `remove` -- `notification` (alias `notifications`): `list`, `count`, `read`, `read-all`, `prefs get`, `prefs set` +- `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` - `git`: `providers`, `branches`, `validate` - `template`: also `clone`, `unpublish`, `versions list|get|diff` @@ -694,7 +723,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 + analytics.go cleanup_policy.go favorite.go notification.go notification_channel.go chart_changes.go testdata/ # golden files pkg/ client/ # HTTP client, auth renewal, WebSocket diff --git a/cli/cmd/notification_channel.go b/cli/cmd/notification_channel.go new file mode 100644 index 0000000..9942d02 --- /dev/null +++ b/cli/cmd/notification_channel.go @@ -0,0 +1,818 @@ +package cmd + +import ( + "fmt" + "net/url" + "os" + "path" + "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" + "golang.org/x/term" +) + +// Notification channels: outbound webhooks for stack events (devops or +// admin). The webhook URL can hold a token, so the command masks it in +// every output mode unless --show-url is set. The secret goes to the +// server only; it is read from an environment variable or stdin, never from +// a flag value. + +var notificationChannelCmd = &cobra.Command{ + Use: "channel", + Aliases: []string{"channels"}, + Short: "Manage the outbound webhook channels (devops or admin)", + Long: `Manage the outbound webhook channels of the server. A channel sends the +events that it subscribes to (for example deployment.success) to a webhook +URL, as generic JSON or as a Microsoft Teams Adaptive Card ("teams"). + +Filters limit a channel to some stack instances: instance name globs +(path.Match syntax; the match ignores case), owner IDs, definitions and +cluster IDs. +Each set filter must match; one value of a filter is enough. + +The webhook URL can hold a token (for example the signature of a Teams +workflow URL). The commands show only the scheme and the host of the URL, +also with -o json and -o yaml. --show-url shows the full URL. To keep the +URL out of the shell history and the process list, give it with --url-env +NAME (read from that environment variable) instead of --url.`, +} + +// channelOutputFlags adds --show-url to a command that prints a channel. +func channelOutputFlags(cmd *cobra.Command) { + cmd.Flags().Bool("show-url", false, "Show the full webhook URL (it can hold a token)") +} + +// maskWebhookURL returns the scheme and the host (with the port) of a +// webhook URL, and "/..." when the URL has a path, a query or a fragment. +// User info is dropped. A URL that does not parse becomes "". +func maskWebhookURL(raw string) string { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Scheme == "" || u.Host == "" { + if raw == "" { + return "" + } + return "" + } + out := u.Scheme + "://" + u.Host + if (u.Path != "" && u.Path != "/") || u.RawQuery != "" || u.Fragment != "" || u.ForceQuery { + out += "/..." + } + return out +} + +// shownChannel returns a copy of ch with the webhook URL masked unless +// --show-url is set. +func shownChannel(cmd *cobra.Command, ch types.NotificationChannel) types.NotificationChannel { + if show, _ := cmd.Flags().GetBool("show-url"); !show { + ch.WebhookURL = maskWebhookURL(ch.WebhookURL) + } + return ch +} + +// resolveChannelID returns the ID of the channel with the name or ID. +func resolveChannelID(c *client.Client, nameOrID string) (string, error) { + nameOrID = strings.TrimSpace(nameOrID) + if nameOrID == "" { + return "", fmt.Errorf("channel name or ID must not be empty") + } + if looksLikeID(nameOrID) { + return nameOrID, nil + } + channels, err := c.ListNotificationChannels() + if err != nil { + return "", fmt.Errorf("resolving channel %q: %w", nameOrID, err) + } + for _, ch := range channels { + if ch.Name == nameOrID { + return ch.ID, nil + } + } + return "", fmt.Errorf("notification channel %q not found", nameOrID) +} + +// filterSummary returns the filters of a channel for a table. +func filterSummary(f types.NotificationChannelFilters) string { + var parts []string + add := func(label string, values []string) { + if len(values) > 0 { + parts = append(parts, label+"="+strings.Join(values, "|")) + } + } + add("name", f.InstanceNamePatterns) + add("owner", f.OwnerIDs) + add("definition", f.DefinitionIDs) + add("cluster", f.ClusterIDs) + if len(parts) == 0 { + return "all instances" + } + return client.SanitizeServerText(strings.Join(parts, ", ")) +} + +// printChannel prints one channel. events is added to the table when it +// is not nil. +func printChannel(cmd *cobra.Command, ch types.NotificationChannel, events []string) error { + ch = shownChannel(cmd, ch) + for _, w := range ch.Warnings { + printServerWarning(cmd, w) + } + if printer.Quiet { + fmt.Fprintln(printer.Writer, ch.ID) + return nil + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(ch) + case output.FormatYAML: + return printer.PrintYAML(ch) + } + fields := []output.KeyValue{ + {Key: "ID", Value: ch.ID}, + {Key: "Name", Value: client.SanitizeServerText(ch.Name)}, + {Key: "Webhook URL", Value: client.SanitizeServerText(ch.WebhookURL)}, + {Key: "Format", Value: client.SanitizeServerText(ch.Format)}, + {Key: "Enabled", Value: yesNo(ch.Enabled)}, + {Key: "Filters", Value: filterSummary(ch.Filters)}, + } + if events != nil { + ev := strings.Join(events, ", ") + if ev == "" { + ev = "none" + } + fields = append(fields, output.KeyValue{Key: "Events", Value: client.SanitizeServerText(ev)}) + } + return printer.PrintSingle(ch, fields) +} + +var notificationChannelListCmd = &cobra.Command{ + Use: "list", + Short: "List the notification channels", + Long: `List the notification channels with their format, state, number of +subscribed events and filters. The webhook URL shows only the scheme and the +host unless --show-url is set. + +Examples: + stackctl notification channel list + stackctl notification channel list -o json`, + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + channels, err := c.ListNotificationChannels() + if err != nil { + return err + } + for i := range channels { + channels[i] = shownChannel(cmd, channels[i]) + } + if printer.Quiet { + for _, ch := range channels { + fmt.Fprintln(printer.Writer, ch.ID) + } + return nil + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(channels) + case output.FormatYAML: + return printer.PrintYAML(channels) + } + if len(channels) == 0 { + printer.PrintMessage("No notification channels.") + return nil + } + rows := make([][]string, len(channels)) + for i, ch := range channels { + count := "-" + if ch.SubscriptionCount != nil { + count = strconv.Itoa(*ch.SubscriptionCount) + } + rows[i] = []string{ + ch.ID, client.SanitizeServerText(ch.Name), client.SanitizeServerText(ch.Format), yesNo(ch.Enabled), count, + filterSummary(ch.Filters), client.SanitizeServerText(ch.WebhookURL), + } + } + return printer.PrintTable([]string{"ID", "NAME", "FORMAT", "ENABLED", "EVENTS", "FILTERS", "WEBHOOK URL"}, rows) + }, +} + +var notificationChannelGetCmd = &cobra.Command{ + Use: "get ", + Short: "Show a notification channel", + Long: `Show a notification channel (name or ID). The table also lists the +subscribed events. + +Examples: + stackctl notification channel get team-alerts + stackctl notification channel get team-alerts --show-url`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + id, err := resolveChannelID(c, args[0]) + if err != nil { + return err + } + ch, err := c.GetNotificationChannel(id) + if err != nil { + return err + } + var events []string + if printer.Format == output.FormatTable && !printer.Quiet { + if events, err = c.GetChannelSubscriptions(id); err != nil { + return err + } + if events == nil { + events = []string{} + } + } + return printChannel(cmd, *ch, events) + }, +} + +// channelFlags registers the flags of channel create and update. +func channelFlags(cmd *cobra.Command, update bool) { + cmd.Flags().String("name", "", "Channel name") + cmd.Flags().String("url", "", "Webhook URL (https:// only)") + cmd.Flags().String("url-env", "", "Read the webhook URL from this environment variable (keeps it out of the shell history)") + cmd.Flags().String("format", "", "Body format: generic (the event JSON) or teams (Teams Adaptive Card)") + cmd.Flags().String("secret-env", "", "Read the HMAC secret from this environment variable (signs the requests)") + cmd.Flags().Bool("secret-stdin", false, "Read the HMAC secret from stdin") + cmd.Flags().StringArray("filter-name", nil, "Instance name glob, for example 'pr-*' (repeat the flag for more)") + cmd.Flags().StringArray("filter-owner", nil, "Owner user ID (repeat the flag for more)") + cmd.Flags().StringArray("filter-definition", nil, "Stack definition name or ID (repeat the flag for more)") + cmd.Flags().StringArray("filter-cluster", nil, "Cluster ID (repeat the flag for more)") + if update { + cmd.Flags().Bool("clear-secret", false, "Remove the secret (the requests are not signed)") + cmd.Flags().Bool("clear-filters", false, "Remove all filters (the channel gets the events of all instances)") + cmd.Flags().Bool("enable", false, "Enable the channel") + cmd.Flags().Bool("disable", false, "Disable the channel") + } else { + cmd.Flags().Bool("disabled", false, "Create the channel disabled") + cmd.Flags().StringArray("event", nil, "Subscribe to this event type (repeat the flag for more; see 'notification channel events')") + } + channelOutputFlags(cmd) +} + +// readSecret returns the secret of --secret-env or --secret-stdin. set is +// false when neither flag is given. +func readSecret(cmd *cobra.Command) (secret string, set bool, err error) { + env, _ := cmd.Flags().GetString("secret-env") + stdin, _ := cmd.Flags().GetBool("secret-stdin") + switch { + case env != "" && stdin: + return "", false, fmt.Errorf("--secret-env and --secret-stdin cannot be used together") + case env != "": + v, ok := os.LookupEnv(env) + if !ok || v == "" { + return "", false, fmt.Errorf("--secret-env: the environment variable %s is not set or empty", env) + } + return v, true, nil + case stdin: + // On a terminal the input is hidden; from a pipe the first line counts. + f, isFile := cmd.InOrStdin().(*os.File) + line, err := readPassword(cmd, "Channel secret: ", !(isFile && term.IsTerminal(int(f.Fd())))) + if err != nil { + return "", false, fmt.Errorf("reading the secret from stdin: %w", err) + } + if line == "" { + return "", false, fmt.Errorf("--secret-stdin: stdin has no secret") + } + return line, true, nil + } + return "", false, nil +} + +// readFilterFlags returns the filters of the --filter-* flags, or nil +// when none is given. It checks the globs and resolves definition names. +func readFilterFlags(cmd *cobra.Command, c *client.Client) (*types.NotificationChannelFilters, error) { + names, _ := cmd.Flags().GetStringArray("filter-name") + owners, _ := cmd.Flags().GetStringArray("filter-owner") + defs, _ := cmd.Flags().GetStringArray("filter-definition") + clusters, _ := cmd.Flags().GetStringArray("filter-cluster") + if len(names)+len(owners)+len(defs)+len(clusters) == 0 { + return nil, nil + } + for _, p := range names { + if _, err := path.Match(p, ""); err != nil { + return nil, fmt.Errorf("--filter-name %q is not a valid glob: %w", p, err) + } + } + f := &types.NotificationChannelFilters{InstanceNamePatterns: names, OwnerIDs: owners, ClusterIDs: clusters} + for _, d := range defs { + id, err := resolveDefinitionID(c, d) + if err != nil { + return nil, err + } + f.DefinitionIDs = append(f.DefinitionIDs, id) + } + return f, nil +} + +// readWebhookURL returns the webhook URL of --url or --url-env, checked +// before any request ("" when neither flag is given). +func readWebhookURL(cmd *cobra.Command) (string, error) { + raw, _ := cmd.Flags().GetString("url") + env, _ := cmd.Flags().GetString("url-env") + switch { + case raw != "" && env != "": + return "", fmt.Errorf("--url and --url-env cannot be used together") + case env != "": + v, ok := os.LookupEnv(env) + if !ok || strings.TrimSpace(v) == "" { + return "", fmt.Errorf("--url-env: the environment variable %s is not set or empty", env) + } + raw = v + } + raw = strings.TrimSpace(raw) + if raw == "" { + return "", nil + } + // The error texts never contain the URL: it can hold a token. + if !strings.HasPrefix(raw, "https://") { + return "", fmt.Errorf("the webhook URL must start with https://") + } + if u, err := url.Parse(raw); err != nil || u.Host == "" { + return "", fmt.Errorf("the webhook URL is not a valid URL") + } + return raw, nil +} + +// checkChannelFormat checks --format before any request. +func checkChannelFormat(cmd *cobra.Command) error { + if !cmd.Flags().Changed("format") { + return nil + } + if f, _ := cmd.Flags().GetString("format"); f != "generic" && f != "teams" { + return fmt.Errorf("--format must be generic or teams, not %q", f) + } + return nil +} + +// checkEventTypes refuses event types that the server does not know, +// before a channel is created. When the list cannot be read, the server +// checks the types at the subscription. +func checkEventTypes(c *client.Client, events []string) error { + if len(events) == 0 { + return nil + } + known, err := c.ListChannelEventTypes() + if err != nil || len(known) == 0 { + return nil + } + for _, e := range events { + if !containsString(known, e) { + return fmt.Errorf("unknown event type %q (see 'stackctl notification channel events')", e) + } + } + return nil +} + +var notificationChannelCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a notification channel", + Long: `Create a notification channel. --event subscribes the channel to event +types in the same step (see "notification channel events"). + +The HMAC secret signs each request (X-StackManager-Signature). Give it with +--secret-env NAME (read from that environment variable) or --secret-stdin, +never as a flag value. The server does not return the secret. + +The server saves a filter ID that does not exist, and the command writes +its warning to stderr. + +Examples: + stackctl notification channel create --name team-alerts --url https://hooks.example.com/abc --event deployment.failed + STACKCTL_CHANNEL_SECRET=... stackctl notification channel create --name ci --url https://ci.example.com/hook --secret-env STACKCTL_CHANNEL_SECRET + stackctl notification channel create --name pr-stacks --url https://example.webhook.office.com/... --format teams --filter-name 'pr-*'`, + Args: cobra.NoArgs, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + name, _ := cmd.Flags().GetString("name") + format, _ := cmd.Flags().GetString("format") + disabled, _ := cmd.Flags().GetBool("disabled") + events, _ := cmd.Flags().GetStringArray("event") + webhook, err := readWebhookURL(cmd) + if err != nil { + return err + } + if strings.TrimSpace(name) == "" || webhook == "" { + return fmt.Errorf("--name and --url (or --url-env) are required") + } + if err := checkChannelFormat(cmd); err != nil { + return err + } + secret, _, err := readSecret(cmd) + if err != nil { + return err + } + + c, err := newClient() + if err != nil { + return err + } + filters, err := readFilterFlags(cmd, c) + if err != nil { + return err + } + if err := checkEventTypes(c, events); err != nil { + return err + } + enabled := !disabled + ch, err := c.CreateNotificationChannel(&types.CreateNotificationChannelRequest{ + Name: name, WebhookURL: webhook, Secret: secret, + Enabled: &enabled, Format: format, Filters: filters, + }) + if err != nil { + return err + } + var subscribed []string + if len(events) > 0 { + if subscribed, err = c.SetChannelSubscriptions(ch.ID, events); err != nil { + // The channel exists: show it, then say how to subscribe. + if perr := printChannel(cmd, *ch, nil); perr != nil { + return perr + } + return fmt.Errorf("the channel %s is created, but the subscription failed: %w. Run: stackctl notification channel subscribe %s --event ", + ch.ID, err, ch.ID) + } + } + return printChannel(cmd, *ch, subscribed) + }, +} + +var notificationChannelUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Change a notification channel", + Long: `Change a notification channel (name or ID). Only the given flags change. + +A --filter-* flag replaces all filters of the channel with the given +filters. --clear-filters removes all filters. --secret-env or --secret-stdin +sets a new secret; --clear-secret removes it. + +Examples: + stackctl notification channel update team-alerts --disable + stackctl notification channel update team-alerts --format teams --filter-cluster 5b6c7d8e-9f0a-4b1c-8d2e-3f4a5b6c7d8e + stackctl notification channel update team-alerts --clear-filters`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + webhook, err := readWebhookURL(cmd) + if err != nil { + return err + } + if err := checkChannelFormat(cmd); err != nil { + return err + } + enable, _ := cmd.Flags().GetBool("enable") + disable, _ := cmd.Flags().GetBool("disable") + clearSecret, _ := cmd.Flags().GetBool("clear-secret") + clearFilters, _ := cmd.Flags().GetBool("clear-filters") + if enable && disable { + return fmt.Errorf("--enable and --disable cannot be used together") + } + secret, secretSet, err := readSecret(cmd) + if err != nil { + return err + } + if clearSecret && secretSet { + return fmt.Errorf("--clear-secret cannot be used with --secret-env or --secret-stdin") + } + + req := &types.UpdateNotificationChannelRequest{} + req.Name, _ = cmd.Flags().GetString("name") + req.WebhookURL = webhook + if cmd.Flags().Changed("format") { + f, _ := cmd.Flags().GetString("format") + req.Format = &f + } + if secretSet { + req.Secret = &secret + } else if clearSecret { + empty := "" + req.Secret = &empty + } + if enable || disable { + req.Enabled = &enable + } + + c, err := newClient() + if err != nil { + return err + } + filters, err := readFilterFlags(cmd, c) + if err != nil { + return err + } + switch { + case filters != nil && clearFilters: + return fmt.Errorf("--clear-filters cannot be used with a --filter-* flag") + case filters != nil: + req.Filters = filters + case clearFilters: + req.Filters = &types.NotificationChannelFilters{} + } + if *req == (types.UpdateNotificationChannelRequest{}) { + return fmt.Errorf("nothing to update: set at least one flag") + } + + id, err := resolveChannelID(c, args[0]) + if err != nil { + return err + } + ch, err := c.UpdateNotificationChannel(id, req) + if err != nil { + return err + } + return printChannel(cmd, *ch, nil) + }, +} + +var notificationChannelDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a notification channel", + Long: `Delete a notification channel (name or ID) and its subscriptions. The +command asks for confirmation unless --yes is set. + +Examples: + stackctl notification channel delete team-alerts + stackctl notification channel delete team-alerts --yes`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + id, err := resolveChannelID(c, args[0]) + if err != nil { + return err + } + ok, err := confirmAction(cmd, fmt.Sprintf("Delete notification channel %s? (y/n): ", args[0])) + if err != nil { + return err + } + if !ok { + printer.PrintMessage("Aborted.") + return nil + } + if err := c.DeleteNotificationChannel(id); err != nil { + return err + } + if printer.Quiet { + fmt.Fprintln(printer.Writer, id) + return nil + } + printer.PrintMessage("Deleted notification channel %s", id) + return nil + }, +} + +// printEventTypes prints a list of event types, one per line in table mode. +func printEventTypes(events []string) error { + if events == nil { + events = []string{} + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(map[string][]string{"event_types": events}) + case output.FormatYAML: + return printer.PrintYAML(map[string][]string{"event_types": events}) + } + for _, e := range events { + fmt.Fprintln(printer.Writer, client.SanitizeServerText(e)) + } + return nil +} + +var notificationChannelEventsCmd = &cobra.Command{ + Use: "events", + Short: "List the event types that a channel can subscribe to", + Args: cobra.NoArgs, + Long: `List the event types that a channel can subscribe to, one per line. + +Examples: + stackctl notification channel events`, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + events, err := c.ListChannelEventTypes() + if err != nil { + return err + } + return printEventTypes(events) + }, +} + +var notificationChannelSubscriptionsCmd = &cobra.Command{ + Use: "subscriptions ", + Short: "List the event types of a channel", + Long: `List the event types that a channel (name or ID) subscribes to, one per +line. + +Examples: + stackctl notification channel subscriptions team-alerts`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + id, err := resolveChannelID(c, args[0]) + if err != nil { + return err + } + events, err := c.GetChannelSubscriptions(id) + if err != nil { + return err + } + return printEventTypes(events) + }, +} + +var notificationChannelSubscribeCmd = &cobra.Command{ + Use: "subscribe ", + Short: "Set the event types of a channel", + Long: `Set the event types of a channel (name or ID). The given --event list +replaces all subscriptions of the channel. --none removes all +subscriptions. An unknown event type gives an error of the server. + +Examples: + stackctl notification channel subscribe team-alerts --event deployment.success --event deployment.failed + stackctl notification channel subscribe team-alerts --none`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + events, _ := cmd.Flags().GetStringArray("event") + none, _ := cmd.Flags().GetBool("none") + switch { + case none && len(events) > 0: + return fmt.Errorf("--none cannot be used with --event") + case !none && len(events) == 0: + return fmt.Errorf("--event or --none is required") + } + c, err := newClient() + if err != nil { + return err + } + id, err := resolveChannelID(c, args[0]) + if err != nil { + return err + } + stored, err := c.SetChannelSubscriptions(id, events) + if err != nil { + return err + } + return printEventTypes(stored) + }, +} + +var notificationChannelTestCmd = &cobra.Command{ + Use: "test ", + Short: "Send a test event to a channel", + Long: `Send a test event (event type "test") to the webhook of a channel (name +or ID), with one attempt, in the format of the channel. The server writes +the send to the delivery log. A disabled channel can be tested; the command +then writes a note to stderr. The exit code is non-zero when the webhook +did not accept the event. + +Examples: + stackctl notification channel test team-alerts`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := newClient() + if err != nil { + return err + } + id, err := resolveChannelID(c, args[0]) + if err != nil { + return err + } + res, err := c.TestNotificationChannel(id) + if err != nil { + return err + } + if !res.ChannelEnabled { + fmt.Fprintln(cmd.ErrOrStderr(), "Note: the channel is disabled; it gets no events until you enable it.") + } + msg := client.SanitizeServerText(res.Message) + switch { + case printer.Quiet: + case printer.Format == output.FormatJSON: + if err := printer.PrintJSON(res); err != nil { + return err + } + case printer.Format == output.FormatYAML: + if err := printer.PrintYAML(res); err != nil { + return err + } + case res.Success: + printer.PrintMessage("Test succeeded: status %d. %s", res.StatusCode, msg) + } + if !res.Success { + return fmt.Errorf("the test event to channel %s failed (status %d): %s", args[0], res.StatusCode, msg) + } + return nil + }, +} + +var notificationChannelLogsCmd = &cobra.Command{ + Use: "logs ", + Short: "Show the delivery log of a channel", + Long: `Show the webhook deliveries of a channel (name or ID), newest first. + +Examples: + stackctl notification channel logs team-alerts + stackctl notification channel logs team-alerts --limit 50 --offset 50`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + if limit < 1 || limit > 100 { + return fmt.Errorf("--limit must be 1 to 100") + } + if offset < 0 { + return fmt.Errorf("--offset must be 0 or more") + } + c, err := newClient() + if err != nil { + return err + } + id, err := resolveChannelID(c, args[0]) + if err != nil { + return err + } + page, err := c.ListChannelDeliveryLogs(id, limit, offset) + if err != nil { + return err + } + if printer.Quiet { + for _, l := range page.Logs { + fmt.Fprintln(printer.Writer, l.ID) + } + return nil + } + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(page) + case output.FormatYAML: + return printer.PrintYAML(page) + } + if len(page.Logs) == 0 { + printer.PrintMessage("No deliveries.") + return nil + } + rows := make([][]string, len(page.Logs)) + for i, l := range page.Logs { + code := "-" + if l.StatusCode != 0 { + code = strconv.Itoa(l.StatusCode) + } + rows[i] = []string{ + l.CreatedAt.Format(time.RFC3339), client.SanitizeServerText(l.EventType), + printer.StatusColor(client.SanitizeServerText(l.Status)), code, valueOrDash(client.SanitizeServerText(l.ErrorMessage)), + } + } + if err := printer.PrintTable([]string{"TIME", "EVENT", "STATUS", "CODE", "ERROR"}, rows); err != nil { + return err + } + if shown := offset + len(page.Logs); int64(shown) < page.Total { + fmt.Fprintf(cmd.ErrOrStderr(), "Showing %d to %d of %d. Use --offset %d to see more.\n", offset+1, shown, page.Total, shown) + } + return nil + }, +} + +func init() { + channelOutputFlags(notificationChannelListCmd) + channelOutputFlags(notificationChannelGetCmd) + channelFlags(notificationChannelCreateCmd, false) + channelFlags(notificationChannelUpdateCmd, true) + notificationChannelDeleteCmd.Flags().BoolP("yes", "y", false, "Skip the confirmation prompt") + notificationChannelSubscribeCmd.Flags().StringArray("event", nil, "Event type (repeat the flag for more)") + notificationChannelSubscribeCmd.Flags().Bool("none", false, "Remove all subscriptions") + notificationChannelLogsCmd.Flags().Int("limit", 20, "Number of entries (1 to 100)") + notificationChannelLogsCmd.Flags().Int("offset", 0, "Number of entries to skip") + + notificationChannelCmd.AddCommand(notificationChannelListCmd, notificationChannelGetCmd, + notificationChannelCreateCmd, notificationChannelUpdateCmd, notificationChannelDeleteCmd, + notificationChannelEventsCmd, notificationChannelSubscriptionsCmd, notificationChannelSubscribeCmd, + notificationChannelTestCmd, notificationChannelLogsCmd) + notificationCmd.AddCommand(notificationChannelCmd) +} diff --git a/cli/cmd/notification_channel_test.go b/cli/cmd/notification_channel_test.go new file mode 100644 index 0000000..1b2b424 --- /dev/null +++ b/cli/cmd/notification_channel_test.go @@ -0,0 +1,373 @@ +package cmd + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/omattsson/stackctl/cli/pkg/output" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + teamsURL = "https://prod-1.example.logic.azure.com:443/workflows/abc/triggers/manual/paths/invoke?api-version=1&sig=SECRETSIG" + channelJSON = `{"id":"11111111-2222-3333-4444-555555555555","name":"team-alerts","webhook_url":"` + teamsURL + `","format":"teams","enabled":true,"filters":{"instance_name_patterns":["pr-*"]}}` + channelsJSON = `[{"id":"11111111-2222-3333-4444-555555555555","name":"team-alerts","webhook_url":"` + teamsURL + `","format":"teams","enabled":true,"subscription_count":2,"filters":{}}]` + channelID = "11111111-2222-3333-4444-555555555555" + channelPath = "/api/v1/admin/notification-channels" +) + +func TestMaskWebhookURL(t *testing.T) { + tests := map[string]string{ + teamsURL: "https://prod-1.example.logic.azure.com:443/...", + "https://hooks.example.com/T1/B2/x": "https://hooks.example.com/...", + "https://hooks.example.com": "https://hooks.example.com", + "https://user:pw@hooks.example.com/": "https://hooks.example.com", + "not a url": "", + "https://[2001:db8::1]:8443/x": "https://[2001:db8::1]:8443/...", + "https://h.example.com#sig=x": "https://h.example.com/...", + "": "", + } + for in, want := range tests { + assert.Equal(t, want, maskWebhookURL(in), in) + } +} + +func TestNotificationChannelListCmd(t *testing.T) { + tests := []struct { + name string + format output.Format + showURL bool + want string + notWant string + }{ + {"table masks the URL", output.FormatTable, false, "https://prod-1.example.logic.azure.com:443/...", "SECRETSIG"}, + {"json masks the URL", output.FormatJSON, false, `"webhook_url": "https://prod-1.example.logic.azure.com:443/..."`, "SECRETSIG"}, + {"show-url", output.FormatTable, true, "SECRETSIG", ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs := &recordServer{routes: map[string]route{"GET " + channelPath: {body: channelsJSON}}} + buf, _ := setupChartCmd(t, notificationChannelListCmd, rs) + printer.Format = tt.format + if tt.showURL { + require.NoError(t, notificationChannelListCmd.Flags().Set("show-url", "true")) + } + require.NoError(t, notificationChannelListCmd.RunE(notificationChannelListCmd, nil)) + assert.Contains(t, buf.String(), tt.want) + if tt.notWant != "" { + assert.NotContains(t, buf.String(), tt.notWant) + } + if tt.format == output.FormatTable && !tt.showURL { + assert.Regexp(t, `team-alerts\s+teams\s+yes\s+2\s+all instances`, buf.String()) + } + }) + } +} + +func TestNotificationChannelGetCmd_JSONMasksTheURL(t *testing.T) { + _, out := setupChannelCmd(t, notificationChannelGetCmd, map[string]route{"GET " + channelPath + "/" + channelID: {body: channelJSON}}) + printer.Format = output.FormatJSON + require.NoError(t, notificationChannelGetCmd.RunE(notificationChannelGetCmd, []string{channelID})) + stdout, _ := out() + assert.Contains(t, stdout, `"webhook_url": "https://prod-1.example.logic.azure.com:443/..."`) + assert.NotContains(t, stdout, "SECRETSIG") +} + +func TestNotificationChannelGetCmd_ByName(t *testing.T) { + rs := &recordServer{routes: map[string]route{ + "GET " + channelPath: {body: channelsJSON}, + "GET " + channelPath + "/" + channelID: {body: channelJSON}, + "GET " + channelPath + "/" + channelID + "/subscriptions": {body: `{"event_types":["deployment.failed","deployment.success"]}`}, + }} + buf, _ := setupChartCmd(t, notificationChannelGetCmd, rs) + + require.NoError(t, notificationChannelGetCmd.RunE(notificationChannelGetCmd, []string{"team-alerts"})) + out := buf.String() + assert.Regexp(t, `Events:\s+deployment.failed, deployment.success`, out) + assert.Regexp(t, `Filters:\s+name=pr-\*`, out) + assert.NotContains(t, out, "SECRETSIG") +} + +// setupChannelCmd prepares a channel command; StringArray flags are reset +// by resetCmdFlags. +func setupChannelCmd(t *testing.T, cmd *cobra.Command, routes map[string]route) (*recordServer, func() (string, string)) { + t.Helper() + rs := &recordServer{routes: routes} + buf, stderr := setupChartCmd(t, cmd, rs) + return rs, func() (string, string) { return buf.String(), stderr.String() } +} + +func setFlags(t *testing.T, cmd *cobra.Command, kv ...string) { + t.Helper() + require.Zero(t, len(kv)%2) + for i := 0; i < len(kv); i += 2 { + require.NoError(t, cmd.Flags().Set(kv[i], kv[i+1])) + } +} + +func TestNotificationChannelCreateCmd(t *testing.T) { + t.Setenv("CHANNEL_SECRET", "s3cr3t-value") + created := strings.Replace(channelJSON, `"enabled":true`, `"enabled":true,"warnings":["cluster_ids: cluster c9 does not exist"]`, 1) + rs, out := setupChannelCmd(t, notificationChannelCreateCmd, map[string]route{ + "GET " + channelPath + "/event-types": {body: `{"event_types":["deployment.failed","deployment.success"]}`}, + "POST " + channelPath: {http.StatusCreated, created}, + "PUT " + channelPath + "/" + channelID + "/subscriptions": {body: `{"event_types":["deployment.failed"]}`}, + }) + setFlags(t, notificationChannelCreateCmd, + "name", "team-alerts", "url", teamsURL, "format", "teams", "secret-env", "CHANNEL_SECRET", + "filter-name", "pr-*", "filter-definition", "7", "filter-cluster", "c9", "event", "deployment.failed") + + require.NoError(t, notificationChannelCreateCmd.RunE(notificationChannelCreateCmd, nil)) + assert.Equal(t, map[string]any{ + "name": "team-alerts", "webhook_url": teamsURL, "secret": "s3cr3t-value", "enabled": true, "format": "teams", + "filters": map[string]any{"instance_name_patterns": []any{"pr-*"}, "definition_ids": []any{"7"}, "cluster_ids": []any{"c9"}}, + }, rs.bodies["POST "+channelPath]) + assert.Equal(t, map[string]any{"event_types": []any{"deployment.failed"}}, rs.bodies["PUT "+channelPath+"/"+channelID+"/subscriptions"]) + stdout, stderr := out() + assert.Regexp(t, `Events:\s+deployment.failed`, stdout) + assert.Equal(t, "Warning: cluster_ids: cluster c9 does not exist\n", stderr) + assert.NotContains(t, stdout+stderr, "s3cr3t-value", "the secret is never printed") + assert.NotContains(t, stdout+stderr, "SECRETSIG") +} + +func TestNotificationChannelCreateCmd_SecretFromStdin(t *testing.T) { + rs, _ := setupChannelCmd(t, notificationChannelCreateCmd, map[string]route{"POST " + channelPath: {http.StatusCreated, channelJSON}}) + setFlags(t, notificationChannelCreateCmd, "name", "n", "url", "https://h.example.com/x", "secret-stdin", "true", "disabled", "true") + notificationChannelCreateCmd.SetIn(strings.NewReader("from-stdin\n")) + + require.NoError(t, notificationChannelCreateCmd.RunE(notificationChannelCreateCmd, nil)) + body := rs.bodies["POST "+channelPath] + assert.Equal(t, "from-stdin", body["secret"]) + assert.Equal(t, false, body["enabled"]) +} + +func TestNotificationChannelCreateCmd_UsageErrors(t *testing.T) { + t.Setenv("EMPTY_SECRET", "") + tests := []struct { + name string + flags []string + wantErr string + }{ + {"no url", []string{"name", "n"}, "--name and --url (or --url-env) are required"}, + {"http url", []string{"name", "n", "url", "http://h.example.com"}, "the webhook URL must start with https://"}, + {"url does not parse", []string{"name", "n", "url", "https://h/%zz?sig=TOKEN"}, "the webhook URL is not a valid URL"}, + {"url and url-env", []string{"name", "n", "url", "https://h.example.com", "url-env", "X"}, "--url and --url-env cannot be used together"}, + {"bad format", []string{"name", "n", "url", "https://h.example.com", "format", "slack"}, `--format must be generic or teams, not "slack"`}, + {"bad glob", []string{"name", "n", "url", "https://h.example.com", "filter-name", "pr-["}, `--filter-name "pr-[" is not a valid glob`}, + {"empty secret env", []string{"name", "n", "url", "https://h.example.com", "secret-env", "EMPTY_SECRET"}, "EMPTY_SECRET is not set or empty"}, + {"two secret sources", []string{"name", "n", "url", "https://h.example.com", "secret-env", "X", "secret-stdin", "true"}, "cannot be used together"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs, _ := setupChannelCmd(t, notificationChannelCreateCmd, map[string]route{}) + setFlags(t, notificationChannelCreateCmd, tt.flags...) + err := notificationChannelCreateCmd.RunE(notificationChannelCreateCmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.NotContains(t, err.Error(), "TOKEN", "an error never shows the URL") + assert.Empty(t, rs.requests) + }) + } +} + +func TestNotificationChannelCreateCmd_URLFromEnv(t *testing.T) { + t.Setenv("HOOK_URL", teamsURL) + rs, out := setupChannelCmd(t, notificationChannelCreateCmd, map[string]route{"POST " + channelPath: {http.StatusCreated, channelJSON}}) + setFlags(t, notificationChannelCreateCmd, "name", "n", "url-env", "HOOK_URL") + printer.Format = output.FormatYAML + + require.NoError(t, notificationChannelCreateCmd.RunE(notificationChannelCreateCmd, nil)) + assert.Equal(t, teamsURL, rs.bodies["POST "+channelPath]["webhook_url"]) + stdout, _ := out() + assert.Contains(t, stdout, "webhook_url: https://prod-1.example.logic.azure.com:443/...") + assert.NotContains(t, stdout, "SECRETSIG", "-o yaml masks the URL") +} + +func TestNotificationChannelCreateCmd_UnknownEventBeforeCreate(t *testing.T) { + rs, _ := setupChannelCmd(t, notificationChannelCreateCmd, map[string]route{ + "GET " + channelPath + "/event-types": {body: `{"event_types":["deployment.failed"]}`}, + }) + setFlags(t, notificationChannelCreateCmd, "name", "n", "url", "https://h.example.com/x", "event", "deploy.failed") + + err := notificationChannelCreateCmd.RunE(notificationChannelCreateCmd, nil) + require.Error(t, err) + assert.Equal(t, `unknown event type "deploy.failed" (see 'stackctl notification channel events')`, err.Error()) + assert.Equal(t, []string{"GET " + channelPath + "/event-types"}, rs.requests, "no channel is created") +} + +func TestNotificationChannelCreateCmd_SubscriptionFails(t *testing.T) { + _, out := setupChannelCmd(t, notificationChannelCreateCmd, map[string]route{ + "GET " + channelPath + "/event-types": {status: http.StatusInternalServerError, body: `{"error":"x"}`}, + "POST " + channelPath: {http.StatusCreated, channelJSON}, + "PUT " + channelPath + "/" + channelID + "/subscriptions": {http.StatusBadRequest, `{"error":"unknown event type: nope"}`}, + }) + printer.Quiet = true + setFlags(t, notificationChannelCreateCmd, "name", "n", "url", "https://h.example.com/x", "event", "nope") + + err := notificationChannelCreateCmd.RunE(notificationChannelCreateCmd, nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "the channel "+channelID+" is created, but the subscription failed") + assert.Contains(t, err.Error(), "Run: stackctl notification channel subscribe "+channelID+" --event ") + stdout, _ := out() + assert.Equal(t, channelID+"\n", stdout, "-q prints the ID of the created channel") +} + +func TestNotificationChannelUpdateCmd(t *testing.T) { + tests := []struct { + name string + flags []string + want map[string]any + }{ + {"disable", []string{"disable", "true"}, map[string]any{"enabled": false}}, + {"enable", []string{"enable", "true"}, map[string]any{"enabled": true}}, + {"clear secret", []string{"clear-secret", "true"}, map[string]any{"secret": ""}}, + {"clear filters", []string{"clear-filters", "true"}, map[string]any{"filters": map[string]any{}}}, + {"replace filters", []string{"filter-owner", "u1"}, map[string]any{"filters": map[string]any{"owner_ids": []any{"u1"}}}}, + {"rename and format", []string{"name", "new", "format", "generic"}, map[string]any{"name": "new", "format": "generic"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs, _ := setupChannelCmd(t, notificationChannelUpdateCmd, map[string]route{"PUT " + channelPath + "/" + channelID: {body: channelJSON}}) + setFlags(t, notificationChannelUpdateCmd, tt.flags...) + require.NoError(t, notificationChannelUpdateCmd.RunE(notificationChannelUpdateCmd, []string{channelID})) + assert.Equal(t, tt.want, rs.bodies["PUT "+channelPath+"/"+channelID]) + }) + } +} + +func TestNotificationChannelUpdateCmd_UsageErrors(t *testing.T) { + tests := []struct { + name string + flags []string + wantErr string + }{ + {"nothing", nil, "nothing to update"}, + {"enable and disable", []string{"enable", "true", "disable", "true"}, "--enable and --disable cannot be used together"}, + {"clear filters and a filter", []string{"clear-filters", "true", "filter-owner", "u1"}, "--clear-filters cannot be used with a --filter-* flag"}, + {"empty format", []string{"format", ""}, `--format must be generic or teams, not ""`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rs, _ := setupChannelCmd(t, notificationChannelUpdateCmd, map[string]route{}) + setFlags(t, notificationChannelUpdateCmd, tt.flags...) + err := notificationChannelUpdateCmd.RunE(notificationChannelUpdateCmd, []string{channelID}) + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + assert.Empty(t, rs.requests) + }) + } +} + +func TestNotificationChannelDeleteCmd(t *testing.T) { + rs, out := setupChannelCmd(t, notificationChannelDeleteCmd, map[string]route{"DELETE " + channelPath + "/" + channelID: {status: http.StatusNoContent}}) + notificationChannelDeleteCmd.SetIn(strings.NewReader("y\n")) + + require.NoError(t, notificationChannelDeleteCmd.RunE(notificationChannelDeleteCmd, []string{channelID})) + stdout, stderr := out() + assert.Contains(t, rs.requests, "DELETE "+channelPath+"/"+channelID) + assert.Equal(t, "Deleted notification channel "+channelID+"\n", stdout) + assert.Contains(t, stderr, "Delete notification channel "+channelID+"? (y/n): ") +} + +func TestNotificationChannelSubscribeCmd(t *testing.T) { + t.Run("events", func(t *testing.T) { + rs, out := setupChannelCmd(t, notificationChannelSubscribeCmd, map[string]route{ + "PUT " + channelPath + "/" + channelID + "/subscriptions": {body: `{"event_types":["a","b"]}`}, + }) + setFlags(t, notificationChannelSubscribeCmd, "event", "a", "event", "b") + require.NoError(t, notificationChannelSubscribeCmd.RunE(notificationChannelSubscribeCmd, []string{channelID})) + assert.Equal(t, map[string]any{"event_types": []any{"a", "b"}}, rs.bodies["PUT "+channelPath+"/"+channelID+"/subscriptions"]) + stdout, _ := out() + assert.Equal(t, "a\nb\n", stdout) + }) + t.Run("none sends an empty list", func(t *testing.T) { + rs, _ := setupChannelCmd(t, notificationChannelSubscribeCmd, map[string]route{ + "PUT " + channelPath + "/" + channelID + "/subscriptions": {body: `{"event_types":[]}`}, + }) + setFlags(t, notificationChannelSubscribeCmd, "none", "true") + require.NoError(t, notificationChannelSubscribeCmd.RunE(notificationChannelSubscribeCmd, []string{channelID})) + assert.Equal(t, map[string]any{"event_types": []any{}}, rs.bodies["PUT "+channelPath+"/"+channelID+"/subscriptions"]) + }) + t.Run("unknown event", func(t *testing.T) { + _, _ = setupChannelCmd(t, notificationChannelSubscribeCmd, map[string]route{ + "PUT " + channelPath + "/" + channelID + "/subscriptions": {http.StatusBadRequest, `{"error":"unknown event type: nope"}`}, + }) + setFlags(t, notificationChannelSubscribeCmd, "event", "nope") + err := notificationChannelSubscribeCmd.RunE(notificationChannelSubscribeCmd, []string{channelID}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown event type: nope") + }) + t.Run("no flag", func(t *testing.T) { + rs, _ := setupChannelCmd(t, notificationChannelSubscribeCmd, map[string]route{}) + err := notificationChannelSubscribeCmd.RunE(notificationChannelSubscribeCmd, []string{channelID}) + require.Error(t, err) + assert.Equal(t, "--event or --none is required", err.Error()) + assert.Empty(t, rs.requests) + }) +} + +func TestNotificationChannelEventsCmd_JSON(t *testing.T) { + _, out := setupChannelCmd(t, notificationChannelEventsCmd, map[string]route{"GET " + channelPath + "/event-types": {body: `{"event_types":["x","y"]}`}}) + printer.Format = output.FormatJSON + require.NoError(t, notificationChannelEventsCmd.RunE(notificationChannelEventsCmd, nil)) + stdout, _ := out() + var got map[string][]string + require.NoError(t, json.Unmarshal([]byte(stdout), &got)) + assert.Equal(t, []string{"x", "y"}, got["event_types"]) +} + +func TestNotificationChannelTestCmd(t *testing.T) { + tests := []struct { + name string + body string + wantErr string + wantOut string + wantStderr string + }{ + {"success", `{"success":true,"message":"Delivered","status_code":202,"channel_enabled":true}`, "", "Test succeeded: status 202. Delivered\n", ""}, + {"failure on a disabled channel", `{"success":false,"message":"Webhook returned 404","status_code":404,"channel_enabled":false}`, + "the test event to channel " + channelID + " failed (status 404): Webhook returned 404", "", "Note: the channel is disabled"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, out := setupChannelCmd(t, notificationChannelTestCmd, map[string]route{"POST " + channelPath + "/" + channelID + "/test": {body: tt.body}}) + err := notificationChannelTestCmd.RunE(notificationChannelTestCmd, []string{channelID}) + if tt.wantErr != "" { + require.Error(t, err) + assert.Equal(t, tt.wantErr, err.Error()) + } else { + require.NoError(t, err) + } + stdout, stderr := out() + assert.Equal(t, tt.wantOut, stdout) + assert.Contains(t, stderr, tt.wantStderr) + }) + } +} + +func TestNotificationChannelLogsCmd(t *testing.T) { + body := `{"logs":[{"id":"l1","created_at":"2026-10-19T08:00:00Z","event_type":"deployment.failed","status":"failed","status_code":500,"error_message":"boom\u001b[2J"}, + {"id":"l2","created_at":"2026-10-19T07:00:00Z","event_type":"test","status":"success","status_code":0}],"total":30,"limit":2,"offset":0}` + _, out := setupChannelCmd(t, notificationChannelLogsCmd, map[string]route{"GET " + channelPath + "/" + channelID + "/delivery-logs": {body: body}}) + setFlags(t, notificationChannelLogsCmd, "limit", "2") + + require.NoError(t, notificationChannelLogsCmd.RunE(notificationChannelLogsCmd, []string{channelID})) + stdout, stderr := out() + assert.Regexp(t, `2026-10-19T08:00:00Z\s+deployment.failed\s+failed\s+500\s+boom \[2J`, stdout) + assert.Regexp(t, `test\s+success\s+-\s+-`, stdout) + assert.NotContains(t, stdout, "\x1b[2J") + assert.Equal(t, "Showing 1 to 2 of 30. Use --offset 2 to see more.\n", stderr) +} + +func TestNotificationChannelLogsCmd_BadLimit(t *testing.T) { + rs, _ := setupChannelCmd(t, notificationChannelLogsCmd, map[string]route{}) + setFlags(t, notificationChannelLogsCmd, "limit", "500") + err := notificationChannelLogsCmd.RunE(notificationChannelLogsCmd, []string{channelID}) + require.Error(t, err) + assert.Equal(t, "--limit must be 1 to 100", err.Error()) + assert.Empty(t, rs.requests) +} diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index d93f4cf..3b0b0b0 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -650,6 +650,111 @@ func (c *Client) ListStacks(params map[string]string) (*types.ListResponse[types return &resp, nil } +// pathChannels is the base path of the notification channel routes +// (devops or admin). +const pathChannels = "/api/v1/admin/notification-channels" + +// ListNotificationChannels returns all notification channels with their +// subscription counts. +func (c *Client) ListNotificationChannels() ([]types.NotificationChannel, error) { + var channels []types.NotificationChannel + if err := c.Get(pathChannels, &channels); err != nil { + return nil, err + } + return channels, nil +} + +// GetNotificationChannel returns one notification channel. +func (c *Client) GetNotificationChannel(id string) (*types.NotificationChannel, error) { + var ch types.NotificationChannel + if err := c.Get(pathChannels+"/"+url.PathEscape(id), &ch); err != nil { + return nil, err + } + return &ch, nil +} + +// CreateNotificationChannel creates a notification channel. +func (c *Client) CreateNotificationChannel(req *types.CreateNotificationChannelRequest) (*types.NotificationChannel, error) { + var ch types.NotificationChannel + if err := c.Post(pathChannels, req, &ch); err != nil { + return nil, err + } + return &ch, nil +} + +// UpdateNotificationChannel changes the fields of req on a channel. +func (c *Client) UpdateNotificationChannel(id string, req *types.UpdateNotificationChannelRequest) (*types.NotificationChannel, error) { + var ch types.NotificationChannel + if err := c.Put(pathChannels+"/"+url.PathEscape(id), req, &ch); err != nil { + return nil, err + } + return &ch, nil +} + +// DeleteNotificationChannel deletes a channel and its subscriptions. +func (c *Client) DeleteNotificationChannel(id string) error { + return c.Delete(pathChannels + "/" + url.PathEscape(id)) +} + +// ListChannelEventTypes returns the event types that a channel can +// subscribe to. +func (c *Client) ListChannelEventTypes() ([]string, error) { + var resp struct { + EventTypes []string `json:"event_types"` + } + if err := c.Get(pathChannels+"/event-types", &resp); err != nil { + return nil, err + } + return resp.EventTypes, nil +} + +// GetChannelSubscriptions returns the event types of a channel. +func (c *Client) GetChannelSubscriptions(id string) ([]string, error) { + var resp struct { + EventTypes []string `json:"event_types"` + } + if err := c.Get(pathChannels+"/"+url.PathEscape(id)+"/subscriptions", &resp); err != nil { + return nil, err + } + return resp.EventTypes, nil +} + +// SetChannelSubscriptions replaces the event types of a channel and returns +// the stored list. +func (c *Client) SetChannelSubscriptions(id string, eventTypes []string) ([]string, error) { + if eventTypes == nil { + eventTypes = []string{} + } + var resp struct { + EventTypes []string `json:"event_types"` + } + if err := c.Put(pathChannels+"/"+url.PathEscape(id)+"/subscriptions", map[string][]string{"event_types": eventTypes}, &resp); err != nil { + return nil, err + } + return resp.EventTypes, nil +} + +// TestNotificationChannel sends a test event to the webhook of a channel +// (one attempt). +func (c *Client) TestNotificationChannel(id string) (*types.ChannelTestResult, error) { + var res types.ChannelTestResult + if err := c.Post(pathChannels+"/"+url.PathEscape(id)+"/test", nil, &res); err != nil { + return nil, err + } + return &res, nil +} + +// ListChannelDeliveryLogs returns one page of the delivery log of a +// channel, newest first. +func (c *Client) ListChannelDeliveryLogs(id string, limit, offset int) (*types.ChannelDeliveryLogs, error) { + var page types.ChannelDeliveryLogs + params := map[string]string{"limit": strconv.Itoa(limit), "offset": strconv.Itoa(offset)} + if err := c.GetWithQuery(pathChannels+"/"+url.PathEscape(id)+"/delivery-logs", params, &page); err != nil { + return nil, err + } + return &page, 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) { diff --git a/cli/pkg/client/notification_channels_test.go b/cli/pkg/client/notification_channels_test.go new file mode 100644 index 0000000..84e3525 --- /dev/null +++ b/cli/pkg/client/notification_channels_test.go @@ -0,0 +1,87 @@ +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 TestNotificationChannelRoutes(t *testing.T) { + t.Parallel() + type call struct{ method, uri, body 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)}) + switch { + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case r.URL.Path == "/api/v1/admin/notification-channels" && r.Method == http.MethodGet: + _, _ = io.WriteString(w, `[{"id":"c1","subscription_count":3}]`) + case r.URL.Path == "/api/v1/admin/notification-channels/c1/delivery-logs": + _, _ = io.WriteString(w, `{"logs":[{"id":"l1"}],"total":1,"limit":5,"offset":10}`) + case r.URL.Path == "/api/v1/admin/notification-channels/c1/test": + _, _ = io.WriteString(w, `{"success":true,"status_code":200,"channel_enabled":true}`) + case r.Method == http.MethodGet && (r.URL.Path == "/api/v1/admin/notification-channels/event-types" || r.URL.Path == "/api/v1/admin/notification-channels/c1/subscriptions"), + r.Method == http.MethodPut && r.URL.Path == "/api/v1/admin/notification-channels/c1/subscriptions": + _, _ = io.WriteString(w, `{"event_types":["a"]}`) + default: + _, _ = io.WriteString(w, `{"id":"c1","warnings":["w"]}`) + } + })) + t.Cleanup(srv.Close) + c := New(srv.URL) + + list, err := c.ListNotificationChannels() + require.NoError(t, err) + require.NotNil(t, list[0].SubscriptionCount) + assert.Equal(t, 3, *list[0].SubscriptionCount) + _, err = c.GetNotificationChannel("c1") + require.NoError(t, err) + ch, err := c.CreateNotificationChannel(&types.CreateNotificationChannelRequest{Name: "n", WebhookURL: "https://h"}) + require.NoError(t, err) + assert.Equal(t, []string{"w"}, ch.Warnings) + empty := "" + _, err = c.UpdateNotificationChannel("c1", &types.UpdateNotificationChannelRequest{Secret: &empty}) + require.NoError(t, err) + require.NoError(t, c.DeleteNotificationChannel("c1")) + ev, err := c.ListChannelEventTypes() + require.NoError(t, err) + assert.Equal(t, []string{"a"}, ev) + _, err = c.GetChannelSubscriptions("c1") + require.NoError(t, err) + _, err = c.SetChannelSubscriptions("c1", nil) + require.NoError(t, err) + res, err := c.TestNotificationChannel("c1") + require.NoError(t, err) + assert.True(t, res.Success) + page, err := c.ListChannelDeliveryLogs("c1", 5, 10) + require.NoError(t, err) + assert.Equal(t, int64(1), page.Total) + + want := []call{ + {http.MethodGet, "/api/v1/admin/notification-channels", ""}, + {http.MethodGet, "/api/v1/admin/notification-channels/c1", ""}, + {http.MethodPost, "/api/v1/admin/notification-channels", `{"name":"n","webhook_url":"https://h"}`}, + {http.MethodPut, "/api/v1/admin/notification-channels/c1", `{"secret":""}`}, + {http.MethodDelete, "/api/v1/admin/notification-channels/c1", ""}, + {http.MethodGet, "/api/v1/admin/notification-channels/event-types", ""}, + {http.MethodGet, "/api/v1/admin/notification-channels/c1/subscriptions", ""}, + {http.MethodPut, "/api/v1/admin/notification-channels/c1/subscriptions", `{"event_types":[]}`}, + {http.MethodPost, "/api/v1/admin/notification-channels/c1/test", ""}, + {http.MethodGet, "/api/v1/admin/notification-channels/c1/delivery-logs?limit=5&offset=10", ""}, + } + 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) + 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 da03800..2ba001b 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -50,6 +50,90 @@ type StackInstance struct { FollowerCount *int64 `json:"follower_count,omitempty" yaml:"follower_count,omitempty"` } +// NotificationChannel is an outbound webhook channel (GET +// /api/v1/admin/notification-channels/:id). The server never sends the +// secret. WebhookURL can hold a token (for example the signature of a Teams +// workflow URL): print it only on request. +type NotificationChannel struct { + CreatedAt time.Time `json:"created_at" yaml:"created_at"` + UpdatedAt time.Time `json:"updated_at" yaml:"updated_at"` + ID string `json:"id" yaml:"id"` + Name string `json:"name" yaml:"name"` + WebhookURL string `json:"webhook_url" yaml:"webhook_url"` + Format string `json:"format" yaml:"format"` + Filters NotificationChannelFilters `json:"filters" yaml:"filters"` + Enabled bool `json:"enabled" yaml:"enabled"` + SubscriptionCount *int `json:"subscription_count,omitempty" yaml:"subscription_count,omitempty"` + // Warnings is set by create and update: filter IDs that point to no + // user, definition or cluster. The server saves the channel anyway. + Warnings []string `json:"warnings,omitempty" yaml:"warnings,omitempty"` +} + +// NotificationChannelFilters limits a channel to some stack instances. Each +// set filter must match; one value of a filter is enough. Empty filters +// match all instances. +type NotificationChannelFilters struct { + InstanceNamePatterns []string `json:"instance_name_patterns,omitempty" yaml:"instance_name_patterns,omitempty"` + OwnerIDs []string `json:"owner_ids,omitempty" yaml:"owner_ids,omitempty"` + DefinitionIDs []string `json:"definition_ids,omitempty" yaml:"definition_ids,omitempty"` + ClusterIDs []string `json:"cluster_ids,omitempty" yaml:"cluster_ids,omitempty"` +} + +// CreateNotificationChannelRequest is the body of POST +// /api/v1/admin/notification-channels. +type CreateNotificationChannelRequest struct { + Name string `json:"name"` + WebhookURL string `json:"webhook_url"` + Secret string `json:"secret,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Format string `json:"format,omitempty"` + Filters *NotificationChannelFilters `json:"filters,omitempty"` +} + +// UpdateNotificationChannelRequest is the body of PUT +// /api/v1/admin/notification-channels/:id. Only the fields sent change. A +// Secret of "" removes the secret (unsigned requests). Filters replaces all +// filters; an empty object removes them. +type UpdateNotificationChannelRequest struct { + Name string `json:"name,omitempty"` + WebhookURL string `json:"webhook_url,omitempty"` + Secret *string `json:"secret,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Format *string `json:"format,omitempty"` + Filters *NotificationChannelFilters `json:"filters,omitempty"` +} + +// ChannelTestResult is the response of POST +// /api/v1/admin/notification-channels/:id/test. StatusCode is 0 when the +// webhook did not answer. +type ChannelTestResult struct { + Success bool `json:"success" yaml:"success"` + Message string `json:"message" yaml:"message"` + StatusCode int `json:"status_code" yaml:"status_code"` + ChannelEnabled bool `json:"channel_enabled" yaml:"channel_enabled"` +} + +// ChannelDeliveryLogs is one page of GET +// /api/v1/admin/notification-channels/:id/delivery-logs. +type ChannelDeliveryLogs struct { + Logs []ChannelDeliveryLog `json:"logs" yaml:"logs"` + Total int64 `json:"total" yaml:"total"` + Limit int `json:"limit" yaml:"limit"` + Offset int `json:"offset" yaml:"offset"` +} + +// ChannelDeliveryLog is one webhook delivery of a channel. +type ChannelDeliveryLog struct { + CreatedAt time.Time `json:"created_at" yaml:"created_at"` + ID string `json:"id" yaml:"id"` + ChannelID string `json:"channel_id" yaml:"channel_id"` + ChannelName string `json:"channel_name" yaml:"channel_name"` + EventType string `json:"event_type" yaml:"event_type"` + Status string `json:"status" yaml:"status"` + StatusCode int `json:"status_code" yaml:"status_code"` + ErrorMessage string `json:"error_message,omitempty" yaml:"error_message,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 { diff --git a/cli/test/contract/response_contract_test.go b/cli/test/contract/response_contract_test.go index 15b35e9..d26231c 100644 --- a/cli/test/contract/response_contract_test.go +++ b/cli/test/contract/response_contract_test.go @@ -44,6 +44,32 @@ func TestResponseSchemas_MatchBackend(t *testing.T) { {name: "PodEvent", goType: types.PodEvent{}, swaggerDef: "k8s.PodEvent"}, {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: "FollowState", goType: types.FollowState{}, swaggerDef: "handlers.followStateResponse"}, + {name: "DeployPreview", goType: types.DeployPreview{}, swaggerDef: "handlers.DeployPreviewResponse"}, + {name: "ChartDeployPreview", goType: types.ChartDeployPreview{}, swaggerDef: "handlers.ChartDeployPreview"}, + {name: "ActionList", goType: types.ActionList{}, swaggerDef: "handlers.actionListResponse"}, + {name: "ActionJobLog", goType: types.ActionJobLog{}, swaggerDef: "handlers.actionJobLogResponse"}, + {name: "UpgradeCheck", goType: types.UpgradeCheck{}, swaggerDef: "handlers.upgradeCheckResponse"}, + {name: "UpgradeChanges", goType: types.UpgradeChanges{}, swaggerDef: "handlers.upgradeChanges"}, + {name: "TemplateLinkRequest", goType: types.TemplateLinkRequest{}, swaggerDef: "handlers.templateLinkRequest"}, + // filters is a $ref in the schema (no type); the + // NotificationChannelFilters case checks its fields. + {name: "CreateNotificationChannelRequest", goType: types.CreateNotificationChannelRequest{}, swaggerDef: "handlers.createChannelRequest", excludeGoTags: []string{"filters"}}, + {name: "UpdateNotificationChannelRequest", goType: types.UpdateNotificationChannelRequest{}, swaggerDef: "handlers.updateChannelRequest", excludeGoTags: []string{"filters"}}, + {name: "NotificationChannelFilters", goType: types.NotificationChannelFilters{}, swaggerDef: "models.NotificationChannelFilters"}, + { + name: "NotificationChannel", + goType: types.NotificationChannel{}, + swaggerDef: "models.NotificationChannel", + // subscription_count comes only with the list + // (handlers.notificationChannelWithCount); warnings only with + // create and update (handlers.notificationChannelResponse). + // filters is a $ref (see above). + excludeGoTags: []string{"subscription_count", "warnings", "filters"}, + }, + // The delivery log route documents a generic map, so the schema has + // no definition for ChannelDeliveryLog. { name: "StackInstance", goType: types.StackInstance{},