diff --git a/README.md b/README.md index 7d82199..ea729f9 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,8 @@ 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 list` and `stack action run` see the actions that apply to the stack: a server can limit an action to some templates, template categories, definitions or clusters (`applies_to`). An action of another stack gives "unknown action" with the actions of this stack. When the server could not check where an action applies, `stack action list` ends with "Some actions are not shown: the server could not check where they apply. Try again.", and `-o json` has `"scope_incomplete": true`. + `stack action run`: - 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. @@ -654,6 +656,7 @@ These groups have no section above. Run `stackctl --help` for the flags. | `--follow` and `stack watch` with an API key, `bulk delete` with clean first, `orphaned delete --confirm`, `user set-role` | v0.8.0 or later | | `definition link-template` | v0.11.0 or later | | `--stop-at`, `stack extend --until`, the TTL limit of `server info` and of the local check (an older server ignores `--stop-at` and `--until`: stackctl shows the result and exits with code 1; it uses 30 days as the limit) | v0.12.0 or later | +| Actions limited to some stacks (`applies_to`) and the `scope_incomplete` hint of `stack action list` (an older server lists every action for every stack) | the release after v0.14.0 (omattsson/k8s-stack-manager#720) | ## Output Formats diff --git a/cli/cmd/stack_action.go b/cli/cmd/stack_action.go index 1273a34..e7ed59b 100644 --- a/cli/cmd/stack_action.go +++ b/cli/cmd/stack_action.go @@ -96,7 +96,10 @@ Examples: return printer.PrintYAML(list) } if len(list.Actions) == 0 { - printer.PrintMessage("The server has no custom actions.") + printer.PrintMessage("This stack has no custom actions.") + if list.ScopeIncomplete { + printer.PrintMessage(msgActionsIncomplete) + } return nil } rows := make([][]string, len(list.Actions)) @@ -109,10 +112,20 @@ Examples: client.SanitizeServerText(parameterSummary(a.Parameters)), } } - return printer.PrintTable([]string{"NAME", "LABEL", "CAN RUN", "JOB LOG", "PARAMETERS"}, rows) + if err := printer.PrintTable([]string{"NAME", "LABEL", "CAN RUN", "JOB LOG", "PARAMETERS"}, rows); err != nil { + return err + } + if list.ScopeIncomplete { + printer.PrintMessage(msgActionsIncomplete) + } + return nil }, } +// msgActionsIncomplete tells that the server left out actions it could not +// check for the stack (scope_incomplete). +const msgActionsIncomplete = "Some actions are not shown: the server could not check where they apply. Try again." + // 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 { @@ -265,7 +278,9 @@ func parseParamFlags(raw []string) (map[string]string, error) { return out, nil } -// findAction returns the action with the given name. +// findAction returns the action with the given name. The list holds the +// actions that apply to the stack: a server can limit an action to some +// templates or clusters (applies_to). func findAction(list *types.ActionList, name string) (types.ActionInfo, error) { names := make([]string, 0, len(list.Actions)) for _, a := range list.Actions { @@ -274,10 +289,14 @@ func findAction(list *types.ActionList, name string) (types.ActionInfo, error) { } names = append(names, a.Name) } + hint := "" + if list.ScopeIncomplete { + hint = "; some actions could not be checked for this stack, try again" + } 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: this stack has no custom actions%s", name, hint) } - return types.ActionInfo{}, fmt.Errorf("unknown action %q (actions: %s)", name, strings.Join(names, ", ")) + return types.ActionInfo{}, fmt.Errorf("unknown action %q (actions of this stack: %s)%s", name, strings.Join(names, ", "), hint) } // buildActionParams checks the given parameters against the declared ones diff --git a/cli/cmd/stack_action_test.go b/cli/cmd/stack_action_test.go index cdf3534..e7a3ded 100644 --- a/cli/cmd/stack_action_test.go +++ b/cli/cmd/stack_action_test.go @@ -164,7 +164,20 @@ func TestStackActionListCmd(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()) + assert.Equal(t, "This stack has no custom actions.\n", buf.String()) + }) + t.Run("scope incomplete", func(t *testing.T) { + incomplete := &actionServer{list: strings.Replace(actionListBody, `"instance_id":"42",`, `"instance_id":"42","scope_incomplete":true,`, 1)} + buf := setupStackTestCmd(t, incomplete.start(t).URL) + require.NoError(t, stackActionListCmd.RunE(stackActionListCmd, []string{"42"})) + assert.Regexp(t, `ping\s+Ping`, buf.String()) + assert.True(t, strings.HasSuffix(buf.String(), msgActionsIncomplete+"\n"), buf.String()) + }) + t.Run("no actions, scope incomplete", func(t *testing.T) { + empty := &actionServer{list: `{"instance_id":"42","actions":[],"scope_incomplete":true}`} + buf := setupStackTestCmd(t, empty.start(t).URL) + require.NoError(t, stackActionListCmd.RunE(stackActionListCmd, []string{"42"})) + assert.Equal(t, "This stack has no custom actions.\n"+msgActionsIncomplete+"\n", buf.String()) }) } @@ -198,7 +211,7 @@ func TestStackActionRunCmd_UsageErrors(t *testing.T) { {"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)`}, + {"unknown action", "nope", nil, `unknown action "nope" (actions of this stack: refresh-db, ping)`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -443,3 +456,28 @@ func TestParameterSummary(t *testing.T) { {Name: "c", Type: "bool", Default: false}, })) } + +func TestFindAction_ScopeMessages(t *testing.T) { + t.Parallel() + ping := types.ActionInfo{Name: "ping"} + tests := []struct { + name string + list types.ActionList + wantErr string + }{ + {"no actions", types.ActionList{}, `unknown action "nope": this stack has no custom actions`}, + {"no actions, scope incomplete", types.ActionList{ScopeIncomplete: true}, + `unknown action "nope": this stack has no custom actions; some actions could not be checked for this stack, try again`}, + {"other actions, scope incomplete", types.ActionList{Actions: []types.ActionInfo{ping}, ScopeIncomplete: true}, + `unknown action "nope" (actions of this stack: ping); some actions could not be checked for this stack, try again`}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, err := findAction(&tt.list, "nope") + require.Error(t, err) + assert.Equal(t, tt.wantErr, err.Error()) + }) + } +} diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index a8f1b44..23a0433 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -135,11 +135,16 @@ type ChannelDeliveryLog struct { } // ActionList is the response of GET /api/v1/stack-instances/:id/actions: -// the custom actions of the hooks config, without URLs or secrets. +// the custom actions of the hooks config that apply to the stack, 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"` + // ScopeIncomplete is true when the server left out actions limited to + // some templates (applies_to) because it could not read the template + // of the stack. Omitted when false, and by servers without applies_to. + ScopeIncomplete bool `json:"scope_incomplete,omitempty" yaml:"scope_incomplete,omitempty"` } // ActionInfo is one custom action with its UI metadata. diff --git a/cli/test/contract/testdata/swagger.json b/cli/test/contract/testdata/swagger.json index 07394d7..68ad9f5 100644 --- a/cli/test/contract/testdata/swagger.json +++ b/cli/test/contract/testdata/swagger.json @@ -355,7 +355,7 @@ "BearerAuth": [] } ], - "description": "Creates a new notification channel. format sets the body of the webhook request: \"generic\" (default, the event JSON) or \"teams\" (a Microsoft Teams Adaptive Card envelope for a Teams workflow made from the template \"Post to a channel when a webhook request is received\"). An unknown format gives 400. Optional filters limit the channel to some stack instances: instance_name_patterns (globs, path.Match syntax, case-insensitive), owner_ids, definition_ids and cluster_ids. Each set filter must match (AND); one value of a filter is enough (OR); empty filters match all instances. Events without an instance go only to channels without filters; a cleanup policy run goes to a channel when at least one affected instance matches. An invalid pattern gives 400. An ID that does not exist gives a warning in the response; the channel is saved.", + "description": "Creates a new notification channel. A secret needs KUBECONFIG_ENCRYPTION_KEY: without the key a non-empty secret gives 400. With the key the secret and the webhook URL are encrypted at rest; without the key the URL is stored in plaintext. The webhook URL is at most 2048 characters. format sets the body of the webhook request: \"generic\" (default, the event JSON) or \"teams\" (a Microsoft Teams Adaptive Card envelope for a Teams workflow made from the template \"Post to a channel when a webhook request is received\"). An unknown format gives 400. Optional filters limit the channel to some stack instances: instance_name_patterns (globs, path.Match syntax, case-insensitive), owner_ids, definition_ids and cluster_ids. Each set filter must match (AND); one value of a filter is enough (OR); empty filters match all instances. Events without an instance go only to channels without filters; a cleanup policy run goes to a channel when at least one affected instance matches. An invalid pattern gives 400. An ID that does not exist gives a warning in the response; the channel is saved.", "consumes": [ "application/json" ], @@ -524,7 +524,7 @@ "BearerAuth": [] } ], - "description": "Updates an existing notification channel. Only the fields in the body change. format (\"generic\" or \"teams\") replaces the body format; omit it to keep the format; an unknown format gives 400. filters replaces the channel filters (omit it to keep them, send {} to remove them); the rules are the same as for create. An ID that does not exist gives a warning in the response.", + "description": "Updates an existing notification channel. Only the fields in the body change. A new non-empty secret needs KUBECONFIG_ENCRYPTION_KEY (400 without it); \"secret\": \"\" removes the secret. A secret_unreadable or webhook_url_unreadable channel (stored value that cannot be decrypted) sends nothing until a PUT sets the value again. format (\"generic\" or \"teams\") replaces the body format; omit it to keep the format; an unknown format gives 400. filters replaces the channel filters (omit it to keep them, send {} to remove them); the rules are the same as for create. An ID that does not exist gives a warning in the response.", "consumes": [ "application/json" ], @@ -1451,7 +1451,7 @@ }, "/api/v1/auth/logout": { "post": { - "description": "Revokes the session of the refresh token cookie (all refresh tokens of its family), blocklists the access token and closes its WebSockets. When the refresh token lookup or the revoke fails, the cookie is still cleared and the access token blocked, but the answer is 500.", + "description": "Ends the sessions that the request proves: the session of the refresh token cookie and the session of a valid bearer token (its sid claim), also when the cookie is missing. When both name different sessions, both end. It revokes all refresh tokens of each session, then blocks the session in the session store, so that every access token of it (not only the presented one) gets 401, and its WebSockets close within the revalidation interval. It also blocklists the presented access token and closes its WebSockets. Access tokens of other sessions of the user keep working. A request without a cookie and without a valid bearer token ends nothing and answers 200. When the refresh token lookup or a revoke fails, the cookie is still cleared and the blocks are still written, but the answer is 500.", "consumes": [ "application/json" ], @@ -1472,15 +1472,6 @@ } } }, - "401": { - "description": "Unauthorized", - "schema": { - "type": "object", - "additionalProperties": { - "type": "string" - } - } - }, "500": { "description": "Internal Server Error", "schema": { @@ -1495,7 +1486,7 @@ }, "/api/v1/auth/logout-all": { "post": { - "description": "Revokes all refresh tokens for the current user", + "description": "Revokes all refresh tokens for the current user, then blocks every session of the user that has a refresh token row whose expiry is later than now minus (access token lifetime + 1 minute + reuse grace), also when that refresh token was revoked earlier. A session block is a plain block, so every access token of those sessions is rejected, and cannot open a WebSocket, while the replica clocks differ by less than the 1 minute block margin. It also writes a user block that rejects every access token issued up to now. The refresh-token cleanup keeps rows for that window after they expire. A session without such a row (a login whose refresh token could not be stored) and a token without a session (the SSO CLI token) rely on the user block, which holds on replicas with synchronised (NTP) clocks. A login after the logout works. The presented access token is blocklisted and all WebSockets of the user close. When the revoke fails, the sessions get the long block (until the maximum session lifetime), the other blocks are still written, the cookie is cleared and the answer is 500. When only the listing of the sessions fails, the revoke and the user block still run and the answer is 200. When the listing and the revoke both fail, only the presented session is blocked and the answer is 500.", "consumes": [ "application/json" ], @@ -5109,6 +5100,15 @@ "type": "string" } } + }, + "503": { + "description": "A pre-instance-create hook could not check where it applies (applies_to); try again", + "schema": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } } } } @@ -5679,7 +5679,7 @@ } }, "503": { - "description": "Deploy manager not configured", + "description": "Deploy manager not configured, or a pre-instance-delete hook could not check where it applies (applies_to)", "schema": { "type": "object", "additionalProperties": { @@ -5772,7 +5772,7 @@ }, "/api/v1/stack-instances/{id}/actions": { "get": { - "description": "Returns the registered actions with their UI metadata (label, description, confirmation text, parameter schema) and a can_invoke flag. Any authenticated user who can view the instance gets the list; can_invoke is true for the owner, an admin and a devops user. The response never contains the subscriber URL, secret or headers. Without an action registry the list is empty.", + "description": "Returns the registered actions that apply to the instance (the applies_to of each action: templates, template categories, definitions, clusters) with their UI metadata (label, description, confirmation text, parameter schema) and a can_invoke flag. Any authenticated user who can view the instance gets the list; can_invoke is true for the owner, an admin and a devops user. When the template of the instance cannot be read, the actions limited to templates are left out and scope_incomplete is true. The response never contains the subscriber URL, secret, headers or applies_to. Without an action registry the list is empty.", "consumes": [ "application/json" ], @@ -5920,7 +5920,7 @@ } }, "404": { - "description": "Instance not found or action not registered", + "description": "Instance not found, or action not registered or not for this instance (applies_to)", "schema": { "type": "object", "additionalProperties": { @@ -5947,7 +5947,7 @@ } }, "503": { - "description": "Action registry not configured", + "description": "Action registry not configured, or the template of the instance could not be read to check where the action applies", "schema": { "type": "object", "additionalProperties": { @@ -12032,6 +12032,10 @@ }, "instance_id": { "type": "string" + }, + "scope_incomplete": { + "description": "ScopeIncomplete is true when an action limited to some templates\n(applies_to) was left out because the template of the instance could\nnot be read. Omitted when false.", + "type": "boolean" } } }, @@ -12529,6 +12533,10 @@ "name": { "type": "string" }, + "secret_unreadable": { + "description": "SecretUnreadable is set by the repository when the stored secret cannot\nbe decrypted. Secret is then empty, the channel sends nothing, and a\nPUT with a new secret repairs the row.", + "type": "boolean" + }, "updated_at": { "type": "string" }, @@ -12540,7 +12548,12 @@ } }, "webhook_url": { + "description": "https URL, at most 2048 characters; empty when webhook_url_unreadable.", "type": "string" + }, + "webhook_url_unreadable": { + "description": "WebhookURLUnreadable is set by the repository when the stored webhook\nURL cannot be decrypted. WebhookURL is then empty, the channel sends\nnothing, and a PUT with a new URL repairs the row.", + "type": "boolean" } } }, @@ -12572,6 +12585,10 @@ "name": { "type": "string" }, + "secret_unreadable": { + "description": "SecretUnreadable is set by the repository when the stored secret cannot\nbe decrypted. Secret is then empty, the channel sends nothing, and a\nPUT with a new secret repairs the row.", + "type": "boolean" + }, "subscription_count": { "type": "integer" }, @@ -12579,7 +12596,12 @@ "type": "string" }, "webhook_url": { + "description": "https URL, at most 2048 characters; empty when webhook_url_unreadable.", "type": "string" + }, + "webhook_url_unreadable": { + "description": "WebhookURLUnreadable is set by the repository when the stored webhook\nURL cannot be decrypted. WebhookURL is then empty, the channel sends\nnothing, and a PUT with a new URL repairs the row.", + "type": "boolean" } } }, @@ -13930,11 +13952,20 @@ "name": { "type": "string" }, + "secret_unreadable": { + "description": "SecretUnreadable is set by the repository when the stored secret cannot\nbe decrypted. Secret is then empty, the channel sends nothing, and a\nPUT with a new secret repairs the row.", + "type": "boolean" + }, "updated_at": { "type": "string" }, "webhook_url": { + "description": "https URL, at most 2048 characters; empty when webhook_url_unreadable.", "type": "string" + }, + "webhook_url_unreadable": { + "description": "WebhookURLUnreadable is set by the repository when the stored webhook\nURL cannot be decrypted. WebhookURL is then empty, the channel sends\nnothing, and a PUT with a new URL repairs the row.", + "type": "boolean" } } },