diff --git a/docs/README.md b/docs/README.md index ccbb3db9..683794cc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -85,7 +85,7 @@ Piped, CI, and `NO_COLOR` output remains plain. Machine output is unchanged. | Access tokens | create, list, get, revoke project credentials | `cloud access-tokens …` | [access-tokens.md](access-tokens.md) | | Project | create, list, get, rename, delete, select, get keys and usage | `projects …`, `use` | below | | Functions | deploy, invoke, inspect, set visibility, schedule, alias | `functions …` | [functions.md](functions.md) | -| Durable functions | deploy, start, inspect executions, read logs, schedule | `durable …`, `cloud durable …` | [durable-functions.md](durable-functions.md) | +| Durable functions | deploy, start, inspect executions, read logs, schedule, decide approvals | `durable …`, `cloud durable …` | [durable-functions.md](durable-functions.md) | | Databases | create, inspect, delete, migrate | `databases …`, `migrations …` | [databases.md](databases.md) | | Storage | manage buckets, objects, policies | `storage …` | [storage.md](storage.md) | | Variables | deploy, list, get, delete | `variables …` | [variables.md](variables.md) | diff --git a/docs/durable-functions.md b/docs/durable-functions.md index be937cc4..8b3c2140 100644 --- a/docs/durable-functions.md +++ b/docs/durable-functions.md @@ -70,10 +70,15 @@ existing function's scope alone. | List schedulers | `volcano cloud durable schedulers list ` | | Pause or resume one | `volcano cloud durable schedulers disable\|enable ` | | Delete one | `volcano cloud durable schedulers delete [--yes]` | +| List approvals | `volcano cloud durable approvals list [--function …] [--status pending] [--execution …] [--since …] [--page 1] [--limit 100]` | +| Get one approval | `volcano cloud durable approvals get ` | +| Approve or deny one | `volcano cloud durable approvals approve\|deny [--comment …] [--yes]` | +| Approval stats | `volcano cloud durable approvals stats [--function …] [--since 30d]` | Wherever a command takes a function, it takes the name or the id. `deploy` needs exactly one of `--all` and `-f`. `--yes` skips the confirmation prompt the three -destructive commands ask for. Schedulers require `SUPERAGENT` and are capped at +destructive commands and the two approval decisions ask for. Every `approvals` +command takes `--json`. Schedulers require `SUPERAGENT` and are capped at 5 per project across standard and durable functions together; a create beyond that answers `403`. @@ -163,6 +168,80 @@ executions it already started keep running. `schedulers delete` removes the scheduler and its run history, and also leaves running executions alone — stop those with `executions stop`. +## Approvals + +A workflow that calls `ctx.waitForApproval` pauses until a person approves or +denies it, then resumes with the decision. See +[durable functions in the JavaScript SDK](/sdk/js/durable-functions) for the +authoring side. The `approvals` commands are the deciding side: + +```bash +# What is waiting on someone +volcano cloud durable approvals list + +# Read what the workflow is asking, with the details it attached +volcano cloud durable approvals get 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20 + +# Decide it; the comment reaches the workflow and stays in the history +volcano cloud durable approvals approve 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20 --comment "Checked stock" +volcano cloud durable approvals deny 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20 +``` + +```text +ID: 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20 +Title: Ship order 4417? +Name: ship-order +Status: approved +Workflow: order-pipeline +Execution: order-4417 (66666666-6666-4666-8666-666666666666, running) +Requested: 2026-10-06T11:00:00Z +Expires: when its execution ends +Details: { + "order": 4417 +} +Decided By: owner@example.com +Decided At: 2026-10-06T11:04:12Z +Comment: Checked stock +✓ Approved "Ship order 4417?"; the workflow resumes with the decision +``` + +`list` shows pending approvals unless `--status` says otherwise. It takes +`pending`, `approved`, `denied`, `expired`, `cancelled`, or `all`. An approval +ends in one of four ways: + +| Status | What happened | What the workflow gets | +|---|---|---| +| `approved` | A person approved it | `approved: true` and the comment | +| `denied` | A person denied it | `approved: false` and the comment | +| `expired` | Its timeout passed with nobody deciding | `status: 'expired'`; the wait resolves rather than failing | +| `cancelled` | Its execution ended first | Nothing; the execution is already over | + +`approve` and `deny` show the title and ask before deciding, because a decision +cannot be changed. Repeating the decision an approval already has changes +nothing and succeeds, so a retried command is safe. A different decision, or +any decision on an expired or cancelled approval, fails with what happened, for +example `approval … was already approved by owner@example.com at …`. An approval +past its timeout counts as expired, even while it still reads `pending`. + +Approvals are decided by a person. A command run with a project access token is +refused before it asks; run `volcano login` with `VOLCANO_TOKEN` unset, or +decide in the dashboard. + +`stats` counts the approvals requested in a window, 30 days unless `--since` +says otherwise, up to 366 days: + +```bash +volcano cloud durable approvals stats --since 7d +``` + +It prints the count in each status, the approval rate (approved out of +approved and denied), the median time from request to decision, and the +workflows that request the most. + +In local mode, drop `cloud`: `volcano durable approvals approve …` decides as the +local user. An approval timeout runs in real time locally, even though plain +waits resolve immediately. + ## Starting is asynchronous `start` returns as soon as the execution is accepted, with the execution's id diff --git a/internal/api/durable_approvals.go b/internal/api/durable_approvals.go new file mode 100644 index 00000000..c9c2261c --- /dev/null +++ b/internal/api/durable_approvals.go @@ -0,0 +1,113 @@ +package api + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/Kong/volcano-cli/internal/apiclient" +) + +// DurableApprovalListInput filters one approval page. Empty and nil fields +// apply no filter, so an empty Status lists approvals in every status. +type DurableApprovalListInput struct { + Function string + Status string + ExecutionID *uuid.UUID + From *time.Time + To *time.Time + Page int + Limit int +} + +// ListDurableApprovals lists one approval page for a project, newest first. +func (c *Client) ListDurableApprovals( + ctx context.Context, projectID uuid.UUID, input DurableApprovalListInput, +) (*apiclient.PaginatedDurableApprovals, error) { + params := &apiclient.ListDurableApprovalsParams{ + Page: &input.Page, + Limit: &input.Limit, + ExecutionId: input.ExecutionID, + From: input.From, + To: input.To, + } + if input.Function != "" { + function := input.Function + params.Function = &function + } + if input.Status != "" { + status := apiclient.DurableApprovalStatus(input.Status) + params.Status = &status + } + + resp, err := c.client.ListDurableApprovalsWithResponse(ctx, projectID, params) + if err != nil { + return nil, err + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400) +} + +// GetDurableApproval returns one approval, including its decision once a +// person has made one. +func (c *Client) GetDurableApproval( + ctx context.Context, projectID, approvalID uuid.UUID, +) (*apiclient.DurableApproval, error) { + resp, err := c.client.GetDurableApprovalWithResponse(ctx, projectID, approvalID) + if err != nil { + return nil, err + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON404) +} + +// GetDurableApprovalStats counts a project's approvals by outcome from `from` +// until `to`. A nil to is the API's now, and a nil from 30 days before to. +func (c *Client) GetDurableApprovalStats( + ctx context.Context, projectID uuid.UUID, function string, from, to *time.Time, +) (*apiclient.DurableApprovalStats, error) { + params := &apiclient.GetDurableApprovalStatsParams{From: from, To: to} + if function != "" { + params.Function = &function + } + + resp, err := c.client.GetDurableApprovalStatsWithResponse(ctx, projectID, params) + if err != nil { + return nil, err + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, resp.JSON400) +} + +// ApproveDurableApproval approves a pending approval, and the workflow +// resumes with the decision. Only a person's credential may decide. +func (c *Client) ApproveDurableApproval( + ctx context.Context, projectID, approvalID uuid.UUID, comment string, +) (*apiclient.DurableApproval, error) { + resp, err := c.client.ApproveDurableApprovalWithResponse( + ctx, projectID, approvalID, durableApprovalDecisionBody(comment)) + if err != nil { + return nil, err + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, + resp.JSON400, resp.JSON403, resp.JSON404, resp.JSON409) +} + +// DenyDurableApproval denies a pending approval. A denial resumes the workflow +// too: it is a decision, not a failure. +func (c *Client) DenyDurableApproval( + ctx context.Context, projectID, approvalID uuid.UUID, comment string, +) (*apiclient.DurableApproval, error) { + resp, err := c.client.DenyDurableApprovalWithResponse( + ctx, projectID, approvalID, durableApprovalDecisionBody(comment)) + if err != nil { + return nil, err + } + return apiResult(resp.StatusCode(), resp.Body, resp.JSON200, + resp.JSON400, resp.JSON403, resp.JSON404, resp.JSON409) +} + +func durableApprovalDecisionBody(comment string) apiclient.DurableApprovalDecisionRequest { + if comment == "" { + return apiclient.DurableApprovalDecisionRequest{} + } + return apiclient.DurableApprovalDecisionRequest{Comment: &comment} +} diff --git a/internal/api/durable_approvals_test.go b/internal/api/durable_approvals_test.go new file mode 100644 index 00000000..2572788d --- /dev/null +++ b/internal/api/durable_approvals_test.go @@ -0,0 +1,152 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var durableTestApprovalID = uuid.MustParse("77777777-7777-4777-8777-777777777777") + +// The decision body is optional on the wire. A comment travels in it, and no +// comment is an empty object rather than an empty string, which the history +// would record as a comment someone left blank. +func TestDecideDurableApprovalSendsTheComment(t *testing.T) { + for _, tc := range []struct { + name string + approve bool + comment string + want map[string]any + }{ + {name: "approve with a comment", approve: true, comment: "Checked stock", want: map[string]any{"comment": "Checked stock"}}, + {name: "deny without one", approve: false, comment: "", want: map[string]any{}}, + } { + t.Run(tc.name, func(t *testing.T) { + var path string + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + path = r.URL.Path + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + writeAPIJSON(t, w, http.StatusOK, durableApprovalAPIPayload()) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token", WithHTTPClient(server.Client())) + require.NoError(t, err) + + decide, action := client.DenyDurableApproval, "deny" + if tc.approve { + decide, action = client.ApproveDurableApproval, "approve" + } + approval, err := decide(context.Background(), durableTestProjectID, durableTestApprovalID, tc.comment) + require.NoError(t, err) + assert.Equal(t, durableTestApprovalID, approval.Id) + assert.Equal(t, "/projects/"+durableTestProjectID.String()+"/durable-approvals/"+ + durableTestApprovalID.String()+"/"+action, path) + assert.Equal(t, tc.want, body) + }) + } +} + +// A 409 carries the API's reason and code, which callers need to tell a +// conflicting decision from any other failure, and one conflict from another. +func TestDecideDurableApprovalSurfacesTheConflict(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + writeAPIJSON(t, w, http.StatusConflict, map[string]any{ + "error": "approval already decided", "code": "approval_decided", + }) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token", WithHTTPClient(server.Client())) + require.NoError(t, err) + + _, err = client.ApproveDurableApproval(context.Background(), durableTestProjectID, durableTestApprovalID, "") + require.Error(t, err) + assert.Equal(t, http.StatusConflict, Status(err)) + assert.Equal(t, "approval already decided", Message(err)) + assert.Equal(t, "approval_decided", Code(err)) +} + +// An unset filter is no filter: sending an empty status or function would ask +// the API for approvals matching the empty string. +func TestListDurableApprovalsSendsOnlyTheFiltersSet(t *testing.T) { + var query map[string][]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + writeAPIJSON(t, w, http.StatusOK, map[string]any{ + "data": []any{}, "page": 1, "limit": 20, "total": 0, "has_more": false, + }) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token", WithHTTPClient(server.Client())) + require.NoError(t, err) + + _, err = client.ListDurableApprovals(context.Background(), durableTestProjectID, + DurableApprovalListInput{Page: 1, Limit: 20}) + require.NoError(t, err) + assert.Equal(t, map[string][]string{"page": {"1"}, "limit": {"20"}}, query) + + from := time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC) + _, err = client.ListDurableApprovals(context.Background(), durableTestProjectID, DurableApprovalListInput{ + Function: "order-pipeline", Status: "pending", From: &from, Page: 1, Limit: 20, + }) + require.NoError(t, err) + assert.Equal(t, []string{"order-pipeline"}, query["function"]) + assert.Equal(t, []string{"pending"}, query["status"]) + assert.Equal(t, []string{"2026-10-05T12:00:00Z"}, query["from"]) +} + +// The API measures a missing `to` from its own clock, so a caller that names +// both ends has to get both onto the wire. +func TestGetDurableApprovalStatsSendsTheWindow(t *testing.T) { + var query map[string][]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + writeAPIJSON(t, w, http.StatusOK, map[string]any{ + "from": "2026-09-06T12:00:00Z", "to": "2026-10-06T12:00:00Z", + }) + })) + defer server.Close() + + client, err := NewClient(server.URL, "token", WithHTTPClient(server.Client())) + require.NoError(t, err) + + _, err = client.GetDurableApprovalStats(context.Background(), durableTestProjectID, "", nil, nil) + require.NoError(t, err) + assert.Empty(t, query) + + from := time.Date(2025, 10, 5, 12, 0, 0, 0, time.UTC) + to := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC) + _, err = client.GetDurableApprovalStats(context.Background(), durableTestProjectID, "order-pipeline", &from, &to) + require.NoError(t, err) + assert.Equal(t, map[string][]string{ + "function": {"order-pipeline"}, + "from": {"2025-10-05T12:00:00Z"}, + "to": {"2026-10-06T12:00:00Z"}, + }, query) +} + +func durableApprovalAPIPayload() map[string]any { + return map[string]any{ + "id": durableTestApprovalID.String(), + "status": "approved", + "name": "ship-order", + "title": "Ship order 4417?", + "description": "", + "function": map[string]any{"id": "55555555-5555-4555-8555-555555555555", "name": "order-pipeline"}, + "execution": map[string]any{"id": durableTestExecutionID.String(), "name": "order-4417", "status": "running"}, + "requested_at": "2026-10-06T11:00:00Z", + "expires_at": nil, + "decision": nil, + } +} diff --git a/internal/api/errors.go b/internal/api/errors.go index c24a7db0..26113f4a 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -18,6 +18,8 @@ var ErrNotFound = errors.New("not found") type Error struct { StatusCode int Message string + // Code is the API's machine-readable code, when the response declares one. + Code string } // Status returns the HTTP status code carried by an *Error wrapped in err, or @@ -39,6 +41,16 @@ func Message(err error) string { return "" } +// Code returns the API's machine-readable code for the *Error wrapped in err, +// or "" if err does not wrap an *Error or the response carried no code. A code +// tells apart refusals that share a status and whose messages may change. +func Code(err error) string { + if apiErr, ok := errors.AsType[*Error](err); ok { + return apiErr.Code + } + return "" +} + func (e *Error) Error() string { if e.StatusCode == 0 { return e.Message @@ -120,7 +132,11 @@ func apiOK(statusCode int, body []byte, generatedErrors ...*apiclient.Error) err func apiErrorFromGeneratedErrors(statusCode int, body []byte, generatedErrors ...*apiclient.Error) error { for _, generatedError := range generatedErrors { if generatedError != nil { - return apiErrorWithMessage(statusCode, generatedError.Error) + apiErr := &Error{StatusCode: statusCode, Message: strings.TrimSpace(generatedError.Error)} + if generatedError.Code != nil { + apiErr.Code = *generatedError.Code + } + return apiErr } } return apiError(statusCode, body) diff --git a/internal/apiclient/client.gen.go b/internal/apiclient/client.gen.go index 8d91ff5e..29c462b8 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -867,6 +867,33 @@ func (e DeploymentProgressCurrentPhase) Valid() bool { } } +// Defines values for DurableApprovalStatus. +const ( + DurableApprovalStatusApproved DurableApprovalStatus = "approved" + DurableApprovalStatusCancelled DurableApprovalStatus = "cancelled" + DurableApprovalStatusDenied DurableApprovalStatus = "denied" + DurableApprovalStatusExpired DurableApprovalStatus = "expired" + DurableApprovalStatusPending DurableApprovalStatus = "pending" +) + +// Valid indicates whether the value is a known member of the DurableApprovalStatus enum. +func (e DurableApprovalStatus) Valid() bool { + switch e { + case DurableApprovalStatusApproved: + return true + case DurableApprovalStatusCancelled: + return true + case DurableApprovalStatusDenied: + return true + case DurableApprovalStatusExpired: + return true + case DurableApprovalStatusPending: + return true + default: + return false + } +} + // Defines values for DurableExecutionOperationKind. const ( DurableExecutionOperationKindCallback DurableExecutionOperationKind = "callback" @@ -3749,22 +3776,22 @@ func (e ListDeploymentsParamsStatus) Valid() bool { // Defines values for ListDeploymentsParamsOperation. const ( - ListDeploymentsParamsOperationDelete ListDeploymentsParamsOperation = "delete" - ListDeploymentsParamsOperationDeploy ListDeploymentsParamsOperation = "deploy" - ListDeploymentsParamsOperationRedeploy ListDeploymentsParamsOperation = "redeploy" - ListDeploymentsParamsOperationUpdate ListDeploymentsParamsOperation = "update" + Delete ListDeploymentsParamsOperation = "delete" + Deploy ListDeploymentsParamsOperation = "deploy" + Redeploy ListDeploymentsParamsOperation = "redeploy" + Update ListDeploymentsParamsOperation = "update" ) // Valid indicates whether the value is a known member of the ListDeploymentsParamsOperation enum. func (e ListDeploymentsParamsOperation) Valid() bool { switch e { - case ListDeploymentsParamsOperationDelete: + case Delete: return true - case ListDeploymentsParamsOperationDeploy: + case Deploy: return true - case ListDeploymentsParamsOperationRedeploy: + case Redeploy: return true - case ListDeploymentsParamsOperationUpdate: + case Update: return true default: return false @@ -5980,6 +6007,155 @@ type DeviceAuthorizationResponse struct { VerificationUriComplete string `json:"verification_uri_complete"` } +// DurableApproval An approval a durable workflow requested. +type DurableApproval struct { + // Decision Who decided and when. Null unless the approval was approved or denied. + Decision *DurableApprovalDecision `json:"decision"` + Description string `json:"description"` + + // Details The JSON value the workflow attached for the person deciding, as + // given. + Details interface{} `json:"details,omitempty"` + + // Execution The durable execution that requested the approval. `id` and `status` + // are null once the execution is no longer retained; `name` is kept. + Execution DurableApprovalExecution `json:"execution"` + + // ExpiresAt When the approval expires if nobody decides. Null when the + // workflow set no timeout; the approval then lasts as long as its + // execution. + ExpiresAt *time.Time `json:"expires_at"` + + // Function The durable function that requested the approval. `id` is null once the + // function has been deleted; `name` is kept. + Function DurableApprovalFunction `json:"function"` + Id openapi_types.UUID `json:"id"` + + // Name The name the workflow gave the approval in `ctx.waitForApproval`. + Name string `json:"name"` + RequestedAt time.Time `json:"requested_at"` + + // Status `pending` means the workflow is waiting for a decision. `approved` and + // `denied` are decisions a person made. `expired` means the workflow's + // approval timeout passed first, and `cancelled` means the execution + // ended while the approval was still pending. Every status but `pending` + // is final. + Status DurableApprovalStatus `json:"status"` + Title string `json:"title"` +} + +// DurableApprovalCounts Approvals by status. `requested` is every approval, whatever its status. +type DurableApprovalCounts struct { + Approved int64 `json:"approved"` + Cancelled int64 `json:"cancelled"` + Denied int64 `json:"denied"` + Expired int64 `json:"expired"` + Pending int64 `json:"pending"` + Requested int64 `json:"requested"` +} + +// DurableApprovalDailyCounts defines model for DurableApprovalDailyCounts. +type DurableApprovalDailyCounts struct { + // Counts Approvals by status. `requested` is every approval, whatever its status. + Counts DurableApprovalCounts `json:"counts"` + Date openapi_types.Date `json:"date"` +} + +// DurableApprovalDecider The person who decided. Null once their account is deleted. +type DurableApprovalDecider struct { + Email string `json:"email"` + Id string `json:"id"` +} + +// DurableApprovalDecision Who decided and when. Null unless the approval was approved or denied. +type DurableApprovalDecision struct { + Comment string `json:"comment"` + DecidedAt time.Time `json:"decided_at"` + + // DecidedBy The person who decided. Null once their account is deleted. + DecidedBy *DurableApprovalDecider `json:"decided_by"` +} + +// DurableApprovalDecisionRequest defines model for DurableApprovalDecisionRequest. +type DurableApprovalDecisionRequest struct { + // Comment A note for the workflow and the approval's history. + Comment *string `json:"comment,omitempty"` +} + +// DurableApprovalExecution The durable execution that requested the approval. `id` and `status` +// are null once the execution is no longer retained; `name` is kept. +type DurableApprovalExecution struct { + Id *openapi_types.UUID `json:"id"` + Name string `json:"name"` + Status *DurableExecutionStatus `json:"status"` +} + +// DurableApprovalFunction The durable function that requested the approval. `id` is null once the +// function has been deleted; `name` is kept. +type DurableApprovalFunction struct { + Id *openapi_types.UUID `json:"id"` + Name string `json:"name"` +} + +// DurableApprovalFunctionCounts defines model for DurableApprovalFunctionCounts. +type DurableApprovalFunctionCounts struct { + // Counts Approvals by status. `requested` is every approval, whatever its status. + Counts DurableApprovalCounts `json:"counts"` + + // Function The durable function that requested the approval. `id` is null once the + // function has been deleted; `name` is kept. + Function DurableApprovalFunction `json:"function"` +} + +// DurableApprovalRegistration The registered approval, as the requesting workflow sees it. +type DurableApprovalRegistration struct { + ExpiresAt *time.Time `json:"expires_at"` + Id openapi_types.UUID `json:"id"` + + // Status `pending` means the workflow is waiting for a decision. `approved` and + // `denied` are decisions a person made. `expired` means the workflow's + // approval timeout passed first, and `cancelled` means the execution + // ended while the approval was still pending. Every status but `pending` + // is final. + Status DurableApprovalStatus `json:"status"` +} + +// DurableApprovalStats defines model for DurableApprovalStats. +type DurableApprovalStats struct { + // ApprovalRate `approved / (approved + denied)`, from 0 to 1. Null when nothing in + // the window was decided. + ApprovalRate *float64 `json:"approval_rate"` + + // Counts Approvals by status. `requested` is every approval, whatever its status. + Counts DurableApprovalCounts `json:"counts"` + + // Daily One entry per UTC day in the window that has approvals, oldest + // first. Days with none are omitted. + Daily []DurableApprovalDailyCounts `json:"daily"` + From time.Time `json:"from"` + + // Functions The ten workflows that requested the most approvals, most first. + // The rest are summed in `other_functions`. + Functions []DurableApprovalFunctionCounts `json:"functions"` + + // MedianSecondsToDecision Median time from request to decision. Null when nothing was decided. + MedianSecondsToDecision *float64 `json:"median_seconds_to_decision"` + + // OtherFunctions Approvals by status. `requested` is every approval, whatever its status. + OtherFunctions DurableApprovalCounts `json:"other_functions"` + + // P90SecondsToDecision 90th percentile time from request to decision. Null when nothing was decided. + P90SecondsToDecision *float64 `json:"p90_seconds_to_decision"` + To time.Time `json:"to"` +} + +// DurableApprovalStatus `pending` means the workflow is waiting for a decision. `approved` and +// `denied` are decisions a person made. `expired` means the workflow's +// approval timeout passed first, and `cancelled` means the execution +// ended while the approval was still pending. Every status but `pending` +// is final. +type DurableApprovalStatus string + // DurableExecution defines model for DurableExecution. type DurableExecution struct { // CompletedAt Present once the execution has reached a terminal status. @@ -7329,6 +7505,23 @@ type PaginatedDatabases struct { Total int `json:"total"` } +// PaginatedDurableApprovals defines model for PaginatedDurableApprovals. +type PaginatedDurableApprovals struct { + Data []DurableApproval `json:"data"` + + // HasMore Whether there are more pages available + HasMore bool `json:"has_more"` + + // Limit Number of items per page + Limit int `json:"limit"` + + // Page Current page number (1-indexed) + Page int `json:"page"` + + // Total Total number of items across all pages + Total int `json:"total"` +} + // PaginatedDurableExecutions defines model for PaginatedDurableExecutions. type PaginatedDurableExecutions struct { Data []DurableExecution `json:"data"` @@ -9031,6 +9224,22 @@ type RealtimeStats struct { PeakConnections *int `json:"peak_connections,omitempty"` } +// RequestDurableApprovalRequest defines model for RequestDurableApprovalRequest. +type RequestDurableApprovalRequest struct { + // CallbackId Opaque approval reference, supplied by the SDK. + CallbackId string `json:"callback_id"` + Description *string `json:"description,omitempty"` + + // Details Any JSON value to show the person deciding. The whole request is + // limited to 64 KiB. + Details interface{} `json:"details,omitempty"` + + // ExecutionRef Opaque reference to the running execution, supplied by the SDK. + ExecutionRef string `json:"execution_ref"` + Name string `json:"name"` + Title string `json:"title"` +} + // ResolveFunctionResponse defines model for ResolveFunctionResponse. type ResolveFunctionResponse struct { // CacheTtlSeconds Suggested SDK cache TTL for this name-to-ID mapping @@ -9892,6 +10101,12 @@ type DeploymentResourceType string // DeploymentStatus defines model for DeploymentStatus. type DeploymentStatus string +// DurableApprovalFunctionFilter defines model for DurableApprovalFunctionFilter. +type DurableApprovalFunctionFilter = string + +// DurableApprovalId defines model for DurableApprovalId. +type DurableApprovalId = openapi_types.UUID + // DurableExecutionId defines model for DurableExecutionId. type DurableExecutionId = openapi_types.UUID @@ -10973,6 +11188,50 @@ type ListProjectCustomDomainsParams struct { Search *Search `form:"search,omitempty" json:"search,omitempty"` } +// ListDurableApprovalsParams defines parameters for ListDurableApprovals. +type ListDurableApprovalsParams struct { + // Page Page number (1-indexed) for offset pagination. Declares no schema + // default so the request validator does not inject one: handlers that omit + // `page` see it unset (nil) and default to 1 in code, while cursor-first + // endpoints (e.g. the project deployments feed) can detect its absence to + // stay in keyset/search mode. Supplying `page` selects offset pagination. + Page *Page `form:"page,omitempty" json:"page,omitempty"` + + // Limit Number of items per page (max 100) + Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` + + // Status Return only approvals in this status. + Status *DurableApprovalStatus `form:"status,omitempty" json:"status,omitempty"` + + // Function Only approvals requested by this durable function, given by id or by + // name. A name also matches approvals from a deleted function of that + // name. + Function *DurableApprovalFunctionFilter `form:"function,omitempty" json:"function,omitempty"` + + // ExecutionId Return only approvals requested by this durable execution. + ExecutionId *openapi_types.UUID `form:"execution_id,omitempty" json:"execution_id,omitempty"` + + // From Return only approvals requested at or after this time. + From *time.Time `form:"from,omitempty" json:"from,omitempty"` + + // To Return only approvals requested before this time. + To *time.Time `form:"to,omitempty" json:"to,omitempty"` +} + +// GetDurableApprovalStatsParams defines parameters for GetDurableApprovalStats. +type GetDurableApprovalStatsParams struct { + // Function Only approvals requested by this durable function, given by id or by + // name. A name also matches approvals from a deleted function of that + // name. + Function *DurableApprovalFunctionFilter `form:"function,omitempty" json:"function,omitempty"` + + // From Start of the window. Defaults to 30 days before `to`. + From *time.Time `form:"from,omitempty" json:"from,omitempty"` + + // To End of the window. Defaults to now. + To *time.Time `form:"to,omitempty" json:"to,omitempty"` +} + // ListDurableFunctionsParams defines parameters for ListDurableFunctions. type ListDurableFunctionsParams struct { // Page Page number (1-indexed) for offset pagination. Declares no schema @@ -11811,6 +12070,9 @@ type QueryDatabaseSelectJSONRequestBody = DatabaseSelectRequest // QueryDatabaseUpdateJSONRequestBody defines body for QueryDatabaseUpdate for application/json ContentType. type QueryDatabaseUpdateJSONRequestBody = DatabaseUpdateRequest +// RequestDurableApprovalJSONRequestBody defines body for RequestDurableApproval for application/json ContentType. +type RequestDurableApprovalJSONRequestBody = RequestDurableApprovalRequest + // StartDurableExecutionFromApplicationJSONRequestBody defines body for StartDurableExecutionFromApplication for application/json ContentType. type StartDurableExecutionFromApplicationJSONRequestBody = StartDurableExecutionFromApplicationJSONBody @@ -11895,6 +12157,12 @@ type CreateDatabaseRestoreJSONRequestBody = CreateDatabaseRestoreRequest // UpdateDatabaseTypeJSONRequestBody defines body for UpdateDatabaseType for application/json ContentType. type UpdateDatabaseTypeJSONRequestBody = UpdateDatabaseTypeRequest +// ApproveDurableApprovalJSONRequestBody defines body for ApproveDurableApproval for application/json ContentType. +type ApproveDurableApprovalJSONRequestBody = DurableApprovalDecisionRequest + +// DenyDurableApprovalJSONRequestBody defines body for DenyDurableApproval for application/json ContentType. +type DenyDurableApprovalJSONRequestBody = DurableApprovalDecisionRequest + // CreateDurableFunctionMultipartRequestBody defines body for CreateDurableFunction for multipart/form-data ContentType. type CreateDurableFunctionMultipartRequestBody CreateDurableFunctionMultipartBody @@ -13576,6 +13844,11 @@ type ClientInterface interface { // ListDeployments request ListDeployments(ctx context.Context, params *ListDeploymentsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // RequestDurableApprovalWithBody request with any body + RequestDurableApprovalWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + RequestDurableApproval(ctx context.Context, body RequestDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // StartDurableExecutionFromApplicationWithBody request with any body StartDurableExecutionFromApplicationWithBody(ctx context.Context, functionId DurableFunctionId, params *StartDurableExecutionFromApplicationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -13923,6 +14196,25 @@ type ClientInterface interface { // ListProjectCustomDomains request ListProjectCustomDomains(ctx context.Context, id ProjectId, params *ListProjectCustomDomainsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListDurableApprovals request + ListDurableApprovals(ctx context.Context, id ProjectId, params *ListDurableApprovalsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetDurableApprovalStats request + GetDurableApprovalStats(ctx context.Context, id ProjectId, params *GetDurableApprovalStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetDurableApproval request + GetDurableApproval(ctx context.Context, id ProjectId, approvalId DurableApprovalId, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ApproveDurableApprovalWithBody request with any body + ApproveDurableApprovalWithBody(ctx context.Context, id ProjectId, approvalId DurableApprovalId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ApproveDurableApproval(ctx context.Context, id ProjectId, approvalId DurableApprovalId, body ApproveDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DenyDurableApprovalWithBody request with any body + DenyDurableApprovalWithBody(ctx context.Context, id ProjectId, approvalId DurableApprovalId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + DenyDurableApproval(ctx context.Context, id ProjectId, approvalId DurableApprovalId, body DenyDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // ListDurableFunctions request ListDurableFunctions(ctx context.Context, id ProjectId, params *ListDurableFunctionsParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -15356,6 +15648,30 @@ func (c *Client) ListDeployments(ctx context.Context, params *ListDeploymentsPar return c.Client.Do(req) } +func (c *Client) RequestDurableApprovalWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRequestDurableApprovalRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) RequestDurableApproval(ctx context.Context, body RequestDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewRequestDurableApprovalRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) StartDurableExecutionFromApplicationWithBody(ctx context.Context, functionId DurableFunctionId, params *StartDurableExecutionFromApplicationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewStartDurableExecutionFromApplicationRequestWithBody(c.Server, functionId, params, contentType, body) if err != nil { @@ -16856,6 +17172,90 @@ func (c *Client) ListProjectCustomDomains(ctx context.Context, id ProjectId, par return c.Client.Do(req) } +func (c *Client) ListDurableApprovals(ctx context.Context, id ProjectId, params *ListDurableApprovalsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewListDurableApprovalsRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetDurableApprovalStats(ctx context.Context, id ProjectId, params *GetDurableApprovalStatsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetDurableApprovalStatsRequest(c.Server, id, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetDurableApproval(ctx context.Context, id ProjectId, approvalId DurableApprovalId, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetDurableApprovalRequest(c.Server, id, approvalId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ApproveDurableApprovalWithBody(ctx context.Context, id ProjectId, approvalId DurableApprovalId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewApproveDurableApprovalRequestWithBody(c.Server, id, approvalId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ApproveDurableApproval(ctx context.Context, id ProjectId, approvalId DurableApprovalId, body ApproveDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewApproveDurableApprovalRequest(c.Server, id, approvalId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DenyDurableApprovalWithBody(ctx context.Context, id ProjectId, approvalId DurableApprovalId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDenyDurableApprovalRequestWithBody(c.Server, id, approvalId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DenyDurableApproval(ctx context.Context, id ProjectId, approvalId DurableApprovalId, body DenyDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDenyDurableApprovalRequest(c.Server, id, approvalId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) ListDurableFunctions(ctx context.Context, id ProjectId, params *ListDurableFunctionsParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewListDurableFunctionsRequest(c.Server, id, params) if err != nil { @@ -21351,6 +21751,46 @@ func NewListDeploymentsRequest(server string, params *ListDeploymentsParams) (*h return req, nil } +// NewRequestDurableApprovalRequest calls the generic RequestDurableApproval builder with application/json body +func NewRequestDurableApprovalRequest(server string, body RequestDurableApprovalJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewRequestDurableApprovalRequestWithBody(server, "application/json", bodyReader) +} + +// NewRequestDurableApprovalRequestWithBody generates requests for RequestDurableApproval with any type of body +func NewRequestDurableApprovalRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/durable-approvals") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewStartDurableExecutionFromApplicationRequest calls the generic StartDurableExecutionFromApplication builder with application/json body func NewStartDurableExecutionFromApplicationRequest(server string, functionId DurableFunctionId, params *StartDurableExecutionFromApplicationParams, body StartDurableExecutionFromApplicationJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -26571,42 +27011,256 @@ func NewListProjectDeploymentsRequest(server string, id ProjectId, params *ListP } - if params.CreatedAfter != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "created_after", *params.CreatedAfter, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.ResourceType != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "resource_type", *params.ResourceType, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - - if params.FunctionKind != nil { - - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "function_kind", *params.FunctionKind, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } - - } - + if params.CreatedAfter != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "created_after", *params.CreatedAfter, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.ResourceType != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "resource_type", *params.ResourceType, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.FunctionKind != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "function_kind", *params.FunctionKind, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewSummarizeProjectDeploymentsRequest generates requests for SummarizeProjectDeployments +func NewSummarizeProjectDeploymentsRequest(server string, id ProjectId, params *SummarizeProjectDeploymentsParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/deployments/summary", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Search != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "search", *params.Search, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "resource_type", params.ResourceType, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + if params.FunctionKind != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "function_kind", *params.FunctionKind, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.CreatedAfter != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "created_after", *params.CreatedAfter, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListProjectCustomDomainsRequest generates requests for ListProjectCustomDomains +func NewListProjectCustomDomainsRequest(server string, id ProjectId, params *ListProjectCustomDomainsParams) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/domains", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + // queryValues collects non-styled parameters (passthrough, JSON) + // that are safe to round-trip through url.Values.Encode(). + queryValues := queryURL.Query() + // rawQueryFragments collects pre-encoded query fragments from + // styled parameters, preserving literal commas as delimiters + // per the OpenAPI spec (e.g. "color=blue,black,brown"). + var rawQueryFragments []string + + if params.Page != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Limit != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "limit", *params.Limit, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Cursor != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "cursor", *params.Cursor, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.EndingBefore != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "ending_before", *params.EndingBefore, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Offset != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "offset", *params.Offset, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Search != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "search", *params.Search, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + if encoded := queryValues.Encode(); encoded != "" { rawQueryFragments = append(rawQueryFragments, encoded) } @@ -26621,8 +27275,8 @@ func NewListProjectDeploymentsRequest(server string, id ProjectId, params *ListP return req, nil } -// NewSummarizeProjectDeploymentsRequest generates requests for SummarizeProjectDeployments -func NewSummarizeProjectDeploymentsRequest(server string, id ProjectId, params *SummarizeProjectDeploymentsParams) (*http.Request, error) { +// NewListDurableApprovalsRequest generates requests for ListDurableApprovals +func NewListDurableApprovalsRequest(server string, id ProjectId, params *ListDurableApprovalsParams) (*http.Request, error) { var err error var pathParam0 string @@ -26637,7 +27291,7 @@ func NewSummarizeProjectDeploymentsRequest(server string, id ProjectId, params * return nil, err } - operationPath := fmt.Sprintf("/projects/%s/deployments/summary", pathParam0) + operationPath := fmt.Sprintf("/projects/%s/durable-approvals", pathParam0) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -26656,9 +27310,9 @@ func NewSummarizeProjectDeploymentsRequest(server string, id ProjectId, params * // per the OpenAPI spec (e.g. "color=blue,black,brown"). var rawQueryFragments []string - if params.Search != nil { + if params.Page != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "search", *params.Search, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -26668,17 +27322,21 @@ func NewSummarizeProjectDeploymentsRequest(server string, id ProjectId, params * } - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "resource_type", params.ResourceType, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) + if params.Limit != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "limit", *params.Limit, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } } + } - if params.FunctionKind != nil { + if params.Status != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "function_kind", *params.FunctionKind, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "status", *params.Status, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -26688,9 +27346,45 @@ func NewSummarizeProjectDeploymentsRequest(server string, id ProjectId, params * } - if params.CreatedAfter != nil { + if params.Function != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "created_after", *params.CreatedAfter, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "function", *params.Function, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.ExecutionId != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "execution_id", *params.ExecutionId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "uuid"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.From != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "from", *params.From, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { + return nil, err + } else { + for _, qp := range strings.Split(queryFrag, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.To != nil { + + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "to", *params.To, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -26714,8 +27408,8 @@ func NewSummarizeProjectDeploymentsRequest(server string, id ProjectId, params * return req, nil } -// NewListProjectCustomDomainsRequest generates requests for ListProjectCustomDomains -func NewListProjectCustomDomainsRequest(server string, id ProjectId, params *ListProjectCustomDomainsParams) (*http.Request, error) { +// NewGetDurableApprovalStatsRequest generates requests for GetDurableApprovalStats +func NewGetDurableApprovalStatsRequest(server string, id ProjectId, params *GetDurableApprovalStatsParams) (*http.Request, error) { var err error var pathParam0 string @@ -26730,7 +27424,7 @@ func NewListProjectCustomDomainsRequest(server string, id ProjectId, params *Lis return nil, err } - operationPath := fmt.Sprintf("/projects/%s/domains", pathParam0) + operationPath := fmt.Sprintf("/projects/%s/durable-approvals/stats", pathParam0) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -26749,9 +27443,9 @@ func NewListProjectCustomDomainsRequest(server string, id ProjectId, params *Lis // per the OpenAPI spec (e.g. "color=blue,black,brown"). var rawQueryFragments []string - if params.Page != nil { + if params.Function != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "page", *params.Page, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "function", *params.Function, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -26761,9 +27455,9 @@ func NewListProjectCustomDomainsRequest(server string, id ProjectId, params *Lis } - if params.Limit != nil { + if params.From != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "limit", *params.Limit, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "from", *params.From, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -26773,9 +27467,9 @@ func NewListProjectCustomDomainsRequest(server string, id ProjectId, params *Lis } - if params.Cursor != nil { + if params.To != nil { - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "cursor", *params.Cursor, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { + if queryFrag, err := runtime.StyleParamWithOptions("form", true, "to", *params.To, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: "date-time"}); err != nil { return nil, err } else { for _, qp := range strings.Split(queryFrag, "&") { @@ -26785,46 +27479,51 @@ func NewListProjectCustomDomainsRequest(server string, id ProjectId, params *Lis } - if params.EndingBefore != nil { + if encoded := queryValues.Encode(); encoded != "" { + rawQueryFragments = append(rawQueryFragments, encoded) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "ending_before", *params.EndingBefore, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } - } + return req, nil +} - if params.Offset != nil { +// NewGetDurableApprovalRequest generates requests for GetDurableApproval +func NewGetDurableApprovalRequest(server string, id ProjectId, approvalId DurableApprovalId) (*http.Request, error) { + var err error - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "offset", *params.Offset, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "integer", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } + var pathParam0 string - } + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } - if params.Search != nil { + var pathParam1 string - if queryFrag, err := runtime.StyleParamWithOptions("form", true, "search", *params.Search, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationQuery, Type: "string", Format: ""}); err != nil { - return nil, err - } else { - for _, qp := range strings.Split(queryFrag, "&") { - rawQueryFragments = append(rawQueryFragments, qp) - } - } + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "approvalId", approvalId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } - } + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + operationPath := fmt.Sprintf("/projects/%s/durable-approvals/%s", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err } req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) @@ -26835,6 +27534,114 @@ func NewListProjectCustomDomainsRequest(server string, id ProjectId, params *Lis return req, nil } +// NewApproveDurableApprovalRequest calls the generic ApproveDurableApproval builder with application/json body +func NewApproveDurableApprovalRequest(server string, id ProjectId, approvalId DurableApprovalId, body ApproveDurableApprovalJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewApproveDurableApprovalRequestWithBody(server, id, approvalId, "application/json", bodyReader) +} + +// NewApproveDurableApprovalRequestWithBody generates requests for ApproveDurableApproval with any type of body +func NewApproveDurableApprovalRequestWithBody(server string, id ProjectId, approvalId DurableApprovalId, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "approvalId", approvalId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/durable-approvals/%s/approve", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDenyDurableApprovalRequest calls the generic DenyDurableApproval builder with application/json body +func NewDenyDurableApprovalRequest(server string, id ProjectId, approvalId DurableApprovalId, body DenyDurableApprovalJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewDenyDurableApprovalRequestWithBody(server, id, approvalId, "application/json", bodyReader) +} + +// NewDenyDurableApprovalRequestWithBody generates requests for DenyDurableApproval with any type of body +func NewDenyDurableApprovalRequestWithBody(server string, id ProjectId, approvalId DurableApprovalId, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithOptions("simple", false, "id", id, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "approvalId", approvalId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/durable-approvals/%s/deny", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewListDurableFunctionsRequest generates requests for ListDurableFunctions func NewListDurableFunctionsRequest(server string, id ProjectId, params *ListDurableFunctionsParams) (*http.Request, error) { var err error @@ -34450,6 +35257,11 @@ type ClientWithResponsesInterface interface { // ListDeploymentsWithResponse request ListDeploymentsWithResponse(ctx context.Context, params *ListDeploymentsParams, reqEditors ...RequestEditorFn) (*ListDeploymentsClientResponse, error) + // RequestDurableApprovalWithBodyWithResponse request with any body + RequestDurableApprovalWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestDurableApprovalClientResponse, error) + + RequestDurableApprovalWithResponse(ctx context.Context, body RequestDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*RequestDurableApprovalClientResponse, error) + // StartDurableExecutionFromApplicationWithBodyWithResponse request with any body StartDurableExecutionFromApplicationWithBodyWithResponse(ctx context.Context, functionId DurableFunctionId, params *StartDurableExecutionFromApplicationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartDurableExecutionFromApplicationClientResponse, error) @@ -34797,6 +35609,25 @@ type ClientWithResponsesInterface interface { // ListProjectCustomDomainsWithResponse request ListProjectCustomDomainsWithResponse(ctx context.Context, id ProjectId, params *ListProjectCustomDomainsParams, reqEditors ...RequestEditorFn) (*ListProjectCustomDomainsClientResponse, error) + // ListDurableApprovalsWithResponse request + ListDurableApprovalsWithResponse(ctx context.Context, id ProjectId, params *ListDurableApprovalsParams, reqEditors ...RequestEditorFn) (*ListDurableApprovalsClientResponse, error) + + // GetDurableApprovalStatsWithResponse request + GetDurableApprovalStatsWithResponse(ctx context.Context, id ProjectId, params *GetDurableApprovalStatsParams, reqEditors ...RequestEditorFn) (*GetDurableApprovalStatsClientResponse, error) + + // GetDurableApprovalWithResponse request + GetDurableApprovalWithResponse(ctx context.Context, id ProjectId, approvalId DurableApprovalId, reqEditors ...RequestEditorFn) (*GetDurableApprovalClientResponse, error) + + // ApproveDurableApprovalWithBodyWithResponse request with any body + ApproveDurableApprovalWithBodyWithResponse(ctx context.Context, id ProjectId, approvalId DurableApprovalId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ApproveDurableApprovalClientResponse, error) + + ApproveDurableApprovalWithResponse(ctx context.Context, id ProjectId, approvalId DurableApprovalId, body ApproveDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*ApproveDurableApprovalClientResponse, error) + + // DenyDurableApprovalWithBodyWithResponse request with any body + DenyDurableApprovalWithBodyWithResponse(ctx context.Context, id ProjectId, approvalId DurableApprovalId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DenyDurableApprovalClientResponse, error) + + DenyDurableApprovalWithResponse(ctx context.Context, id ProjectId, approvalId DurableApprovalId, body DenyDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*DenyDurableApprovalClientResponse, error) + // ListDurableFunctionsWithResponse request ListDurableFunctionsWithResponse(ctx context.Context, id ProjectId, params *ListDurableFunctionsParams, reqEditors ...RequestEditorFn) (*ListDurableFunctionsClientResponse, error) @@ -37013,6 +37844,43 @@ func (r ListDeploymentsClientResponse) ContentType() string { return "" } +type RequestDurableApprovalClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DurableApprovalRegistration + JSON201 *DurableApprovalRegistration + JSON400 *Error + JSON404 *Error + JSON409 *Error + JSON413 *Error + JSON429 *Error + JSON503 *Error +} + +// Status returns HTTPResponse.Status +func (r RequestDurableApprovalClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r RequestDurableApprovalClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r RequestDurableApprovalClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type StartDurableExecutionFromApplicationClientResponse struct { Body []byte HTTPResponse *http.Response @@ -40258,6 +41126,169 @@ func (r ListProjectCustomDomainsClientResponse) ContentType() string { return "" } +type ListDurableApprovalsClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *PaginatedDurableApprovals + JSON400 *Error +} + +// Status returns HTTPResponse.Status +func (r ListDurableApprovalsClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListDurableApprovalsClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ListDurableApprovalsClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetDurableApprovalStatsClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DurableApprovalStats + JSON400 *Error +} + +// Status returns HTTPResponse.Status +func (r GetDurableApprovalStatsClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetDurableApprovalStatsClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetDurableApprovalStatsClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type GetDurableApprovalClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DurableApproval + JSON404 *Error +} + +// Status returns HTTPResponse.Status +func (r GetDurableApprovalClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetDurableApprovalClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r GetDurableApprovalClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type ApproveDurableApprovalClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DurableApproval + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON409 *Error + JSON413 *Error +} + +// Status returns HTTPResponse.Status +func (r ApproveDurableApprovalClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ApproveDurableApprovalClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r ApproveDurableApprovalClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + +type DenyDurableApprovalClientResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *DurableApproval + JSON400 *Error + JSON403 *Error + JSON404 *Error + JSON409 *Error + JSON413 *Error +} + +// Status returns HTTPResponse.Status +func (r DenyDurableApprovalClientResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DenyDurableApprovalClientResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ContentType is a convenience method to retrieve the Content-Type value from the HTTP response headers +func (r DenyDurableApprovalClientResponse) ContentType() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Header.Get("Content-Type") + } + return "" +} + type ListDurableFunctionsClientResponse struct { Body []byte HTTPResponse *http.Response @@ -45453,6 +46484,23 @@ func (c *ClientWithResponses) ListDeploymentsWithResponse(ctx context.Context, p return ParseListDeploymentsClientResponse(rsp) } +// RequestDurableApprovalWithBodyWithResponse request with arbitrary body returning *RequestDurableApprovalClientResponse +func (c *ClientWithResponses) RequestDurableApprovalWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*RequestDurableApprovalClientResponse, error) { + rsp, err := c.RequestDurableApprovalWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRequestDurableApprovalClientResponse(rsp) +} + +func (c *ClientWithResponses) RequestDurableApprovalWithResponse(ctx context.Context, body RequestDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*RequestDurableApprovalClientResponse, error) { + rsp, err := c.RequestDurableApproval(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseRequestDurableApprovalClientResponse(rsp) +} + // StartDurableExecutionFromApplicationWithBodyWithResponse request with arbitrary body returning *StartDurableExecutionFromApplicationClientResponse func (c *ClientWithResponses) StartDurableExecutionFromApplicationWithBodyWithResponse(ctx context.Context, functionId DurableFunctionId, params *StartDurableExecutionFromApplicationParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartDurableExecutionFromApplicationClientResponse, error) { rsp, err := c.StartDurableExecutionFromApplicationWithBody(ctx, functionId, params, contentType, body, reqEditors...) @@ -46550,6 +47598,67 @@ func (c *ClientWithResponses) ListProjectCustomDomainsWithResponse(ctx context.C return ParseListProjectCustomDomainsClientResponse(rsp) } +// ListDurableApprovalsWithResponse request returning *ListDurableApprovalsClientResponse +func (c *ClientWithResponses) ListDurableApprovalsWithResponse(ctx context.Context, id ProjectId, params *ListDurableApprovalsParams, reqEditors ...RequestEditorFn) (*ListDurableApprovalsClientResponse, error) { + rsp, err := c.ListDurableApprovals(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListDurableApprovalsClientResponse(rsp) +} + +// GetDurableApprovalStatsWithResponse request returning *GetDurableApprovalStatsClientResponse +func (c *ClientWithResponses) GetDurableApprovalStatsWithResponse(ctx context.Context, id ProjectId, params *GetDurableApprovalStatsParams, reqEditors ...RequestEditorFn) (*GetDurableApprovalStatsClientResponse, error) { + rsp, err := c.GetDurableApprovalStats(ctx, id, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetDurableApprovalStatsClientResponse(rsp) +} + +// GetDurableApprovalWithResponse request returning *GetDurableApprovalClientResponse +func (c *ClientWithResponses) GetDurableApprovalWithResponse(ctx context.Context, id ProjectId, approvalId DurableApprovalId, reqEditors ...RequestEditorFn) (*GetDurableApprovalClientResponse, error) { + rsp, err := c.GetDurableApproval(ctx, id, approvalId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetDurableApprovalClientResponse(rsp) +} + +// ApproveDurableApprovalWithBodyWithResponse request with arbitrary body returning *ApproveDurableApprovalClientResponse +func (c *ClientWithResponses) ApproveDurableApprovalWithBodyWithResponse(ctx context.Context, id ProjectId, approvalId DurableApprovalId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ApproveDurableApprovalClientResponse, error) { + rsp, err := c.ApproveDurableApprovalWithBody(ctx, id, approvalId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseApproveDurableApprovalClientResponse(rsp) +} + +func (c *ClientWithResponses) ApproveDurableApprovalWithResponse(ctx context.Context, id ProjectId, approvalId DurableApprovalId, body ApproveDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*ApproveDurableApprovalClientResponse, error) { + rsp, err := c.ApproveDurableApproval(ctx, id, approvalId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseApproveDurableApprovalClientResponse(rsp) +} + +// DenyDurableApprovalWithBodyWithResponse request with arbitrary body returning *DenyDurableApprovalClientResponse +func (c *ClientWithResponses) DenyDurableApprovalWithBodyWithResponse(ctx context.Context, id ProjectId, approvalId DurableApprovalId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DenyDurableApprovalClientResponse, error) { + rsp, err := c.DenyDurableApprovalWithBody(ctx, id, approvalId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDenyDurableApprovalClientResponse(rsp) +} + +func (c *ClientWithResponses) DenyDurableApprovalWithResponse(ctx context.Context, id ProjectId, approvalId DurableApprovalId, body DenyDurableApprovalJSONRequestBody, reqEditors ...RequestEditorFn) (*DenyDurableApprovalClientResponse, error) { + rsp, err := c.DenyDurableApproval(ctx, id, approvalId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDenyDurableApprovalClientResponse(rsp) +} + // ListDurableFunctionsWithResponse request returning *ListDurableFunctionsClientResponse func (c *ClientWithResponses) ListDurableFunctionsWithResponse(ctx context.Context, id ProjectId, params *ListDurableFunctionsParams, reqEditors ...RequestEditorFn) (*ListDurableFunctionsClientResponse, error) { rsp, err := c.ListDurableFunctions(ctx, id, params, reqEditors...) @@ -50171,15 +51280,76 @@ func ParseQueryDatabasePingClientResponse(rsp *http.Response) (*QueryDatabasePin return response, nil } -// ParseQueryDatabaseSelectClientResponse parses an HTTP response from a QueryDatabaseSelectWithResponse call -func ParseQueryDatabaseSelectClientResponse(rsp *http.Response) (*QueryDatabaseSelectClientResponse, error) { +// ParseQueryDatabaseSelectClientResponse parses an HTTP response from a QueryDatabaseSelectWithResponse call +func ParseQueryDatabaseSelectClientResponse(rsp *http.Response) (*QueryDatabaseSelectClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &QueryDatabaseSelectClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DatabaseQueryResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest DatabaseQueryCapExceeded + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + } + + return response, nil +} + +// ParseQueryDatabaseUpdateClientResponse parses an HTTP response from a QueryDatabaseUpdateWithResponse call +func ParseQueryDatabaseUpdateClientResponse(rsp *http.Response) (*QueryDatabaseUpdateClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &QueryDatabaseSelectClientResponse{ + response := &QueryDatabaseUpdateClientResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -50232,22 +51402,22 @@ func ParseQueryDatabaseSelectClientResponse(rsp *http.Response) (*QueryDatabaseS return response, nil } -// ParseQueryDatabaseUpdateClientResponse parses an HTTP response from a QueryDatabaseUpdateWithResponse call -func ParseQueryDatabaseUpdateClientResponse(rsp *http.Response) (*QueryDatabaseUpdateClientResponse, error) { +// ParseListDeploymentsClientResponse parses an HTTP response from a ListDeploymentsWithResponse call +func ParseListDeploymentsClientResponse(rsp *http.Response) (*ListDeploymentsClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &QueryDatabaseUpdateClientResponse{ + response := &ListDeploymentsClientResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest DatabaseQueryResult + var dest PaginatedProjectDeployments if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -50274,46 +51444,46 @@ func ParseQueryDatabaseUpdateClientResponse(rsp *http.Response) (*QueryDatabaseU } response.JSON403 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON404 = &dest - - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: - var dest DatabaseQueryCapExceeded - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON429 = &dest + response.JSON500 = &dest } return response, nil } -// ParseListDeploymentsClientResponse parses an HTTP response from a ListDeploymentsWithResponse call -func ParseListDeploymentsClientResponse(rsp *http.Response) (*ListDeploymentsClientResponse, error) { +// ParseRequestDurableApprovalClientResponse parses an HTTP response from a RequestDurableApprovalWithResponse call +func ParseRequestDurableApprovalClientResponse(rsp *http.Response) (*RequestDurableApprovalClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &ListDeploymentsClientResponse{ + response := &RequestDurableApprovalClientResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest PaginatedProjectDeployments + var dest DurableApprovalRegistration if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } response.JSON200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest DurableApprovalRegistration + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -50321,26 +51491,40 @@ func ParseListDeploymentsClientResponse(rsp *http.Response) (*ListDeploymentsCli } response.JSON400 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON401 = &dest + response.JSON404 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON403 = &dest + response.JSON409 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 413: var dest Error if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON500 = &dest + response.JSON413 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 429: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON429 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 503: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON503 = &dest } @@ -54650,6 +55834,227 @@ func ParseListProjectCustomDomainsClientResponse(rsp *http.Response) (*ListProje return response, nil } +// ParseListDurableApprovalsClientResponse parses an HTTP response from a ListDurableApprovalsWithResponse call +func ParseListDurableApprovalsClientResponse(rsp *http.Response) (*ListDurableApprovalsClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ListDurableApprovalsClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest PaginatedDurableApprovals + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + } + + return response, nil +} + +// ParseGetDurableApprovalStatsClientResponse parses an HTTP response from a GetDurableApprovalStatsWithResponse call +func ParseGetDurableApprovalStatsClientResponse(rsp *http.Response) (*GetDurableApprovalStatsClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetDurableApprovalStatsClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DurableApprovalStats + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + } + + return response, nil +} + +// ParseGetDurableApprovalClientResponse parses an HTTP response from a GetDurableApprovalWithResponse call +func ParseGetDurableApprovalClientResponse(rsp *http.Response) (*GetDurableApprovalClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetDurableApprovalClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DurableApproval + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + } + + return response, nil +} + +// ParseApproveDurableApprovalClientResponse parses an HTTP response from a ApproveDurableApprovalWithResponse call +func ParseApproveDurableApprovalClientResponse(rsp *http.Response) (*ApproveDurableApprovalClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &ApproveDurableApprovalClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DurableApproval + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 413: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON413 = &dest + + } + + return response, nil +} + +// ParseDenyDurableApprovalClientResponse parses an HTTP response from a DenyDurableApprovalWithResponse call +func ParseDenyDurableApprovalClientResponse(rsp *http.Response) (*DenyDurableApprovalClientResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DenyDurableApprovalClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest DurableApproval + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 413: + var dest Error + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON413 = &dest + + } + + return response, nil +} + // ParseListDurableFunctionsClientResponse parses an HTTP response from a ListDurableFunctionsWithResponse call func ParseListDurableFunctionsClientResponse(rsp *http.Response) (*ListDurableFunctionsClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/internal/cmd/durable/approvals/approvals.go b/internal/cmd/durable/approvals/approvals.go new file mode 100644 index 00000000..2dec2d3c --- /dev/null +++ b/internal/cmd/durable/approvals/approvals.go @@ -0,0 +1,206 @@ +// Package approvals wires the volcano durable approvals subcommands. +package approvals + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/Kong/volcano-cli/internal/apiclient" + "github.com/Kong/volcano-cli/internal/confirm" + clidurable "github.com/Kong/volcano-cli/internal/durable" + "github.com/Kong/volcano-cli/internal/output" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" + "github.com/Kong/volcano-cli/internal/theme" +) + +// New returns the durable approvals command. +func New(deps cliruntime.Deps) *cobra.Command { + cmd := &cobra.Command{ + Use: "approvals", + Short: "List and decide durable approvals", + Long: `An approval is a decision a durable workflow waits on. The workflow asks for +one with ctx.waitForApproval and suspends until a person approves or denies it, +or until its timeout passes. Either way the workflow resumes with the outcome. + +Anyone with access to the project can list approvals and read their stats. +Only a person can decide one: approve and deny refuse a project access token.`, + } + cmd.AddCommand(newList(deps)) + cmd.AddCommand(newGet(deps)) + cmd.AddCommand(newStats(deps)) + cmd.AddCommand(newApprove(deps)) + cmd.AddCommand(newDeny(deps)) + return cmd +} + +type decideOptions struct { + deps cliruntime.Deps + approvalID uuid.UUID + decision apiclient.DurableApprovalStatus + comment string + yes bool + jsonOutput bool + in io.Reader + out io.Writer + prompt io.Writer +} + +func newDecideCommand( + deps cliruntime.Deps, decision apiclient.DurableApprovalStatus, use, short, long, example string, +) *cobra.Command { + opts := decideOptions{decision: decision} + cmd := &cobra.Command{ + Use: use, + Short: short, + Long: long, + Example: example, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + approvalID, err := parseApprovalID(args[0]) + if err != nil { + return err + } + opts.deps = deps + opts.approvalID = approvalID + opts.in = cmd.InOrStdin() + opts.out = cmd.OutOrStdout() + // The prompt stays off stdout under --json, so what a script reads + // there is only the approval. + opts.prompt = opts.out + if opts.jsonOutput { + opts.prompt = cmd.ErrOrStderr() + } + return runDecide(cmd.Context(), opts) + }, + } + cmd.Flags().StringVar(&opts.comment, "comment", "", "Note for the workflow and the approval's history") + cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false, "Skip confirmation prompt") + cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "Emit machine-readable JSON") + return cmd +} + +// runDecide reads the approval before deciding, both to show the person what +// they are deciding and to answer without a write when nothing is left to +// decide. A credential that cannot decide is refused first, as the API does, +// whether or not the approval exists. +func runDecide(ctx context.Context, opts decideOptions) error { + service := clidurable.NewService(opts.deps) + if err := service.RequirePerson(); err != nil { + return err + } + approval, err := service.GetApproval(ctx, opts.approvalID) + if err != nil { + return err + } + if conflict := clidurable.ApprovalConflict(approval, opts.decision); conflict != nil { + return conflict + } + + verb := "Approve" + if opts.decision == apiclient.DurableApprovalStatusDenied { + verb = "Deny" + } + + if approval.Status == opts.decision { + if opts.jsonOutput { + return writeJSON(opts.out, approval) + } + output.DurableApproval(opts.out, approval) + output.Note(opts.out, "Already %s%s; nothing changed", approval.Status, + clidurable.DecisionSummary(approval.Decision)) + return nil + } + + if !opts.yes { + confirmed, err := confirm.Action(opts.in, opts.prompt, + fmt.Sprintf("%q was requested by workflow %s. The workflow resumes with your decision, "+ + "and a decision cannot be changed.", + theme.StripControl(approval.Title), theme.StripControl(approval.Function.Name)), + verb+" it?") + if err != nil { + return err + } + if !confirmed { + return nil + } + } + + decided, err := service.DecideApproval(ctx, opts.approvalID, opts.decision, opts.comment) + if err != nil { + return err + } + if opts.jsonOutput { + return writeJSON(opts.out, decided) + } + output.DurableApproval(opts.out, decided) + output.Success(opts.out, "%s %q; the workflow resumes with the decision", + pastTense(opts.decision), theme.StripControl(decided.Title)) + return nil +} + +func pastTense(decision apiclient.DurableApprovalStatus) string { + if decision == apiclient.DurableApprovalStatusDenied { + return "Denied" + } + return "Approved" +} + +// parseApprovalID refuses a malformed id here, where the message can name the +// argument, rather than as a validation error from the API. +func parseApprovalID(value string) (uuid.UUID, error) { + approvalID, err := uuid.Parse(strings.TrimSpace(value)) + if err != nil { + return uuid.Nil, fmt.Errorf("invalid approval ID %q: %w", value, err) + } + return approvalID, nil +} + +// parseSince reads a lookback window. It takes Go durations such as 24h or +// 90m, plus whole days such as 30d, because days are the unit approval +// history is usually read in and time.ParseDuration has none. +func parseSince(value string) (time.Duration, error) { + trimmed := strings.TrimSpace(value) + var window time.Duration + if days, found := strings.CutSuffix(trimmed, "d"); found { + n, err := strconv.Atoi(days) + if err != nil { + return 0, fmt.Errorf("invalid --since %q: use a duration such as 30d, 24h, or 90m", value) + } + window = time.Duration(n) * 24 * time.Hour + } else { + parsed, err := time.ParseDuration(trimmed) + if err != nil { + return 0, fmt.Errorf("invalid --since %q: use a duration such as 30d, 24h, or 90m", value) + } + window = parsed + } + if window <= 0 { + return 0, fmt.Errorf("invalid --since %q: the window has to be longer than zero", value) + } + return window, nil +} + +// sinceStart converts a --since value to the instant that long before now, in +// UTC so the query string carries no offset to encode. +func sinceStart(value string, now time.Time) (time.Time, error) { + window, err := parseSince(value) + if err != nil { + return time.Time{}, err + } + return now.UTC().Add(-window), nil +} + +// writeJSON prints value as indented JSON for --json consumers. +func writeJSON(w io.Writer, value any) error { + encoder := json.NewEncoder(w) + encoder.SetIndent("", " ") + return encoder.Encode(value) +} diff --git a/internal/cmd/durable/approvals/approvals_test.go b/internal/cmd/durable/approvals/approvals_test.go new file mode 100644 index 00000000..4c9edfe9 --- /dev/null +++ b/internal/cmd/durable/approvals/approvals_test.go @@ -0,0 +1,1159 @@ +package approvals + +import ( + "bytes" + "encoding/json" + "io" + "maps" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Kong/volcano-cli/internal/api" + "github.com/Kong/volcano-cli/internal/apiclient" + cliconfig "github.com/Kong/volcano-cli/internal/config" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" +) + +const ( + projectID = "22222222-2222-4222-8222-222222222222" + approvalID = "77777777-7777-4777-8777-777777777777" + functionID = "55555555-5555-4555-8555-555555555555" + executionID = "66666666-6666-4666-8666-666666666666" +) + +const approvalsPath = "/projects/" + projectID + "/durable-approvals" + +func TestApprovalsListPopulatedAndEmpty(t *testing.T) { + for _, tc := range []struct { + name string + body map[string]any + query string + args []string + want []string + }{ + { + name: "pending by default", + body: map[string]any{ + "data": []any{withFields(approvalPayload("pending"), map[string]any{ + "expires_at": time.Now().Add(3 * time.Hour).UTC().Format(time.RFC3339), + })}, + "has_more": true, + "page": 2, + "limit": 5, + "total": 12, + }, + query: "page=2&limit=5&status=pending", + args: []string{"list", "--page", "2", "--limit", "5"}, + want: []string{ + approvalID, + "Ship order 4417?", + "order-pipeline", + "order-4417", + "pending", + "in 2h", + "Showing 1 of 12 approval(s) (page 2, limit 5)", + "Next page: volcano cloud durable approvals list --page 3 --limit 5", + }, + }, + // Offset 3 of the unfiltered set is not offset 3 of the filtered one, so + // a hint that dropped a filter would page past rows the user asked for. + { + name: "filtered with more pages", + body: map[string]any{ + "data": []any{approvalPayload("approved")}, + "has_more": true, + "page": 1, + "limit": 1, + "total": 4, + }, + query: "page=1&limit=1&function=order-pipeline", + args: []string{"list", "--function", "order-pipeline", "--status", "all", "--limit", "1"}, + want: []string{ + "Next page: volcano cloud durable approvals list --function order-pipeline --status all --page 2 --limit 1", + }, + }, + { + name: "no pending approvals", + body: map[string]any{ + "data": []any{}, + "has_more": false, + "page": 1, + "limit": 100, + "total": 0, + }, + query: "page=1&limit=100&status=pending", + args: []string{"list"}, + want: []string{"No pending approvals", "Showing 0 of 0 approval(s)"}, + }, + { + name: "none at all", + body: map[string]any{ + "data": []any{}, + "has_more": false, + "page": 1, + "limit": 100, + "total": 0, + }, + query: "page=1&limit=100", + args: []string{"list", "--status", "all"}, + want: []string{"No approvals found"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, approvalsPath, r.URL.Path) + assert.ElementsMatch(t, splitQuery(tc.query), splitQuery(r.URL.RawQuery)) + respondJSON(t, w, http.StatusOK, tc.body) + })) + defer server.Close() + + out, err := executeCommand(t, newApprovalsCommand(server), tc.args...) + require.NoError(t, err) + for _, want := range tc.want { + assert.Contains(t, out, want) + } + }) + } +} + +// --since is a window a person thinks in, and the API takes the instant it +// starts at. A day suffix is what approval history is read in, so it has to +// work where time.ParseDuration has none. +func TestApprovalsListSendsEveryFilter(t *testing.T) { + setApprovalsTestHome(t) + var query map[string][]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + respondJSON(t, w, http.StatusOK, emptyPage()) + })) + defer server.Close() + + before := time.Now() + _, err := executeCommand(t, newApprovalsCommand(server), "list", + "--function", "order-pipeline", "--status", "Denied", "--execution", executionID, "--since", "7d") + require.NoError(t, err) + + assert.Equal(t, []string{"order-pipeline"}, query["function"]) + assert.Equal(t, []string{"denied"}, query["status"]) + assert.Equal(t, []string{executionID}, query["execution_id"]) + require.Len(t, query["from"], 1) + from, err := time.Parse(time.RFC3339Nano, query["from"][0]) + require.NoError(t, err) + assert.WithinDuration(t, before.Add(-7*24*time.Hour), from, time.Minute) + assert.NotContains(t, query, "to", "the API ends the window at its own now") +} + +func TestApprovalsListWithoutSinceSendsNoWindow(t *testing.T) { + setApprovalsTestHome(t) + var query map[string][]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + respondJSON(t, w, http.StatusOK, emptyPage()) + })) + defer server.Close() + + _, err := executeCommand(t, newApprovalsCommand(server), "list") + require.NoError(t, err) + + assert.NotContains(t, query, "from") + assert.NotContains(t, query, "to") +} + +func TestApprovalsListAcceptsEveryStatusTheContractDeclares(t *testing.T) { + for _, status := range []string{"pending", "approved", "denied", "expired", "cancelled"} { + t.Run(status, func(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, status, r.URL.Query().Get("status")) + respondJSON(t, w, http.StatusOK, emptyPage()) + })) + defer server.Close() + + _, err := executeCommand(t, newApprovalsCommand(server), "list", "--status", status) + require.NoError(t, err) + }) + } +} + +// A value the flag does not take must be refused before any request, with the +// values it does take, rather than reaching the API as a filter it rejects. +func TestApprovalsListRefusesInvalidFlags(t *testing.T) { + for _, tc := range []struct { + name string + args []string + want []string + }{ + { + name: "status", + args: []string{"list", "--status", "waiting"}, + want: []string{`unknown approval status "waiting"`, "pending, approved, denied, expired, cancelled, all"}, + }, + { + name: "execution", + args: []string{"list", "--execution", "order-4417"}, + want: []string{`invalid execution ID "order-4417"`}, + }, + { + name: "since", + args: []string{"list", "--since", "a while"}, + want: []string{`invalid --since "a while"`, "30d, 24h, or 90m"}, + }, + { + name: "zero since", + args: []string{"list", "--since", "0d"}, + want: []string{`invalid --since "0d"`, "longer than zero"}, + }, + { + name: "negative since", + args: []string{"stats", "--since", "-1h"}, + want: []string{`invalid --since "-1h"`}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("an invalid flag must not reach the API") + http.NotFound(w, nil) + })) + defer server.Close() + + _, err := executeCommand(t, newApprovalsCommand(server), tc.args...) + require.Error(t, err) + for _, want := range tc.want { + assert.ErrorContains(t, err, want) + } + }) + } +} + +// --json is for scripts and agents, so every command prints the API's own +// object and nothing else on stdout. +func TestApprovalsJSONOutput(t *testing.T) { + for _, tc := range []struct { + name string + args []string + reply any + check func(t *testing.T, stdout []byte) + }{ + { + name: "list", + args: []string{"list", "--json"}, + reply: withFields(emptyPage(), map[string]any{"data": []any{approvalPayload("pending")}, "total": 1}), + check: func(t *testing.T, stdout []byte) { + t.Helper() + var page apiclient.PaginatedDurableApprovals + require.NoError(t, json.Unmarshal(stdout, &page)) + require.Len(t, page.Data, 1) + assert.Equal(t, "Ship order 4417?", page.Data[0].Title) + }, + }, + { + name: "get", + args: []string{"get", approvalID, "--json"}, + reply: approvalPayload("pending"), + check: func(t *testing.T, stdout []byte) { + t.Helper() + var approval apiclient.DurableApproval + require.NoError(t, json.Unmarshal(stdout, &approval)) + assert.Equal(t, apiclient.DurableApprovalStatusPending, approval.Status) + }, + }, + { + name: "stats", + args: []string{"stats", "--json"}, + reply: statsPayload(), + check: func(t *testing.T, stdout []byte) { + t.Helper() + var stats apiclient.DurableApprovalStats + require.NoError(t, json.Unmarshal(stdout, &stats)) + assert.Equal(t, int64(2), stats.Counts.Approved) + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + respondJSON(t, w, http.StatusOK, tc.reply) + })) + defer server.Close() + + stdout, _, err := executeCommandSplit(t, newApprovalsCommand(server), "", tc.args...) + require.NoError(t, err) + tc.check(t, stdout) + }) + } +} + +func TestApprovalsGetRendersTheApproval(t *testing.T) { + for _, tc := range []struct { + name string + payload map[string]any + want []string + wantMissing []string + }{ + { + name: "pending", + payload: approvalPayload("pending"), + want: []string{ + "Title: Ship order 4417?", + "Name: ship-order", + "Status: pending", + "Workflow: order-pipeline", + "Execution: order-4417 (" + executionID + ", running)", + "Description: Stock is reserved for 3 days.", + "Details: {\n \"items\": 3,\n \"order\": 4417\n}", + "Expires: when its execution ends", + }, + wantMissing: []string{"Decided By:", "(deleted)"}, + }, + { + name: "decided", + payload: withFields(approvalPayload("approved"), map[string]any{ + "decision": decisionPayload("owner@example.com", "Checked stock"), + }), + want: []string{ + "Status: approved", + "Decided By: owner@example.com", + "Decided At:", + "Comment: Checked stock", + }, + }, + // A year of history outlives the workflow, the execution, and the person + // who decided, and each has to read as gone rather than as blank. + { + name: "everything it names is gone", + payload: withFields(approvalPayload("denied"), map[string]any{ + "function": map[string]any{"id": nil, "name": "order-pipeline"}, + "execution": map[string]any{"id": nil, "name": "order-4417", "status": nil}, + "decision": map[string]any{ + "comment": "", + "decided_by": nil, + "decided_at": "2026-10-06T12:00:00Z", + }, + }), + want: []string{ + "Workflow: order-pipeline (deleted)", + "Execution: order-4417 (no longer retained)", + "Decided By: a deleted user", + }, + wantMissing: []string{"Comment:"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, approvalsPath+"/"+approvalID, r.URL.Path) + respondJSON(t, w, http.StatusOK, tc.payload) + })) + defer server.Close() + + out, err := executeCommand(t, newApprovalsCommand(server), "get", approvalID) + require.NoError(t, err) + for _, want := range tc.want { + assert.Contains(t, out, want) + } + for _, missing := range tc.wantMissing { + assert.NotContains(t, out, missing) + } + }) + } +} + +// A workflow writes the title, name, description, and details, and the names +// of itself and its execution, so human output must not hand a terminal the +// bytes it acts on. --json keeps the API's text: a script is not a terminal. +func TestApprovalsHumanOutputStripsControlCharacters(t *testing.T) { + decided := withFields(hostileApprovalPayload("approved"), map[string]any{ + "decision": decisionPayload("owner\x1b[8m@example.com", "Checked\x1b[2K stock"), + }) + for _, tc := range []struct { + name string + args []string + current map[string]any + want []string + wantErr string + }{ + { + name: "get", + args: []string{"get", approvalID}, + current: decided, + want: []string{ + "Title: Ship[2J order?", + "Name: ship]52;c;eA==-order", + "Description: Stockis reserved31m", + `"note": "\u001b[31mred"`, + "Workflow: order[1m-pipeline", + "Execution: order[0m-4417 (", + "Decided By: owner[8m@example.com", + "Comment: Checked[2K stock", + }, + }, + { + name: "list", + args: []string{"list"}, + want: []string{"Ship[2J order?", "order[1m-pipeline", "order[0m-4417"}, + }, + { + name: "approve", + args: []string{"approve", approvalID}, + current: hostileApprovalPayload("pending"), + want: []string{ + `"Ship[2J order?" was requested by workflow order[1m-pipeline.`, + `Approved "Ship[2J order?"`, + "Decided By: owner[8m@example.com", + }, + }, + { + name: "conflict", + args: []string{"deny", approvalID}, + current: decided, + wantErr: "was already approved by owner[8m@example.com at ", + }, + { + name: "stats", + args: []string{"stats"}, + want: []string{"order[1m-pipeline"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET " + approvalsPath: + respondJSON(t, w, http.StatusOK, withFields(emptyPage(), map[string]any{ + "data": []any{hostileApprovalPayload("pending")}, "total": 1, + })) + case "GET " + approvalsPath + "/stats": + respondJSON(t, w, http.StatusOK, withFields(statsPayload(), map[string]any{ + "functions": []any{map[string]any{ + "function": map[string]any{"id": functionID, "name": "order\x1b[1m-pipeline"}, + "counts": countsPayload(1, 2, 1, 0, 0), + }}, + })) + case "GET " + approvalsPath + "/" + approvalID: + respondJSON(t, w, http.StatusOK, tc.current) + case "POST " + approvalsPath + "/" + approvalID + "/approve": + respondJSON(t, w, http.StatusOK, decided) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + out, err := executeCommand(t, newApprovalsCommand(server), tc.args...) + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + requireNoControlCharacters(t, err.Error()) + } else { + require.NoError(t, err) + } + for _, want := range tc.want { + assert.Contains(t, out, want) + } + requireNoControlCharacters(t, out) + }) + } +} + +func TestApprovalsJSONKeepsTheWorkflowTextAsIs(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + respondJSON(t, w, http.StatusOK, hostileApprovalPayload("pending")) + })) + defer server.Close() + + stdout, _, err := executeCommandSplit(t, newApprovalsCommand(server), "", "get", approvalID, "--json") + require.NoError(t, err) + var approval apiclient.DurableApproval + require.NoError(t, json.Unmarshal(stdout, &approval)) + assert.Equal(t, "Ship\x1b[2J order\a?", approval.Title) + assert.Equal(t, "order\x1b[0m-4417", approval.Execution.Name) + assert.Equal(t, map[string]any{"note": "\x1b[31mred\u009b\u007f"}, approval.Details) +} + +// The API answers 404 for an id that never existed and for one in another +// project alike, so the message says which project was searched. +func TestApprovalsNotFoundNamesTheProject(t *testing.T) { + for _, args := range [][]string{ + {"get", approvalID}, + {"approve", approvalID, "--yes"}, + {"deny", approvalID, "--yes"}, + } { + t.Run(args[0], func(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + respondJSON(t, w, http.StatusNotFound, map[string]any{"error": "durable approval not found"}) + })) + defer server.Close() + + _, err := executeCommand(t, newApprovalsCommand(server), args...) + require.EqualError(t, err, "no approval "+approvalID+" in this project") + }) + } +} + +func TestApprovalsRefuseAMalformedID(t *testing.T) { + for _, args := range [][]string{ + {"get", "ship-order"}, + {"approve", "ship-order", "--yes"}, + {"deny", "ship-order", "--yes"}, + } { + t.Run(args[0], func(t *testing.T) { + setApprovalsTestHome(t) + _, err := executeCommand(t, newApprovalsCommand(nil), args...) + require.ErrorContains(t, err, `invalid approval ID "ship-order"`) + }) + } +} + +func TestApprovalsDecideConfirmsWithTheTitle(t *testing.T) { + for _, tc := range []struct { + command string + decision string + comment string + want []string + }{ + { + command: "approve", + decision: "approved", + comment: "Checked stock", + want: []string{"Approve it?", "Status: approved", "Comment: Checked stock", `Approved "Ship order 4417?"`}, + }, + { + command: "deny", + decision: "denied", + comment: "Customer cancelled", + want: []string{"Deny it?", "Status: denied", "Comment: Customer cancelled", `Denied "Ship order 4417?"`}, + }, + } { + t.Run(tc.command, func(t *testing.T) { + setApprovalsTestHome(t) + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET " + approvalsPath + "/" + approvalID: + respondJSON(t, w, http.StatusOK, approvalPayload("pending")) + case "POST " + approvalsPath + "/" + approvalID + "/" + tc.command: + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + respondJSON(t, w, http.StatusOK, withFields(approvalPayload(tc.decision), map[string]any{ + "decision": decisionPayload("owner@example.com", tc.comment), + })) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + out, err := executeCommand(t, newApprovalsCommand(server), tc.command, approvalID, "--comment", tc.comment) + require.NoError(t, err) + assert.Equal(t, map[string]any{"comment": tc.comment}, body) + assert.Contains(t, out, `"Ship order 4417?" was requested by workflow order-pipeline.`) + for _, want := range tc.want { + assert.Contains(t, out, want) + } + }) + } +} + +func TestApprovalsDecideDeclinedLeavesTheApprovalAlone(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Error("deny must not be called when the prompt is declined") + } + respondJSON(t, w, http.StatusOK, approvalPayload("pending")) + })) + defer server.Close() + + stdout, _, err := executeCommandSplit(t, newApprovalsCommand(server), "n\n", "deny", approvalID) + require.NoError(t, err) + assert.Contains(t, string(stdout), "Cancelled.") +} + +// --yes is how a script decides, so it must not wait on a prompt, and no +// comment means no comment rather than an empty one invented on the way. +func TestApprovalsDecideWithYesSkipsThePrompt(t *testing.T) { + setApprovalsTestHome(t) + var raw []byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + respondJSON(t, w, http.StatusOK, approvalPayload("pending")) + return + } + var err error + raw, err = io.ReadAll(r.Body) + require.NoError(t, err) + respondJSON(t, w, http.StatusOK, withFields(approvalPayload("approved"), map[string]any{ + "decision": decisionPayload("owner@example.com", ""), + })) + })) + defer server.Close() + + stdout, _, err := executeCommandSplit(t, newApprovalsCommand(server), "", "approve", approvalID, "--yes") + require.NoError(t, err) + assert.JSONEq(t, `{}`, string(raw)) + assert.NotContains(t, string(stdout), "Approve it?") + assert.Contains(t, string(stdout), "Status: approved") +} + +// Under --json the prompt goes to stderr, so stdout is the approval alone even +// when a person answered a prompt on the way. +func TestApprovalsDecideJSONKeepsThePromptOffStdout(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + respondJSON(t, w, http.StatusOK, approvalPayload("pending")) + return + } + respondJSON(t, w, http.StatusOK, withFields(approvalPayload("approved"), map[string]any{ + "decision": decisionPayload("owner@example.com", ""), + })) + })) + defer server.Close() + + stdout, stderr, err := executeCommandSplit(t, newApprovalsCommand(server), "y\n", "approve", approvalID, "--json") + require.NoError(t, err) + assert.Contains(t, string(stderr), "Approve it?") + var approval apiclient.DurableApproval + require.NoError(t, json.Unmarshal(stdout, &approval)) + assert.Equal(t, apiclient.DurableApprovalStatusApproved, approval.Status) +} + +// The API accepts the same decision again unchanged, so a retried approve is +// safe. It is reported as already done rather than asked about again. +func TestApprovalsRepeatingTheDecisionChangesNothing(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Error("an approval already decided this way needs no second decision") + } + respondJSON(t, w, http.StatusOK, withFields(approvalPayload("approved"), map[string]any{ + "decision": decisionPayload("owner@example.com", "Checked stock"), + })) + })) + defer server.Close() + + out, err := executeCommand(t, newApprovalsCommand(server), "approve", approvalID) + require.NoError(t, err) + assert.NotContains(t, out, "Approve it?") + assert.Contains(t, out, "Already approved by owner@example.com at ") + assert.Contains(t, out, "nothing changed") +} + +// A decision that can no longer be made says what happened instead, and fails, +// so a script does not read a denial that never happened as done. +func TestApprovalsConflictSaysWhatHappened(t *testing.T) { + for _, tc := range []struct { + name string + command string + current map[string]any + want string + }{ + { + name: "already approved", + command: "deny", + current: withFields(approvalPayload("approved"), map[string]any{ + "decision": decisionPayload("owner@example.com", ""), + }), + want: "approval " + approvalID + " was already approved by owner@example.com at ", + }, + { + name: "already denied", + command: "approve", + current: withFields(approvalPayload("denied"), map[string]any{ + "decision": decisionPayload("teammate@example.com", ""), + }), + want: "approval " + approvalID + " was already denied by teammate@example.com at ", + }, + { + name: "expired", + command: "approve", + current: withFields(approvalPayload("expired"), map[string]any{"expires_at": "2026-10-06T12:00:00Z"}), + want: "approval " + approvalID + " expired at ", + }, + { + name: "cancelled", + command: "deny", + current: approvalPayload("cancelled"), + want: "approval " + approvalID + " was cancelled: its execution ended before anyone decided", + }, + } { + t.Run(tc.name, func(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Error("a decision that can no longer be made must not be sent") + } + respondJSON(t, w, http.StatusOK, tc.current) + })) + defer server.Close() + + out, err := executeCommand(t, newApprovalsCommand(server), tc.command, approvalID) + require.ErrorContains(t, err, tc.want) + assert.NotContains(t, out, "it?") + }) + } +} + +// Someone else can decide between the read and the write. The API's 409 does +// not say who, so the approval is read again to tell the person what won. +func TestApprovalsConflictFromTheAPIReadsTheApprovalAgain(t *testing.T) { + setApprovalsTestHome(t) + reads := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + respondJSON(t, w, http.StatusConflict, map[string]any{ + "error": "approval already decided", "code": "approval_decided", + }) + return + } + reads++ + if reads == 1 { + respondJSON(t, w, http.StatusOK, approvalPayload("pending")) + return + } + respondJSON(t, w, http.StatusOK, withFields(approvalPayload("denied"), map[string]any{ + "decision": decisionPayload("teammate@example.com", ""), + })) + })) + defer server.Close() + + _, err := executeCommand(t, newApprovalsCommand(server), "approve", approvalID, "--yes") + require.ErrorContains(t, err, "was already denied by teammate@example.com at ") + assert.Equal(t, 2, reads) +} + +// The deadline can pass while the person reads the prompt. The approval then +// still reads pending, and the API's 409 is answered as the expiry it is. +func TestApprovalsDeadlinePassingBeforeTheDecisionReadsAsExpired(t *testing.T) { + setApprovalsTestHome(t) + reads := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + respondJSON(t, w, http.StatusConflict, map[string]any{ + "error": "approval expired", "code": "approval_expired", + }) + return + } + reads++ + expiresAt := time.Now().Add(time.Hour) + if reads > 1 { + expiresAt = time.Now().Add(-time.Second) + } + respondJSON(t, w, http.StatusOK, withFields(approvalPayload("pending"), map[string]any{ + "expires_at": expiresAt.UTC().Format(time.RFC3339Nano), + })) + })) + defer server.Close() + + out, err := executeCommand(t, newApprovalsCommand(server), "deny", approvalID) + require.ErrorContains(t, err, "approval "+approvalID+" expired at ") + assert.NotContains(t, err.Error(), "409") + assert.Contains(t, out, "Deny it?") + assert.Equal(t, 2, reads) +} + +// A pending approval whose deadline this machine's clock has passed is still +// the API's to refuse: a clock running fast must not turn away a decision the +// API would take. +func TestApprovalsPendingPastTheLocalDeadlineIsLeftToTheAPI(t *testing.T) { + pastDeadline := withFields(approvalPayload("pending"), map[string]any{ + "expires_at": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339), + }) + for _, tc := range []struct { + name string + status int + reply map[string]any + wantErr string + want string + }{ + { + name: "the API takes it", + status: http.StatusOK, + reply: withFields(approvalPayload("approved"), map[string]any{ + "decision": decisionPayload("owner@example.com", ""), + }), + want: `Approved "Ship order 4417?"`, + }, + { + name: "the API refuses it as expired", + status: http.StatusConflict, + reply: map[string]any{"error": "approval expired", "code": "approval_expired"}, + wantErr: "approval " + approvalID + " expired at ", + }, + } { + t.Run(tc.name, func(t *testing.T) { + setApprovalsTestHome(t) + decisions := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.Method + " " + r.URL.Path { + case "GET " + approvalsPath + "/" + approvalID: + respondJSON(t, w, http.StatusOK, pastDeadline) + case "POST " + approvalsPath + "/" + approvalID + "/approve": + decisions++ + respondJSON(t, w, tc.status, tc.reply) + default: + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + defer server.Close() + + out, err := executeCommand(t, newApprovalsCommand(server), "approve", approvalID, "--yes") + assert.Equal(t, 1, decisions, "the decision must reach the API") + if tc.wantErr != "" { + require.ErrorContains(t, err, tc.wantErr) + assert.NotContains(t, err.Error(), "409") + return + } + require.NoError(t, err) + assert.Contains(t, out, tc.want) + }) + } +} + +// The API's own refusals, word for word. A read-only token is refused before +// the decision check gets to it. +const ( + refusedDecision = "project access tokens cannot decide durable approvals; " + + "a person decides in the dashboard or with a platform token" + refusedReadOnly = "project access token is read-only" +) + +// Only a person decides. A project access token is refused by the API, and the +// person reading the refusal is told how to decide instead. The message alone +// is enough, for a credential the CLI does not recognize as a project token. +func TestApprovalsDecideRefusesAProjectAccessToken(t *testing.T) { + for _, tc := range []struct { + name string + token string + message string + }{ + {name: "full-scope project token", token: "pt-approvals-test", message: refusedDecision}, + {name: "read-only project token", token: "pt-approvals-test", message: refusedReadOnly}, + {name: "decision refusal on any credential", token: "", message: refusedDecision}, + {name: "read-only refusal on any credential", token: "", message: refusedReadOnly}, + } { + t.Run(tc.name, func(t *testing.T) { + setApprovalsTestHome(t) + api.ResetLastRefusalForTest() + t.Cleanup(api.ResetLastRefusalForTest) + if tc.token != "" { + t.Setenv("VOLCANO_TOKEN", tc.token) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + respondJSON(t, w, http.StatusOK, approvalPayload("pending")) + return + } + respondJSON(t, w, http.StatusForbidden, map[string]any{"error": tc.message}) + })) + defer server.Close() + + _, err := executeCommand(t, newApprovalsCommand(server), "approve", approvalID, "--yes") + require.EqualError(t, err, needsAPerson) + }) + } +} + +// A credential the CLI knows is a project access token is refused before the +// prompt and before the approval is read: confirming a decision the API will +// refuse wastes the person's yes, and an approval that does not exist would +// otherwise answer with a 404 instead of saying who can decide. +func TestApprovalsDecideRefusesAProjectTokenBeforeAnyRequest(t *testing.T) { + for _, args := range [][]string{{"approve", approvalID}, {"deny", approvalID, "--yes"}} { + t.Run(args[0], func(t *testing.T) { + setApprovalsTestHome(t) + t.Setenv("VOLCANO_TOKEN", "PT-approvals-test") + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("a project access token's decision must not reach the API: %s %s", r.Method, r.URL.Path) + respondJSON(t, w, http.StatusNotFound, map[string]any{"error": "not found"}) + })) + defer server.Close() + + out, err := executeCommand(t, newApprovalsCommand(server), args...) + require.EqualError(t, err, needsAPerson) + assert.NotContains(t, out, "Approve it?") + }) + } +} + +const needsAPerson = "approvals are decided by a person. " + + "Run `volcano login` with VOLCANO_TOKEN unset, or decide in the dashboard" + +// Any other refusal keeps the API's own reason: telling a person who is +// already logged in to log in would send them after the wrong thing. +func TestApprovalsDecideKeepsOtherRefusals(t *testing.T) { + setApprovalsTestHome(t) + api.ResetLastRefusalForTest() + t.Cleanup(api.ResetLastRefusalForTest) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + respondJSON(t, w, http.StatusOK, approvalPayload("pending")) + return + } + respondJSON(t, w, http.StatusForbidden, map[string]any{"error": "forbidden"}) + })) + defer server.Close() + + _, err := executeCommand(t, newApprovalsCommand(server), "deny", approvalID, "--yes") + require.ErrorContains(t, err, "failed to deny approval "+approvalID+": HTTP 403: forbidden") +} + +func TestApprovalsStatsRendersTheWindow(t *testing.T) { + setApprovalsTestHome(t) + var query map[string][]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, approvalsPath+"/stats", r.URL.Path) + query = r.URL.Query() + respondJSON(t, w, http.StatusOK, statsPayload()) + })) + defer server.Close() + + before := time.Now() + out, err := executeCommand(t, newApprovalsCommand(server), "stats", "--function", "order-pipeline") + require.NoError(t, err) + + assert.Equal(t, []string{"order-pipeline"}, query["function"]) + from, to := windowSent(t, query) + assert.WithinDuration(t, before, to, time.Minute) + assert.Equal(t, 30*24*time.Hour, to.Sub(from), "--since defaults to 30d") + + for _, want := range []string{ + "Requested: 5", + "Pending: 1", + "Approved: 2", + "Denied: 1", + "Expired: 1", + "Cancelled: 0", + "Approval Rate: 67%", + "Median Time to Decision: 13s", + "order-pipeline", + "(other workflows)", + } { + assert.Contains(t, out, want) + } +} + +// The API refuses a window over 366 days and measures a missing end from its +// own clock, which runs later than the one the start was measured from. The +// window is sent with both ends so the longest one the API takes still fits. +func TestApprovalsStatsSendsTheLongestWindowWhole(t *testing.T) { + setApprovalsTestHome(t) + var query map[string][]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + respondJSON(t, w, http.StatusOK, statsPayload()) + })) + defer server.Close() + + _, err := executeCommand(t, newApprovalsCommand(server), "stats", "--since", "366d") + require.NoError(t, err) + from, to := windowSent(t, query) + assert.Equal(t, 366*24*time.Hour, to.Sub(from)) +} + +// The API owns the longest window it counts, so a longer one is its refusal +// to explain, and a CLI already released follows when the limit changes. +func TestApprovalsStatsLeavesTheWindowLimitToTheAPI(t *testing.T) { + setApprovalsTestHome(t) + var query map[string][]string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + query = r.URL.Query() + respondJSON(t, w, http.StatusBadRequest, map[string]any{"error": "the window can span at most 366 days"}) + })) + defer server.Close() + + _, err := executeCommand(t, newApprovalsCommand(server), "stats", "--since", "367d") + require.ErrorContains(t, err, "the window can span at most 366 days") + from, to := windowSent(t, query) + assert.Equal(t, 367*24*time.Hour, to.Sub(from)) +} + +// Nothing decided in the window is not a rate of zero, and must not read as +// one. +func TestApprovalsStatsWithNothingDecided(t *testing.T) { + setApprovalsTestHome(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + respondJSON(t, w, http.StatusOK, withFields(statsPayload(), map[string]any{ + "approval_rate": nil, + "median_seconds_to_decision": nil, + "p90_seconds_to_decision": nil, + "functions": []any{}, + "other_functions": countsPayload(0, 0, 0, 0, 0), + })) + })) + defer server.Close() + + out, err := executeCommand(t, newApprovalsCommand(server), "stats", "--since", "1h") + require.NoError(t, err) + assert.Contains(t, out, "Approval Rate: -") + assert.Contains(t, out, "Median Time to Decision: -") + assert.NotContains(t, out, "Workflow ") +} + +func windowSent(t *testing.T, query map[string][]string) (time.Time, time.Time) { + t.Helper() + require.Len(t, query["from"], 1) + require.Len(t, query["to"], 1) + from, err := time.Parse(time.RFC3339Nano, query["from"][0]) + require.NoError(t, err) + to, err := time.Parse(time.RFC3339Nano, query["to"][0]) + require.NoError(t, err) + return from, to +} + +func newApprovalsCommand(server *httptest.Server) *cobra.Command { + deps := cliruntime.Deps{CommandPathPrefix: "volcano cloud"} + if server != nil { + deps.HTTPClient = server.Client() + deps.APIBaseURL = server.URL + } + return New(deps) +} + +func executeCommand(t *testing.T, cmd *cobra.Command, args ...string) (string, error) { + t.Helper() + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetIn(bytes.NewBufferString("y\n")) + cmd.SetArgs(args) + err := cmd.Execute() + return out.String(), err +} + +func executeCommandSplit(t *testing.T, cmd *cobra.Command, stdin string, args ...string) ([]byte, []byte, error) { + t.Helper() + var stdout, stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetIn(bytes.NewBufferString(stdin)) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.Bytes(), stderr.Bytes(), err +} + +func setApprovalsTestHome(t *testing.T) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("VOLCANO_TOKEN", "") + t.Setenv("VOLCANO_PROJECT_ID", "") + t.Setenv("VOLCANO_API_URL", "") + t.Setenv("VOLCANO_FIRST_PARTY_DEVICE_CLIENT_ID", "") + + cfg := &cliconfig.Config{ + UserToken: "token", + CurrentProject: &cliconfig.ProjectConfig{ID: projectID, Name: "Beta"}, + } + require.NoError(t, cfg.Save()) +} + +func respondJSON(t *testing.T, w http.ResponseWriter, status int, value any) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + require.NoError(t, json.NewEncoder(w).Encode(value)) +} + +func approvalPayload(status string) map[string]any { + return map[string]any{ + "id": approvalID, + "status": status, + "name": "ship-order", + "title": "Ship order 4417?", + "description": "Stock is reserved for 3 days.", + "details": map[string]any{"order": 4417, "items": 3}, + "function": map[string]any{"id": functionID, "name": "order-pipeline"}, + "execution": map[string]any{"id": executionID, "name": "order-4417", "status": "running"}, + "requested_at": "2026-10-06T11:00:00Z", + "expires_at": nil, + "decision": nil, + } +} + +// hostileApprovalPayload carries terminal escapes in every field a workflow +// writes: C0 sequences, a C1 CSI, DEL, and a line break. +func hostileApprovalPayload(status string) map[string]any { + return withFields(approvalPayload(status), map[string]any{ + "title": "Ship\x1b[2J order\a?", + "name": "ship\x1b]52;c;eA==\x07-order", + "description": "Stock\r\nis reserved\u009b31m", + "details": map[string]any{"note": "\x1b[31mred\u009b\u007f"}, + "function": map[string]any{"id": functionID, "name": "order\x1b[1m-pipeline"}, + "execution": map[string]any{"id": executionID, "name": "order\x1b[0m-4417", "status": "running"}, + }) +} + +// requireNoControlCharacters allows only the line breaks the renderer writes. +func requireNoControlCharacters(t *testing.T, text string) { + t.Helper() + for _, r := range text { + if r != '\n' && (r < 0x20 || (r >= 0x7f && r <= 0x9f)) { + require.Failf(t, "control character in human output", "%U in %q", r, text) + } + } +} + +func decisionPayload(email, comment string) map[string]any { + return map[string]any{ + "comment": comment, + "decided_by": map[string]any{"id": "user-1", "email": email}, + "decided_at": "2026-10-06T12:00:00Z", + } +} + +func statsPayload() map[string]any { + return map[string]any{ + "from": "2026-09-06T12:00:00Z", + "to": "2026-10-06T12:00:00Z", + "counts": countsPayload(1, 2, 1, 1, 0), + "approval_rate": 2.0 / 3.0, + "median_seconds_to_decision": 12.5, + "p90_seconds_to_decision": 30, + "functions": []any{map[string]any{ + "function": map[string]any{"id": functionID, "name": "order-pipeline"}, + "counts": countsPayload(1, 2, 1, 0, 0), + }}, + "other_functions": countsPayload(0, 0, 0, 1, 0), + "daily": []any{}, + } +} + +func countsPayload(pending, approved, denied, expired, cancelled int) map[string]any { + return map[string]any{ + "requested": pending + approved + denied + expired + cancelled, + "pending": pending, + "approved": approved, + "denied": denied, + "expired": expired, + "cancelled": cancelled, + } +} + +func emptyPage() map[string]any { + return map[string]any{"data": []any{}, "page": 1, "limit": 100, "total": 0, "has_more": false} +} + +func withFields(payload, fields map[string]any) map[string]any { + maps.Copy(payload, fields) + return payload +} + +func splitQuery(query string) []string { + if query == "" { + return nil + } + return strings.Split(query, "&") +} diff --git a/internal/cmd/durable/approvals/approve.go b/internal/cmd/durable/approvals/approve.go new file mode 100644 index 00000000..8646e065 --- /dev/null +++ b/internal/cmd/durable/approvals/approve.go @@ -0,0 +1,33 @@ +package approvals + +import ( + "fmt" + + "github.com/spf13/cobra" + + "github.com/Kong/volcano-cli/internal/apiclient" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" +) + +func newApprove(deps cliruntime.Deps) *cobra.Command { + return newDecideCommand(deps, apiclient.DurableApprovalStatusApproved, + "approve ", + "Approve a pending durable approval", + `Approve one pending approval. The workflow waiting on it resumes, and +ctx.waitForApproval returns the decision with approved set to true and your +comment. + +Approving one that is already approved reports it and changes nothing. One that +was denied, expired, or cancelled cannot be approved; the command says which +and exits non-zero. + +Only a person can decide: log in with 'volcano login'. A project access token +is refused. + +By default this command prompts for confirmation, showing the approval's +title. Use --yes to skip the prompt.`, + fmt.Sprintf(` %s + %s`, + cliruntime.CommandPath(deps, "durable approvals approve 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20"), + cliruntime.CommandPath(deps, `durable approvals approve 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20 --comment "Checked stock" --yes`))) +} diff --git a/internal/cmd/durable/approvals/deny.go b/internal/cmd/durable/approvals/deny.go new file mode 100644 index 00000000..ff83d340 --- /dev/null +++ b/internal/cmd/durable/approvals/deny.go @@ -0,0 +1,30 @@ +package approvals + +import ( + "github.com/spf13/cobra" + + "github.com/Kong/volcano-cli/internal/apiclient" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" +) + +func newDeny(deps cliruntime.Deps) *cobra.Command { + return newDecideCommand(deps, apiclient.DurableApprovalStatusDenied, + "deny ", + "Deny a pending durable approval", + `Deny one pending approval. A denial is a decision, not a failure: the workflow +waiting on it resumes, and ctx.waitForApproval returns the decision with +approved set to false and your comment. What happens next is up to the +workflow. + +Denying one that is already denied reports it and changes nothing. One that was +approved, expired, or cancelled cannot be denied; the command says which and +exits non-zero. + +Only a person can decide: log in with 'volcano login'. A project access token +is refused. + +By default this command prompts for confirmation, showing the approval's +title. Use --yes to skip the prompt.`, + " "+cliruntime.CommandPath(deps, + `durable approvals deny 0b6f3c1e-8a4d-4f7e-9c2b-5d1a7e3f9b20 --comment "Customer cancelled"`)) +} diff --git a/internal/cmd/durable/approvals/get.go b/internal/cmd/durable/approvals/get.go new file mode 100644 index 00000000..4ece1d7c --- /dev/null +++ b/internal/cmd/durable/approvals/get.go @@ -0,0 +1,56 @@ +package approvals + +import ( + "context" + "io" + + "github.com/google/uuid" + "github.com/spf13/cobra" + + clidurable "github.com/Kong/volcano-cli/internal/durable" + "github.com/Kong/volcano-cli/internal/output" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" +) + +type getOptions struct { + deps cliruntime.Deps + approvalID uuid.UUID + jsonOutput bool + out io.Writer +} + +func newGet(deps cliruntime.Deps) *cobra.Command { + opts := getOptions{} + cmd := &cobra.Command{ + Use: "get ", + Short: "Get one durable approval", + Long: `Show one approval: its title, description, and the details the workflow +attached, the workflow and execution that asked, and the decision once a person +has made one.`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + approvalID, err := parseApprovalID(args[0]) + if err != nil { + return err + } + opts.deps = deps + opts.approvalID = approvalID + opts.out = cmd.OutOrStdout() + return runGet(cmd.Context(), opts) + }, + } + cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "Emit machine-readable JSON") + return cmd +} + +func runGet(ctx context.Context, opts getOptions) error { + approval, err := clidurable.NewService(opts.deps).GetApproval(ctx, opts.approvalID) + if err != nil { + return err + } + if opts.jsonOutput { + return writeJSON(opts.out, approval) + } + output.DurableApproval(opts.out, approval) + return nil +} diff --git a/internal/cmd/durable/approvals/list.go b/internal/cmd/durable/approvals/list.go new file mode 100644 index 00000000..ea20cf4c --- /dev/null +++ b/internal/cmd/durable/approvals/list.go @@ -0,0 +1,161 @@ +package approvals + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/google/uuid" + "github.com/spf13/cobra" + + "github.com/Kong/volcano-cli/internal/api" + "github.com/Kong/volcano-cli/internal/apiclient" + clidurable "github.com/Kong/volcano-cli/internal/durable" + "github.com/Kong/volcano-cli/internal/output" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" +) + +// statusAll is the --status value that lists approvals in every status. The +// API spells that as no filter at all. +const statusAll = "all" + +type listOptions struct { + deps cliruntime.Deps + function string + status string + execution string + since string + page int + limit int + jsonOutput bool + out io.Writer +} + +func newList(deps cliruntime.Deps) *cobra.Command { + opts := listOptions{} + cmd := &cobra.Command{ + Use: "list", + Short: "List durable approvals", + Long: `List the project's approvals, newest first. By default only the pending ones, +which are the approvals a workflow is waiting on; --status all includes the +decided, expired, and cancelled ones too, which are kept for a year.`, + Example: fmt.Sprintf(` %s + %s + %s`, + cliruntime.CommandPath(deps, "durable approvals list"), + cliruntime.CommandPath(deps, "durable approvals list --function order-pipeline --status all"), + cliruntime.CommandPath(deps, "durable approvals list --status denied --since 7d")), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + opts.deps = deps + opts.out = cmd.OutOrStdout() + return runList(cmd.Context(), opts) + }, + } + cmd.Flags().StringVar(&opts.function, "function", "", "Only approvals requested by this durable function (name or id)") + cmd.Flags().StringVar(&opts.status, "status", string(apiclient.DurableApprovalStatusPending), + "Only approvals in this status ("+approvalStatuses()+")") + cmd.Flags().StringVar(&opts.execution, "execution", "", "Only approvals requested by this execution (id)") + cmd.Flags().StringVar(&opts.since, "since", "", "Only approvals requested within this window, such as 30d or 24h") + cmd.Flags().IntVar(&opts.page, "page", api.DefaultPage, "Page number to fetch") + cmd.Flags().IntVar(&opts.limit, "limit", api.DefaultLimit, "Number of approvals per page") + cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "Emit machine-readable JSON") + return cmd +} + +func runList(ctx context.Context, opts listOptions) error { + status, err := normalizeStatusFilter(opts.status) + if err != nil { + return err + } + input := api.DurableApprovalListInput{ + Function: strings.TrimSpace(opts.function), + Status: status, + Page: opts.page, + Limit: opts.limit, + } + if opts.execution != "" { + executionID, err := uuid.Parse(strings.TrimSpace(opts.execution)) + if err != nil { + return fmt.Errorf("invalid execution ID %q: %w", opts.execution, err) + } + input.ExecutionID = &executionID + } + // Only the start is sent. The API ends the window at its own now, so an + // approval requested a moment ago is listed even when this clock is behind. + if opts.since != "" { + from, err := sinceStart(opts.since, time.Now()) + if err != nil { + return err + } + input.From = &from + } + + approvals, err := clidurable.NewService(opts.deps).ListApprovals(ctx, input) + if err != nil { + return err + } + if opts.jsonOutput { + return writeJSON(opts.out, approvals) + } + output.DurableApprovals(opts.out, approvals, status, listCommand(opts, status)) + return nil +} + +// listCommand rebuilds the command the page was listed with, minus paging, so +// the next-page hint keeps every filter: offset N of the unfiltered set is not +// offset N of a filtered one. +func listCommand(opts listOptions, status string) string { + parts := []string{cliruntime.CommandPath(opts.deps, "durable approvals list")} + if function := strings.TrimSpace(opts.function); function != "" { + parts = append(parts, "--function", function) + } + switch status { + case "": + parts = append(parts, "--status", statusAll) + case string(apiclient.DurableApprovalStatusPending): + default: + parts = append(parts, "--status", status) + } + if opts.execution != "" { + parts = append(parts, "--execution", strings.TrimSpace(opts.execution)) + } + if opts.since != "" { + parts = append(parts, "--since", strings.TrimSpace(opts.since)) + } + return strings.Join(parts, " ") +} + +// normalizeStatusFilter returns the status to send, "" for every status, or +// an error naming what the flag takes. What counts as a status comes from the +// generated enum, so a status the API adds is accepted without a CLI change. +func normalizeStatusFilter(value string) (string, error) { + status := strings.ToLower(strings.TrimSpace(value)) + if status == statusAll { + return "", nil + } + if apiclient.DurableApprovalStatus(status).Valid() { + return status, nil + } + return "", fmt.Errorf("unknown approval status %q: --status takes one of %s", + strings.TrimSpace(value), approvalStatuses()) +} + +// approvalStatuses lists the filter's accepted values, in the order the +// contract declares them. +func approvalStatuses() string { + statuses := []apiclient.DurableApprovalStatus{ + apiclient.DurableApprovalStatusPending, + apiclient.DurableApprovalStatusApproved, + apiclient.DurableApprovalStatusDenied, + apiclient.DurableApprovalStatusExpired, + apiclient.DurableApprovalStatusCancelled, + } + names := make([]string, 0, len(statuses)+1) + for _, status := range statuses { + names = append(names, string(status)) + } + return strings.Join(append(names, statusAll), ", ") +} diff --git a/internal/cmd/durable/approvals/stats.go b/internal/cmd/durable/approvals/stats.go new file mode 100644 index 00000000..e8931995 --- /dev/null +++ b/internal/cmd/durable/approvals/stats.go @@ -0,0 +1,72 @@ +package approvals + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/spf13/cobra" + + clidurable "github.com/Kong/volcano-cli/internal/durable" + "github.com/Kong/volcano-cli/internal/output" + cliruntime "github.com/Kong/volcano-cli/internal/runtime" +) + +type statsOptions struct { + deps cliruntime.Deps + function string + since string + jsonOutput bool + out io.Writer +} + +func newStats(deps cliruntime.Deps) *cobra.Command { + opts := statsOptions{} + cmd := &cobra.Command{ + Use: "stats", + Short: "Count durable approvals by outcome", + Long: `Count the project's approvals by outcome over a window: how many were +approved, denied, expired, or are still pending, the approval rate, the median +time to a decision, and the workflows that asked most. + +An approval is counted in the window it was requested in. The window can reach +back up to a year.`, + Example: fmt.Sprintf(` %s + %s`, + cliruntime.CommandPath(deps, "durable approvals stats"), + cliruntime.CommandPath(deps, "durable approvals stats --function order-pipeline --since 7d")), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + opts.deps = deps + opts.out = cmd.OutOrStdout() + return runStats(cmd.Context(), opts) + }, + } + cmd.Flags().StringVar(&opts.function, "function", "", "Only approvals requested by this durable function (name or id)") + cmd.Flags().StringVar(&opts.since, "since", "30d", "Window to count, such as 30d or 24h") + cmd.Flags().BoolVar(&opts.jsonOutput, "json", false, "Emit machine-readable JSON") + return cmd +} + +func runStats(ctx context.Context, opts statsOptions) error { + // Both ends are sent: the API measures a missing end from its own clock, + // and the window would no longer be as long as asked, which matters for + // the longest one it counts. + to := time.Now().UTC() + from, err := sinceStart(opts.since, to) + if err != nil { + return err + } + + stats, err := clidurable.NewService(opts.deps).ApprovalStats(ctx, strings.TrimSpace(opts.function), &from, &to) + if err != nil { + return err + } + if opts.jsonOutput { + return writeJSON(opts.out, stats) + } + output.DurableApprovalStats(opts.out, stats) + return nil +} diff --git a/internal/cmd/durable/durable.go b/internal/cmd/durable/durable.go index c4494ce2..36d423fd 100644 --- a/internal/cmd/durable/durable.go +++ b/internal/cmd/durable/durable.go @@ -4,6 +4,7 @@ package durable import ( "github.com/spf13/cobra" + approvalscmd "github.com/Kong/volcano-cli/internal/cmd/durable/approvals" executionscmd "github.com/Kong/volcano-cli/internal/cmd/durable/executions" schedulerscmd "github.com/Kong/volcano-cli/internal/cmd/durable/schedulers" cliruntime "github.com/Kong/volcano-cli/internal/runtime" @@ -39,5 +40,6 @@ collections never accept each other's names or ids.`, cmd.AddCommand(newDelete(deps)) cmd.AddCommand(executionscmd.New(deps)) cmd.AddCommand(schedulerscmd.New(deps)) + cmd.AddCommand(approvalscmd.New(deps)) return cmd } diff --git a/internal/docs/parse.go b/internal/docs/parse.go index 72a03443..2aa6b495 100644 --- a/internal/docs/parse.go +++ b/internal/docs/parse.go @@ -4,6 +4,8 @@ import ( "regexp" "strconv" "strings" + + "github.com/Kong/volcano-cli/internal/theme" ) // Section is a heading-delimited slice of a document. Sections are the unit of @@ -247,21 +249,7 @@ func docTitle(docPath string, lines []string) string { if title == "" { title = deriveTitle(docPath, lines[fmLen:]) } - return stripControl(title) -} - -// stripControl drops C0/C1 control characters (turning tabs into spaces) so -// decoded frontmatter titles are safe to print to a terminal. -func stripControl(s string) string { - return strings.Map(func(r rune) rune { - if r == '\t' { - return ' ' - } - if r < 0x20 || (r >= 0x7f && r <= 0x9f) { - return -1 - } - return r - }, s) + return theme.StripControl(title) } func deriveTitle(docPath string, lines []string) string { diff --git a/internal/durable/durable.go b/internal/durable/durable.go index ad8ea4d3..274d72f3 100644 --- a/internal/durable/durable.go +++ b/internal/durable/durable.go @@ -9,16 +9,21 @@ package durable import ( "context" + "errors" "fmt" "net/http" + "strings" + "time" "github.com/google/uuid" "github.com/Kong/volcano-cli/internal/api" "github.com/Kong/volcano-cli/internal/apiclient" + "github.com/Kong/volcano-cli/internal/config" clifunction "github.com/Kong/volcano-cli/internal/function" cliruntime "github.com/Kong/volcano-cli/internal/runtime" clisession "github.com/Kong/volcano-cli/internal/session" + "github.com/Kong/volcano-cli/internal/theme" ) // Service performs durable function workflows against the current project. @@ -339,6 +344,184 @@ func (s Service) DeleteScheduler(ctx context.Context, identifier string, schedul return nil } +// ListApprovals returns one page of the project's approvals, newest first. +func (s Service) ListApprovals( + ctx context.Context, input api.DurableApprovalListInput, +) (*apiclient.PaginatedDurableApprovals, error) { + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return nil, err + } + + approvals, err := authenticated.API.ListDurableApprovals(ctx, authenticated.ProjectID, input) + if err != nil { + return nil, fmt.Errorf("failed to list approvals: %w", err) + } + return approvals, nil +} + +// GetApproval returns one approval of the current project. +func (s Service) GetApproval(ctx context.Context, approvalID uuid.UUID) (*apiclient.DurableApproval, error) { + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return nil, err + } + + approval, err := authenticated.API.GetDurableApproval(ctx, authenticated.ProjectID, approvalID) + if err != nil { + return nil, approvalError("get", approvalID, err) + } + return approval, nil +} + +// ApprovalStats counts the project's approvals by outcome between from and +// to. A nil to is the API's now, and a nil from 30 days before to. +func (s Service) ApprovalStats( + ctx context.Context, function string, from, to *time.Time, +) (*apiclient.DurableApprovalStats, error) { + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return nil, err + } + + stats, err := authenticated.API.GetDurableApprovalStats(ctx, authenticated.ProjectID, function, from, to) + if err != nil { + return nil, fmt.Errorf("failed to get approval stats: %w", err) + } + return stats, nil +} + +// DecideApproval approves or denies one approval, and returns it as decided. +// decision is apiclient.DurableApprovalStatusApproved or +// apiclient.DurableApprovalStatusDenied. +// +// The API answers a conflicting decision with 409 and no detail of what got +// there first, so the approval is read again to say who decided it, or when it +// expired. +func (s Service) DecideApproval( + ctx context.Context, approvalID uuid.UUID, decision apiclient.DurableApprovalStatus, comment string, +) (*apiclient.DurableApproval, error) { + authenticated, err := s.sessions.CurrentProject() + if err != nil { + return nil, err + } + + decide := authenticated.API.ApproveDurableApproval + action := "approve" + if decision == apiclient.DurableApprovalStatusDenied { + decide = authenticated.API.DenyDurableApproval + action = "deny" + } + + approval, err := decide(ctx, authenticated.ProjectID, approvalID, comment) + if err == nil { + return approval, nil + } + switch api.Status(err) { + case http.StatusForbidden: + if api.LastRefusal().ProjectAccessToken || refusesProjectTokenDecision(api.Message(err)) { + return nil, errApprovalNeedsAPerson + } + case http.StatusConflict: + current, getErr := authenticated.API.GetDurableApproval(ctx, authenticated.ProjectID, approvalID) + if getErr == nil { + if conflict := ApprovalConflict(current, decision); conflict != nil { + return nil, conflict + } + // Past its deadline by the API's clock, an approval still reads + // pending until the background pass marks it expired. + if api.Code(err) == "approval_expired" { + return nil, approvalExpired(current) + } + } + } + return nil, approvalError(action, approvalID, err) +} + +// errApprovalNeedsAPerson is the answer to a project access token deciding. +// Approvals exist so that a person signs off, which is why the API refuses +// any credential that is not a person's. VOLCANO_TOKEN overrides a login, and +// is how a project access token usually arrives. +var errApprovalNeedsAPerson = errors.New( + "approvals are decided by a person. Run `volcano login` with VOLCANO_TOKEN unset, or decide in the dashboard") + +// RequirePerson refuses a credential the CLI knows is a project access token, +// so a decision is not confirmed only to be refused. DecideApproval still maps +// the API's refusal, for credentials without a recognizable prefix. +func (s Service) RequirePerson() error { + cfg, err := s.sessions.Config() + if err != nil { + return err + } + if config.IsProjectToken(cfg.Token()) { + return errApprovalNeedsAPerson + } + return nil +} + +// refusesProjectTokenDecision reports whether a 403 message is the API +// refusing a project access token's decision. A read-only token is refused +// before the decision check, but a full one would be refused next, so both +// get the same answer. +func refusesProjectTokenDecision(message string) bool { + return strings.Contains(message, "cannot decide durable approvals") || + strings.Contains(message, "project access token is read-only") +} + +// ApprovalConflict says why decision cannot be applied to approval, or returns +// nil when it can: the approval is pending, or already carries that same +// decision, which the API accepts again unchanged. Whether a pending +// approval's deadline has passed is left to the API: this machine's clock may +// disagree with the one that decides. +func ApprovalConflict(approval *apiclient.DurableApproval, decision apiclient.DurableApprovalStatus) error { + if approval == nil || approval.Status == decision { + return nil + } + + switch approval.Status { + case apiclient.DurableApprovalStatusPending: + return nil + case apiclient.DurableApprovalStatusApproved, apiclient.DurableApprovalStatusDenied: + return fmt.Errorf("approval %s was already %s%s", approval.Id, approval.Status, DecisionSummary(approval.Decision)) + case apiclient.DurableApprovalStatusExpired: + return approvalExpired(approval) + case apiclient.DurableApprovalStatusCancelled: + return fmt.Errorf("approval %s was cancelled: its execution ended before anyone decided", approval.Id) + default: + return fmt.Errorf("approval %s is %s and can no longer be decided", approval.Id, approval.Status) + } +} + +func approvalExpired(approval *apiclient.DurableApproval) error { + if approval.ExpiresAt != nil { + return fmt.Errorf("approval %s expired at %s before anyone decided", + approval.Id, approval.ExpiresAt.Local().Format(time.RFC3339)) + } + return fmt.Errorf("approval %s expired before anyone decided", approval.Id) +} + +// DecisionSummary renders who decided and when as " by at