From 4f228b32c92b2dce9a1e5de856d350ceab7e70f0 Mon Sep 17 00:00:00 2001 From: subnetmarco <88.marco@gmail.com> Date: Tue, 6 Oct 2026 15:17:04 -0700 Subject: [PATCH 01/11] feat(durable): add durable approvals commands `volcano [cloud] durable approvals` lists, reads, counts, approves and denies the approvals a workflow opens with `ctx.waitForApproval`. Approve and deny show the title and confirm unless `--yes`; every command takes `--json`. A 403 for a project access token says a person has to decide. A 409 reads the approval again and says who decided it and when, or when it expired. Repeating the decision an approval already has succeeds and changes nothing, as the API does. The vendored contract gains the approvals paths and schemas from the hosting bundle, and the client is regenerated from it. The cloud and local-mode E2E round trips pin `@volcano.dev/sdk` ^1.16.0, the first release with `waitForApproval`. Co-authored-by: Cursor --- docs/README.md | 2 +- docs/durable-functions.md | 79 +- internal/api/durable_approvals.go | 111 ++ internal/api/durable_approvals_test.go | 121 ++ internal/apiclient/client.gen.go | 1537 ++++++++++++++++- internal/cmd/durable/approvals/approvals.go | 200 +++ .../cmd/durable/approvals/approvals_test.go | 851 +++++++++ internal/cmd/durable/approvals/approve.go | 33 + internal/cmd/durable/approvals/deny.go | 30 + internal/cmd/durable/approvals/get.go | 56 + internal/cmd/durable/approvals/list.go | 158 ++ internal/cmd/durable/approvals/stats.go | 68 + internal/cmd/durable/durable.go | 2 + internal/durable/durable.go | 144 ++ internal/output/durable.go | 196 +++ openapi/openapi.yaml | 671 +++++++ tests/e2e/api/durable_approvals_test.go | 211 +++ tests/e2e/localmode/durable_approvals_test.go | 210 +++ tests/e2e/localmode/localmode_test.go | 9 +- 19 files changed, 4610 insertions(+), 79 deletions(-) create mode 100644 internal/api/durable_approvals.go create mode 100644 internal/api/durable_approvals_test.go create mode 100644 internal/cmd/durable/approvals/approvals.go create mode 100644 internal/cmd/durable/approvals/approvals_test.go create mode 100644 internal/cmd/durable/approvals/approve.go create mode 100644 internal/cmd/durable/approvals/deny.go create mode 100644 internal/cmd/durable/approvals/get.go create mode 100644 internal/cmd/durable/approvals/list.go create mode 100644 internal/cmd/durable/approvals/stats.go create mode 100644 tests/e2e/api/durable_approvals_test.go create mode 100644 tests/e2e/localmode/durable_approvals_test.go diff --git a/docs/README.md b/docs/README.md index be981e3b..c9c38e18 100644 --- a/docs/README.md +++ b/docs/README.md @@ -84,7 +84,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, 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 e01dfb5c..b6dc1b57 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 30d] [--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,78 @@ 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 …`. + +Approvals are decided by a person. A command run with a project access token is +refused; run `volcano login`, 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..f269fe81 --- /dev/null +++ b/internal/api/durable_approvals.go @@ -0,0 +1,111 @@ +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 + 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, + } + 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 now. A nil from leaves the window to the API's default. +func (c *Client) GetDurableApprovalStats( + ctx context.Context, projectID uuid.UUID, function string, from *time.Time, +) (*apiclient.DurableApprovalStats, error) { + params := &apiclient.GetDurableApprovalStatsParams{From: from} + 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..f627eaeb --- /dev/null +++ b/internal/api/durable_approvals_test.go @@ -0,0 +1,121 @@ +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, which callers need to tell a conflicting +// decision from any other failure. +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)) +} + +// 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"]) +} + +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/apiclient/client.gen.go b/internal/apiclient/client.gen.go index a35061ab..384f837a 100644 --- a/internal/apiclient/client.gen.go +++ b/internal/apiclient/client.gen.go @@ -737,6 +737,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 DurableExecutionStatus. const ( DurableExecutionStatusFailed DurableExecutionStatus = "failed" @@ -3460,16 +3487,16 @@ func (e ListUserSessionsParamsSort) Valid() bool { // Defines values for ListUserSessionsParamsStatus. const ( - ListUserSessionsParamsStatusActive ListUserSessionsParamsStatus = "active" - ListUserSessionsParamsStatusExpired ListUserSessionsParamsStatus = "expired" + Active ListUserSessionsParamsStatus = "active" + Expired ListUserSessionsParamsStatus = "expired" ) // Valid indicates whether the value is a known member of the ListUserSessionsParamsStatus enum. func (e ListUserSessionsParamsStatus) Valid() bool { switch e { - case ListUserSessionsParamsStatusActive: + case Active: return true - case ListUserSessionsParamsStatusExpired: + case Expired: return true default: return false @@ -5271,6 +5298,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. @@ -6344,6 +6520,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"` @@ -7824,6 +8017,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 @@ -8605,6 +8814,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 @@ -9559,6 +9774,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 @@ -10293,6 +10552,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 + // InvokeFunctionJSONRequestBody defines body for InvokeFunction for application/json ContentType. type InvokeFunctionJSONRequestBody = FunctionInvocationRequest @@ -10362,6 +10624,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 @@ -11836,6 +12104,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) + // GetDefaultEmailTemplates request GetDefaultEmailTemplates(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -12134,6 +12407,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) @@ -13497,6 +13789,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) GetDefaultEmailTemplates(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetDefaultEmailTemplatesRequest(c.Server) if err != nil { @@ -14781,6 +15097,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 { @@ -18997,6 +19397,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 +} + // NewGetDefaultEmailTemplatesRequest generates requests for GetDefaultEmailTemplates func NewGetDefaultEmailTemplatesRequest(server string) (*http.Request, error) { var err error @@ -23714,8 +24154,347 @@ 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) { +// 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) + } + queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + } + + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListDurableApprovalsRequest generates requests for ListDurableApprovals +func NewListDurableApprovalsRequest(server string, id ProjectId, params *ListDurableApprovalsParams) (*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/durable-approvals", 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.Status != 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, "&") { + rawQueryFragments = append(rawQueryFragments, qp) + } + } + + } + + if params.Function != 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, "&") { + 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 +} + +// NewGetDurableApprovalStatsRequest generates requests for GetDurableApprovalStats +func NewGetDurableApprovalStatsRequest(server string, id ProjectId, params *GetDurableApprovalStatsParams) (*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/durable-approvals/stats", 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.Function != 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.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, "&") { + 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 +} + +// NewGetDurableApprovalRequest generates requests for GetDurableApproval +func NewGetDurableApprovalRequest(server string, id ProjectId, approvalId DurableApprovalId) (*http.Request, error) { var err error var pathParam0 string @@ -23725,12 +24504,19 @@ func NewListProjectCustomDomainsRequest(server string, id ProjectId, params *Lis 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/domains", pathParam0) + operationPath := fmt.Sprintf("/projects/%s/durable-approvals/%s", pathParam0, pathParam1) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -23740,98 +24526,119 @@ func NewListProjectCustomDomainsRequest(server string, id ProjectId, params *Lis 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 + req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + if err != nil { + return nil, err + } - if params.Page != nil { + return req, 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) - } - } +// 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 - if params.Limit != nil { + var pathParam0 string - 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) - } - } + 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 - if params.Cursor != nil { + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "approvalId", approvalId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err + } - 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) - } - } + 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 + } - if params.EndingBefore != nil { + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } - 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.MethodPost, queryURL.String(), body) + if err != nil { + return nil, err + } - } + req.Header.Add("Content-Type", contentType) - if params.Offset != nil { + return req, 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) - } - } +// 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 - if params.Search != nil { + var pathParam0 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) - } - } + 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 - if encoded := queryValues.Encode(); encoded != "" { - rawQueryFragments = append(rawQueryFragments, encoded) - } - queryURL.RawQuery = strings.Join(rawQueryFragments, "&") + pathParam1, err = runtime.StyleParamWithOptions("simple", false, "approvalId", approvalId, runtime.StyleParamOptions{ParamLocation: runtime.ParamLocationPath, Type: "string", Format: "uuid"}) + if err != nil { + return nil, err } - req, err := http.NewRequest(http.MethodGet, queryURL.String(), nil) + 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 } @@ -30649,6 +31456,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) + // GetDefaultEmailTemplatesWithResponse request GetDefaultEmailTemplatesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetDefaultEmailTemplatesClientResponse, error) @@ -30947,6 +31759,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) @@ -33059,6 +33890,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 GetDefaultEmailTemplatesClientResponse struct { Body []byte HTTPResponse *http.Response @@ -35860,6 +36728,167 @@ 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 +} + +// 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 +} + +// 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 @@ -40489,6 +41518,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) +} + // GetDefaultEmailTemplatesWithResponse request returning *GetDefaultEmailTemplatesClientResponse func (c *ClientWithResponses) GetDefaultEmailTemplatesWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetDefaultEmailTemplatesClientResponse, error) { rsp, err := c.GetDefaultEmailTemplates(ctx, reqEditors...) @@ -41429,6 +42475,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...) @@ -44958,6 +46065,81 @@ func ParseListDeploymentsClientResponse(rsp *http.Response) (*ListDeploymentsCli return response, nil } +// 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 := &RequestDurableApprovalClientResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + 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 { + return nil, err + } + response.JSON400 = &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 + + 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 + + } + + return response, nil +} + // ParseGetDefaultEmailTemplatesClientResponse parses an HTTP response from a GetDefaultEmailTemplatesWithResponse call func ParseGetDefaultEmailTemplatesClientResponse(rsp *http.Response) (*GetDefaultEmailTemplatesClientResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -48608,6 +49790,213 @@ 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 + + } + + 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 + + } + + 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..4a862079 --- /dev/null +++ b/internal/cmd/durable/approvals/approvals.go @@ -0,0 +1,200 @@ +// 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" +) + +// 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. +func runDecide(ctx context.Context, opts decideOptions) error { + service := clidurable.NewService(opts.deps) + 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.", approval.Title, 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), 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 +} + +// sinceTime converts a --since value to the start of the window it names. +// Sent in UTC so the query string carries no offset to encode. +func sinceTime(value string, now time.Time) (*time.Time, error) { + window, err := parseSince(value) + if err != nil { + return nil, err + } + from := now.Add(-window).UTC() + return &from, 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..684da737 --- /dev/null +++ b/internal/cmd/durable/approvals/approvals_test.go @@ -0,0 +1,851 @@ +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") +} + +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) + } + }) + } +} + +// 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) +} + +// 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. +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: "approvals are decided by a person; use a platform token or the dashboard", + }, + { + name: "read-only project token", + token: "pt-approvals-test", + message: "read-only project access tokens cannot modify resources", + }, + { + name: "decision message on any credential", + token: "", + message: "approvals are decided by a person; use a platform token or the dashboard", + }, + } { + 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, + "approvals are decided by a person. Run `volcano login`, 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"]) + require.Len(t, query["from"], 1) + from, err := time.Parse(time.RFC3339Nano, query["from"][0]) + require.NoError(t, err) + assert.WithinDuration(t, before.Add(-30*24*time.Hour), from, time.Minute, "--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) + } +} + +// 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 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, + } +} + +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..4b3ac8ea --- /dev/null +++ b/internal/cmd/durable/approvals/list.go @@ -0,0 +1,158 @@ +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 + } + if opts.since != "" { + input.From, err = sinceTime(opts.since, time.Now()) + if err != nil { + return err + } + } + + 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..f440357a --- /dev/null +++ b/internal/cmd/durable/approvals/stats.go @@ -0,0 +1,68 @@ +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 { + from, err := sinceTime(opts.since, time.Now()) + if err != nil { + return err + } + + stats, err := clidurable.NewService(opts.deps).ApprovalStats(ctx, strings.TrimSpace(opts.function), from) + 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/durable/durable.go b/internal/durable/durable.go index b7dd0a87..2ec5c395 100644 --- a/internal/durable/durable.go +++ b/internal/durable/durable.go @@ -9,8 +9,11 @@ package durable import ( "context" + "errors" "fmt" "net/http" + "strings" + "time" "github.com/google/uuid" @@ -336,6 +339,147 @@ 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 since from. A nil +// from leaves the window to the API, which defaults to the last 30 days. +func (s Service) ApprovalStats( + ctx context.Context, function string, from *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) + 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 || strings.Contains(api.Message(err), "decided by a person") { + 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 + } + } + } + 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. +var errApprovalNeedsAPerson = errors.New( + "approvals are decided by a person. Run `volcano login`, or decide in the dashboard") + +// 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. +func ApprovalConflict(approval *apiclient.DurableApproval, decision apiclient.DurableApprovalStatus) error { + if approval == nil || approval.Status == apiclient.DurableApprovalStatusPending || approval.Status == decision { + return nil + } + + switch approval.Status { + case apiclient.DurableApprovalStatusApproved, apiclient.DurableApprovalStatusDenied: + return fmt.Errorf("approval %s was already %s%s", approval.Id, approval.Status, DecisionSummary(approval.Decision)) + case apiclient.DurableApprovalStatusExpired: + 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) + 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) + } +} + +// DecisionSummary renders who decided and when as " by at