From 82e2fff41e79ed3ac0007bf0d7b30f7ede55f736 Mon Sep 17 00:00:00 2001 From: Harsh-2002 Date: Sun, 14 Jun 2026 08:57:25 +0000 Subject: [PATCH] feat(cli): functions update, executions group, webhooks get/update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the CLI to parity with the MCP/dashboard for mutate + inspect (Tier-1/2 capability gaps), all backed by existing REST endpoints: - functions update : partial in-place config update (PUT) built from only the flags you pass — toggle network_mode, adjust limits/concurrency, pause via --status — without a redeploy. Runtime stays create-fixed. - executions : the global, filterable execution surface that complements per-fn `orva logs`: list (--function/--status/--since/--until/--search/--limit/--offset + truncation footer), get, logs, delete , replay (invoke-style output + non-zero exit on 4xx/5xx), and prune (--function/--status/ --older-than, lists+confirms the count, batch-deletes at the 1000/call cap; requires a filter so it can't wipe everything by accident). - webhooks get + update (partial; secret rotation stays delete+recreate). Registers the new `executions` group, wires fn-name completion for the new fn args/flags, and extends the command-tree + required-flags tests. CLI-only (no server change); the endpoints already exist. --- cli/commands/commands_test.go | 10 +- cli/commands/completions.go | 11 +- cli/commands/executions.go | 455 ++++++++++++++++++++++++++++++++++ cli/commands/functions.go | 119 +++++++++ cli/commands/root.go | 2 + cli/commands/webhooks.go | 105 ++++++++ 6 files changed, 700 insertions(+), 2 deletions(-) create mode 100644 cli/commands/executions.go diff --git a/cli/commands/commands_test.go b/cli/commands/commands_test.go index 119b708..352c8cd 100644 --- a/cli/commands/commands_test.go +++ b/cli/commands/commands_test.go @@ -9,12 +9,14 @@ import "testing" func TestCommandTree(t *testing.T) { root := NewRoot() paths := [][]string{ - {"functions"}, {"functions", "list"}, {"functions", "get"}, {"functions", "create"}, {"functions", "delete"}, + {"functions"}, {"functions", "list"}, {"functions", "get"}, {"functions", "create"}, {"functions", "delete"}, {"functions", "update"}, {"deploy"}, {"deployments"}, {"deployments", "list"}, {"deployments", "get"}, {"deployments", "logs"}, {"rollback"}, {"invoke"}, {"logs"}, + {"executions"}, {"executions", "list"}, {"executions", "get"}, {"executions", "logs"}, + {"executions", "delete"}, {"executions", "prune"}, {"executions", "replay"}, {"kv"}, {"kv", "get"}, {"kv", "put"}, {"kv", "delete"}, {"kv", "list"}, {"kv", "incr"}, {"kv", "cas"}, {"cron"}, {"cron", "create"}, {"cron", "list"}, {"cron", "update"}, {"cron", "delete"}, {"jobs"}, {"jobs", "enqueue"}, {"jobs", "list"}, {"jobs", "get"}, {"jobs", "retry"}, {"jobs", "delete"}, @@ -94,6 +96,12 @@ func TestRequiredFlagsPresent(t *testing.T) { {[]string{"cron", "create"}, "expr"}, {[]string{"webhooks", "create"}, "name"}, {[]string{"webhooks", "create"}, "url"}, + {[]string{"webhooks", "update"}, "events"}, + {[]string{"functions", "update"}, "network-mode"}, + {[]string{"functions", "update"}, "env"}, + {[]string{"executions", "list"}, "status"}, + {[]string{"executions", "list"}, "limit"}, + {[]string{"executions", "prune"}, "older-than"}, {[]string{"channels", "create"}, "functions"}, {[]string{"secrets", "set"}, "value"}, {[]string{"keys", "create"}, "expires-in-days"}, diff --git a/cli/commands/completions.go b/cli/commands/completions.go index f47e2d5..fd8f7b0 100644 --- a/cli/commands/completions.go +++ b/cli/commands/completions.go @@ -138,7 +138,7 @@ func wireCompletions(root *cobra.Command) { // First positional = function name. for _, path := range [][]string{ {"invoke"}, {"diff"}, {"rollback"}, {"logs"}, - {"functions", "get"}, {"functions", "delete"}, + {"functions", "get"}, {"functions", "delete"}, {"functions", "update"}, {"deployments", "list"}, {"deployments", "get"}, {"deployments", "logs"}, } { if c, _, err := root.Find(path); err == nil && c.Name() == path[len(path)-1] { @@ -155,6 +155,15 @@ func wireCompletions(root *cobra.Command) { } } + // --function flag = function name (executions filters). + for _, path := range [][]string{ + {"executions", "list"}, {"executions", "prune"}, + } { + if c, _, err := root.Find(path); err == nil { + _ = c.RegisterFlagCompletionFunc("function", completeFunctionNames) + } + } + // Enum + resource flags. if c, _, err := root.Find([]string{"deploy"}); err == nil { _ = c.RegisterFlagCompletionFunc("runtime", completeRuntimes) diff --git a/cli/commands/executions.go b/cli/commands/executions.go new file mode 100644 index 0000000..5c2551d --- /dev/null +++ b/cli/commands/executions.go @@ -0,0 +1,455 @@ +package commands + +import ( + "encoding/json" + "fmt" + "io" + "net/url" + "os" + "strconv" + "strings" + "time" + + cli "github.com/Harsh-2002/Orva/internal/client" + "github.com/spf13/cobra" +) + +var executionsCmd = &cobra.Command{ + Use: "executions", + Aliases: []string{"execs", "exec"}, + Short: "Inspect and manage executions across functions", + Long: `List, inspect, replay, and clean up executions across all functions. + +Complements 'orva logs ' (the per-function view): this is the global, +filterable surface for CI cleanup and cross-function observability. + + orva executions list --status error --limit 100 + orva executions get 019df... + orva executions logs 019df... + orva executions replay 019df... + orva executions prune --older-than 168h --status error --yes`, +} + +var executionsListCmd = &cobra.Command{ + Use: "list", + Short: "List executions across functions", + Long: `List executions, optionally filtered by function, status, time window, or a +search term. Paginated: the footer reports "Showing N of TOTAL"; raise --limit +or page with --offset so a script never silently misses rows. + + orva executions list + orva executions list --function greeter --status error + orva executions list --since 2026-06-01T00:00:00Z --limit 200 + orva executions list --search timeout -o json | jq '.executions[].id'`, + Args: cobra.NoArgs, + RunE: runExecutionsList, +} + +var executionsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get one execution's metadata", + Long: `Print the full execution record (status, timings, sizes, trace ids) as JSON. + + orva executions get 019df... + orva executions get 019df... | jq .status`, + Args: cobra.ExactArgs(1), + RunE: runExecutionsGet, +} + +var executionsLogsCmd = &cobra.Command{ + Use: "logs ", + Short: "Print one execution's stdout/stderr", + Long: `Print the captured stdout (to stdout) and stderr (to stderr) for a single +execution. With -o json the full log object is emitted to stdout. + + orva executions logs 019df... + orva executions logs 019df... -o json`, + Args: cobra.ExactArgs(1), + RunE: runExecutionsLogs, +} + +var executionsDeleteCmd = &cobra.Command{ + Use: "delete ...", + Short: "Delete one or more executions by id", + Long: `Delete executions by id (up to 1000 per call). Destructive; prompts for +confirmation unless --yes is passed. + + orva executions delete 019df... + orva executions delete 019dfa 019dfb 019dfc --yes`, + Args: cobra.MinimumNArgs(1), + RunE: runExecutionsDelete, +} + +var executionsPruneCmd = &cobra.Command{ + Use: "prune", + Short: "Bulk-delete executions matching a filter", + Long: `Delete executions matching a filter — the housekeeping primitive for CI. +Lists the matching rows, reports the count, then deletes them (batched at the +server's 1000-per-call limit). Destructive; prompts unless --yes. + +At least one filter (--function, --status, or --older-than) is required so an +unfiltered run can't wipe every execution by accident. + + orva executions prune --older-than 168h + orva executions prune --status error --function greeter --yes + orva executions prune --older-than 720h --limit 5000 --yes`, + Args: cobra.NoArgs, + RunE: runExecutionsPrune, +} + +var executionsReplayCmd = &cobra.Command{ + Use: "replay ", + Short: "Re-run a captured request against current code", + Long: `Replay an execution's captured request against the function's current +deployment, recording a new execution. The replayed response body is printed +to stdout (pretty on a TTY, raw when piped) and exits non-zero on a 4xx/5xx — +a useful debug and CI regression primitive. + +Requires that the original request was captured (request capture must have +been enabled for the function at the time). + + orva executions replay 019df... + orva executions replay 019df... | jq .`, + Args: cobra.ExactArgs(1), + RunE: runExecutionsReplay, +} + +func init() { + executionsListCmd.Flags().String("function", "", "filter to one function (name or id)") + executionsListCmd.Flags().String("status", "", "filter by status: success | error") + executionsListCmd.Flags().String("since", "", "only executions at/after this RFC3339 time") + executionsListCmd.Flags().String("until", "", "only executions before this RFC3339 time") + executionsListCmd.Flags().String("search", "", "substring match on error message / container id") + executionsListCmd.Flags().Int("limit", 50, "maximum number of executions to return") + executionsListCmd.Flags().Int("offset", 0, "number of executions to skip (pagination)") + + executionsPruneCmd.Flags().String("function", "", "filter to one function (name or id)") + executionsPruneCmd.Flags().String("status", "", "filter by status: success | error") + executionsPruneCmd.Flags().String("older-than", "", "delete executions older than this duration (e.g. 24h, 7d, 30m)") + executionsPruneCmd.Flags().Int("limit", 1000, "maximum number of executions to delete in this run") + + executionsCmd.AddCommand( + executionsListCmd, + executionsGetCmd, + executionsLogsCmd, + executionsDeleteCmd, + executionsPruneCmd, + executionsReplayCmd, + ) +} + +type executionRow struct { + ID string `json:"id"` + FunctionID string `json:"function_id"` + Status string `json:"status"` + StatusCode *int `json:"status_code"` + DurationMS *int64 `json:"duration_ms"` + StartedAt time.Time `json:"started_at"` + ReplayOf *string `json:"replay_of"` +} + +// executionListQuery builds the /api/v1/executions query string from the shared +// filter flags. functionName is resolved to an id when set. +func executionListQuery(cmd *cobra.Command, client *cli.Client) (url.Values, error) { + q := url.Values{} + if fn, _ := cmd.Flags().GetString("function"); fn != "" { + fnID, err := resolveFnID(client, fn) + if err != nil { + return nil, err + } + q.Set("function_id", fnID) + } + if status, _ := cmd.Flags().GetString("status"); status != "" { + q.Set("status", status) + } + return q, nil +} + +func runExecutionsList(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + q, err := executionListQuery(cmd, client) + if err != nil { + return err + } + if since, _ := cmd.Flags().GetString("since"); since != "" { + q.Set("since", since) + } + if until, _ := cmd.Flags().GetString("until"); until != "" { + q.Set("until", until) + } + if search, _ := cmd.Flags().GetString("search"); search != "" { + q.Set("q", search) + } + limit, _ := cmd.Flags().GetInt("limit") + offset, _ := cmd.Flags().GetInt("offset") + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } + if offset > 0 { + q.Set("offset", strconv.Itoa(offset)) + } + + path := "/api/v1/executions" + if encoded := q.Encode(); encoded != "" { + path += "?" + encoded + } + + resp, err := client.Get(path) + if err != nil { + return fmt.Errorf("list: %w", err) + } + if err := checkResponse(resp); err != nil { + return err + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + if outputJSON(cmd) { + return emitRaw(raw) + } + + var result struct { + Executions []executionRow `json:"executions"` + Total int `json:"total"` + } + if err := json.Unmarshal(raw, &result); err != nil { + return fmt.Errorf("decode response: %w", err) + } + + t := newTable("ID", "FUNCTION", "STATUS", "CODE", "DURATION", "STARTED") + for _, e := range result.Executions { + dur := "-" + if e.DurationMS != nil { + dur = fmt.Sprintf("%dms", *e.DurationMS) + } + code := "-" + if e.StatusCode != nil { + code = strconv.Itoa(*e.StatusCode) + } + t.row(e.ID, e.FunctionID, e.Status, code, dur, e.StartedAt.Format(time.DateTime)) + } + t.flush() + + shown := len(result.Executions) + if offset+shown < result.Total { + infof(cmd, "\nShowing %d of %d (raise --limit or page with --offset %d)", shown, result.Total, offset+shown) + } else { + infof(cmd, "\nShowing %d of %d", shown, result.Total) + } + return nil +} + +func runExecutionsGet(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + resp, err := client.Get("/api/v1/executions/" + url.PathEscape(args[0])) + if err != nil { + return fmt.Errorf("get: %w", err) + } + if err := checkResponse(resp); err != nil { + return err + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + return emitRaw(raw) +} + +func runExecutionsLogs(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + // Reuse the same /logs endpoint + rendering as `orva logs --exec-id`. + return showExecutionLogs(cmd, client, args[0]) +} + +func runExecutionsDelete(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + if len(args) > 1000 { + return fmt.Errorf("delete: at most 1000 ids per call (got %d) — use 'executions prune' for bulk cleanup", len(args)) + } + if err := confirm(cmd, fmt.Sprintf("Delete %d execution(s)?", len(args))); err != nil { + return err + } + return bulkDeleteExecutions(cmd, client, args) +} + +func runExecutionsPrune(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + + fnFlag, _ := cmd.Flags().GetString("function") + statusFlag, _ := cmd.Flags().GetString("status") + olderThan, _ := cmd.Flags().GetString("older-than") + if fnFlag == "" && statusFlag == "" && olderThan == "" { + return fmt.Errorf("prune: at least one filter is required (--function, --status, or --older-than)") + } + + q, err := executionListQuery(cmd, client) + if err != nil { + return err + } + if olderThan != "" { + d, err := parseLooseDuration(olderThan) + if err != nil { + return fmt.Errorf("prune: invalid --older-than %q: %w", olderThan, err) + } + cutoff := time.Now().UTC().Add(-d) + q.Set("until", cutoff.Format(time.RFC3339)) + } + limit, _ := cmd.Flags().GetInt("limit") + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } + + // Collect matching ids first so we can confirm the exact count. + path := "/api/v1/executions?" + q.Encode() + resp, err := client.Get(path) + if err != nil { + return fmt.Errorf("prune: list: %w", err) + } + if err := checkResponse(resp); err != nil { + return err + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var listed struct { + Executions []executionRow `json:"executions"` + Total int `json:"total"` + } + if err := json.Unmarshal(raw, &listed); err != nil { + return fmt.Errorf("prune: decode: %w", err) + } + if len(listed.Executions) == 0 { + infof(cmd, "No executions match the filter — nothing to prune.") + return nil + } + ids := make([]string, 0, len(listed.Executions)) + for _, e := range listed.Executions { + ids = append(ids, e.ID) + } + + more := "" + if listed.Total > len(ids) { + more = fmt.Sprintf(" (of %d matching; re-run or raise --limit for the rest)", listed.Total) + } + if err := confirm(cmd, fmt.Sprintf("Delete %d execution(s)%s?", len(ids), more)); err != nil { + return err + } + return bulkDeleteExecutions(cmd, client, ids) +} + +// bulkDeleteExecutions deletes ids in batches of 1000 (the server cap) and +// reports the aggregate result. +func bulkDeleteExecutions(cmd *cobra.Command, client *cli.Client, ids []string) error { + const batch = 1000 + totalDeleted, totalFailed := 0, 0 + for start := 0; start < len(ids); start += batch { + end := start + batch + if end > len(ids) { + end = len(ids) + } + resp, err := client.Post("/api/v1/executions/bulk-delete", map[string]any{"ids": ids[start:end]}) + if err != nil { + return fmt.Errorf("bulk-delete: %w", err) + } + if err := checkResponse(resp); err != nil { + return err + } + raw, _ := io.ReadAll(resp.Body) + resp.Body.Close() + var r struct { + Deleted int `json:"deleted"` + Failed int `json:"failed"` + } + _ = json.Unmarshal(raw, &r) + totalDeleted += r.Deleted + totalFailed += r.Failed + } + if outputJSON(cmd) { + return emitJSON(map[string]any{"deleted": totalDeleted, "failed": totalFailed}) + } + if totalFailed > 0 { + okf(cmd, "deleted %d execution(s), %d failed", totalDeleted, totalFailed) + } else { + okf(cmd, "deleted %d execution(s)", totalDeleted) + } + return nil +} + +func runExecutionsReplay(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + resp, err := client.Post("/api/v1/executions/"+url.PathEscape(args[0])+"/replay", nil) + if err != nil { + return fmt.Errorf("replay: %w", err) + } + // Note: a 4xx/5xx here may be the replayed function's own status (the body + // is still meaningful), so read + print the body before signalling via exit. + respBody, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + newID := resp.Header.Get("X-Orva-Execution-ID") + replayOf := resp.Header.Get("X-Orva-Replay-Of") + durMS := resp.Header.Get("X-Orva-Duration-MS") + + if outputJSON(cmd) { + out := map[string]any{ + "status": resp.StatusCode, + "execution_id": newID, + "replay_of": replayOf, + "duration_ms_hdr": durMS, + } + var parsed any + if json.Unmarshal(respBody, &parsed) == nil { + out["body"] = parsed + } else { + out["body"] = string(respBody) + } + if err := emitJSON(out); err != nil { + return err + } + return exitForStatus(resp.StatusCode) + } + + infof(cmd, "replay %s · new execution %s · %d · %sms", replayOf, newID, resp.StatusCode, dash(durMS)) + if stdoutIsTerminal() { + var parsed any + if json.Unmarshal(respBody, &parsed) == nil { + pretty, _ := json.MarshalIndent(parsed, "", " ") + fmt.Println(string(pretty)) + return exitForStatus(resp.StatusCode) + } + } + os.Stdout.Write(respBody) + if len(respBody) > 0 && respBody[len(respBody)-1] != '\n' && stdoutIsTerminal() { + fmt.Println() + } + return exitForStatus(resp.StatusCode) +} + +// parseLooseDuration accepts Go durations (h/m/s) plus a "d" days suffix, +// which time.ParseDuration does not support, so `--older-than 7d` works. +func parseLooseDuration(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if strings.HasSuffix(s, "d") { + days, err := strconv.ParseFloat(strings.TrimSuffix(s, "d"), 64) + if err != nil { + return 0, err + } + return time.Duration(days * 24 * float64(time.Hour)), nil + } + return time.ParseDuration(s) +} diff --git a/cli/commands/functions.go b/cli/commands/functions.go index 1a8c695..49fc1e4 100644 --- a/cli/commands/functions.go +++ b/cli/commands/functions.go @@ -6,6 +6,7 @@ import ( "io" "net/url" "strconv" + "strings" "time" "github.com/spf13/cobra" @@ -70,6 +71,24 @@ confirmation unless --yes is set. RunE: runFunctionsDelete, } +var functionsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a function's configuration", + Long: `Update a function's settings in place (a partial merge — only the flags you +pass are changed). This is how an agent or CI toggles egress, adjusts limits, +or pauses a function without a full redeploy. + +Runtime cannot be changed here (it is fixed at create; deploy new code to +change behavior). --env replaces the entire env-var map. + + orva functions update greeter --network-mode egress + orva functions update greeter --memory-mb 256 --timeout-ms 60000 + orva functions update greeter --status inactive + orva functions update greeter --env API_URL=https://x --env DEBUG=1`, + Args: cobra.ExactArgs(1), + RunE: runFunctionsUpdate, +} + func init() { functionsListCmd.Flags().Int("limit", 100, "maximum number of functions to return") functionsListCmd.Flags().Int("offset", 0, "number of functions to skip (pagination)") @@ -82,10 +101,25 @@ func init() { functionsCreateCmd.MarkFlagRequired("name") functionsCreateCmd.MarkFlagRequired("runtime") + functionsUpdateCmd.Flags().String("name", "", "rename the function") + functionsUpdateCmd.Flags().String("description", "", "function description") + functionsUpdateCmd.Flags().String("entrypoint", "", "entrypoint file") + functionsUpdateCmd.Flags().Int64("timeout-ms", 0, "invocation timeout in ms") + functionsUpdateCmd.Flags().Int64("memory-mb", 0, "memory limit in MB") + functionsUpdateCmd.Flags().Float64("cpus", 0, "CPU limit (cores)") + functionsUpdateCmd.Flags().StringArray("env", nil, "env var KEY=VALUE (repeatable; replaces the whole env map)") + functionsUpdateCmd.Flags().String("network-mode", "", "network mode: none | egress") + functionsUpdateCmd.Flags().Int("max-concurrency", 0, "max concurrent invocations (0 = unlimited)") + functionsUpdateCmd.Flags().String("concurrency-policy", "", "when at capacity: queue | reject") + functionsUpdateCmd.Flags().String("auth-mode", "", "invocation auth: none | platform_key | signed") + functionsUpdateCmd.Flags().Int("rate-limit-per-min", 0, "per-minute invocation rate limit (0 = none)") + functionsUpdateCmd.Flags().String("status", "", "status: active | inactive") + functionsCmd.AddCommand(functionsListCmd) functionsCmd.AddCommand(functionsCreateCmd) functionsCmd.AddCommand(functionsGetCmd) functionsCmd.AddCommand(functionsDeleteCmd) + functionsCmd.AddCommand(functionsUpdateCmd) } func runFunctionsList(cmd *cobra.Command, args []string) error { @@ -272,3 +306,88 @@ func runFunctionsDelete(cmd *cobra.Command, args []string) error { okf(cmd, "Deleted function %s", fnID) return nil } + +func runFunctionsUpdate(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + + fnID, err := resolveFnID(client, args[0]) + if err != nil { + return err + } + + // Partial update: only send fields whose flag was explicitly set, so an + // omitted flag leaves the server's value untouched (the REST struct is + // all-pointers/merge). + body := map[string]any{} + f := cmd.Flags() + setStr := func(flag, json string) { + if f.Changed(flag) { + v, _ := f.GetString(flag) + body[json] = v + } + } + setStr("name", "name") + setStr("description", "description") + setStr("entrypoint", "entrypoint") + setStr("network-mode", "network_mode") + setStr("concurrency-policy", "concurrency_policy") + setStr("auth-mode", "auth_mode") + setStr("status", "status") + if f.Changed("timeout-ms") { + v, _ := f.GetInt64("timeout-ms") + body["timeout_ms"] = v + } + if f.Changed("memory-mb") { + v, _ := f.GetInt64("memory-mb") + body["memory_mb"] = v + } + if f.Changed("cpus") { + v, _ := f.GetFloat64("cpus") + body["cpus"] = v + } + if f.Changed("max-concurrency") { + v, _ := f.GetInt("max-concurrency") + body["max_concurrency"] = v + } + if f.Changed("rate-limit-per-min") { + v, _ := f.GetInt("rate-limit-per-min") + body["rate_limit_per_min"] = v + } + if f.Changed("env") { + pairs, _ := f.GetStringArray("env") + env := map[string]string{} + for _, p := range pairs { + k, v, ok := strings.Cut(p, "=") + if !ok { + return fmt.Errorf("update: --env %q must be KEY=VALUE", p) + } + env[k] = v + } + body["env_vars"] = env + } + + if len(body) == 0 { + return fmt.Errorf("update: nothing to change — pass at least one field flag (see --help)") + } + + resp, err := client.Put("/api/v1/functions/"+fnID, body) + if err != nil { + return err + } + if err := checkResponse(resp); err != nil { + return err + } + raw, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + if outputJSON(cmd) { + return emitRaw(raw) + } + okf(cmd, "Updated function %s", args[0]) + return nil +} diff --git a/cli/commands/root.go b/cli/commands/root.go index 07b270a..bd068ef 100644 --- a/cli/commands/root.go +++ b/cli/commands/root.go @@ -70,6 +70,7 @@ func commandGroups() map[*cobra.Command]string { diffCmd: groupFunctions, invokeCmd: groupFunctions, logsCmd: groupFunctions, + executionsCmd: groupFunctions, fixturesCmd: groupFunctions, tracesCmd: groupFunctions, poolCmd: groupFunctions, @@ -123,6 +124,7 @@ func RegisterClient(root *cobra.Command) { diffCmd, dnsCmd, docsCmd, + executionsCmd, firewallCmd, fixturesCmd, functionsCmd, diff --git a/cli/commands/webhooks.go b/cli/commands/webhooks.go index 9a82c4c..f5ede5d 100644 --- a/cli/commands/webhooks.go +++ b/cli/commands/webhooks.go @@ -46,6 +46,31 @@ var webhooksTestCmd = &cobra.Command{ RunE: runWebhooksTest, } +var webhooksGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get a webhook subscription", + Long: `Print a single webhook subscription as JSON (the plaintext secret is never +returned — only a preview). + + orva webhooks get + orva webhooks get | jq .events`, + Args: cobra.ExactArgs(1), + RunE: runWebhooksGet, +} + +var webhooksUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a webhook subscription", + Long: `Update a subscription in place (partial — only the flags you pass change). +The secret cannot be rotated here; delete and recreate to rotate it. + + orva webhooks update --enabled=false + orva webhooks update --url https://new.example.com/hook + orva webhooks update --events deployment.failed,job.failed`, + Args: cobra.ExactArgs(1), + RunE: runWebhooksUpdate, +} + var webhooksDeleteCmd = &cobra.Command{ Use: "delete [id]", Short: "Delete a webhook subscription", @@ -74,8 +99,15 @@ func init() { webhooksCreateCmd.MarkFlagRequired("name") webhooksCreateCmd.MarkFlagRequired("url") + webhooksUpdateCmd.Flags().String("name", "", "rename the subscription") + webhooksUpdateCmd.Flags().String("url", "", "delivery URL") + webhooksUpdateCmd.Flags().String("events", "", "comma-separated event names (replaces the set)") + webhooksUpdateCmd.Flags().Bool("enabled", true, "enable or disable delivery") + webhooksCmd.AddCommand(webhooksListCmd) webhooksCmd.AddCommand(webhooksCreateCmd) + webhooksCmd.AddCommand(webhooksGetCmd) + webhooksCmd.AddCommand(webhooksUpdateCmd) webhooksCmd.AddCommand(webhooksTestCmd) webhooksCmd.AddCommand(webhooksDeleteCmd) webhooksCmd.AddCommand(webhooksDeliveriesCmd) @@ -182,6 +214,79 @@ func runWebhooksCreate(cmd *cobra.Command, args []string) error { return nil } +func runWebhooksGet(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + resp, err := client.Get("/api/v1/webhooks/" + args[0]) + if err != nil { + return fmt.Errorf("get: %w", err) + } + if err := checkResponse(resp); err != nil { + return err + } + raw, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + return emitRaw(raw) +} + +func runWebhooksUpdate(cmd *cobra.Command, args []string) error { + client, err := getClient(cmd) + if err != nil { + return err + } + // Partial update — only send fields whose flag was explicitly set. + body := map[string]any{} + f := cmd.Flags() + if f.Changed("name") { + v, _ := f.GetString("name") + body["name"] = v + } + if f.Changed("url") { + v, _ := f.GetString("url") + body["url"] = v + } + if f.Changed("events") { + v, _ := f.GetString("events") + events := []string{} + for _, e := range strings.Split(v, ",") { + if e = strings.TrimSpace(e); e != "" { + events = append(events, e) + } + } + body["events"] = events + } + if f.Changed("enabled") { + v, _ := f.GetBool("enabled") + body["enabled"] = v + } + if len(body) == 0 { + return fmt.Errorf("update: nothing to change — pass at least one of --name/--url/--events/--enabled") + } + + resp, err := client.Put("/api/v1/webhooks/"+args[0], body) + if err != nil { + return fmt.Errorf("update: %w", err) + } + if err := checkResponse(resp); err != nil { + return err + } + raw, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return err + } + if outputJSON(cmd) { + return emitRaw(raw) + } + okf(cmd, "Updated webhook %s", args[0]) + return nil +} + func runWebhooksTest(cmd *cobra.Command, args []string) error { client, err := getClient(cmd) if err != nil {