From 7562680ba116a600745953c4b68c6a10cc1a0e86 Mon Sep 17 00:00:00 2001 From: Scott Cotton Date: Mon, 6 Apr 2026 18:43:03 +0200 Subject: [PATCH 1/7] Add plan CRUD commands (compile, create, list, get, delete) (#306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add plan CRUD commands (compile, create, list, get, delete) Phase 1 of the plan CLI: basic plan management against the API. Commands: signadot plan compile -f [--tag ] signadot plan create -f [--set var=val] signadot plan list signadot plan get signadot plan delete All commands support -o json and -o yaml output formats. Compile supports -f - for stdin. Create supports --set template substitution via the existing LoadUnstructuredTemplate path. Compile --tag compiles then tags the plan via PutPlanTag. Depends on go-sdk with plan CRUD client (signadot/signadot#6825). Co-Authored-By: Claude Opus 4.6 (1M context) * Add plan tag commands (list, get, apply, delete) (#307) * Add plan tag commands (list, get, apply, delete) Phase 2 of the plan CLI: named references to plans. Commands: signadot plan tag list signadot plan tag get signadot plan tag apply --plan signadot plan tag delete Alias: signadot plan t ApplyTag is exported from the plantag package so compile --tag can reuse it. The local plan/tag.go wrapper is removed. Also extracts print.FirstLine as a shared helper, and improves command help text for agent discoverability. Co-Authored-By: Claude Opus 4.6 (1M context) * Add plan execution commands and plan run (#308) * Add plan execution commands and plan run Plan execution management (signadot plan x / plan execution): - get : show execution details with step status table - cancel : cancel a running execution - outputs : list output metadata (inline/artifact, size, ready) - get-output : download output to stdout - get-output /: download step-level output Plan run (signadot plan run): - Creates execution, polls until terminal phase, prints results - Resolve by plan ID or --tag - --param key=value for execution parameters - --wait=false for fire-and-forget - --timeout for poll timeout - --output-dir to export all outputs on completion - Exit codes: 0=completed, 1=failed, 2=cancelled - Ctrl+C cancels the execution via API Also adds --tag flag to plan create (matching compile --tag). Co-Authored-By: Claude Opus 4.6 (1M context) * Remove plan list and add tag history rendering - Remove plan list command — tags are now the sole discovery mechanism per the plan lifecycle redesign. - Remove printPlanTable and PlanList config (dead code after removal). - Render tag-to-plan mapping history in plan tag get detail view. History table shows PLAN ID, TAGGED, UNTAGGED columns. Only displayed when there are previous mappings (len > 1). - 409 on plan delete (tagged plans) passes through as a clear API error message — no special handling needed. Co-Authored-By: Claude Opus 4.6 (1M context) * Add plan recompile command signadot plan recompile [--tag ] Recompiles a plan from its original prompt, producing a new plan. Supports --tag to tag the result in one command. Shows the Compiled From field linking back to the source plan. Co-Authored-By: Claude Opus 4.6 (1M context) * Update go-sdk and adapt to param renames Update go-sdk to latest feature-plan branch which normalises swagger parameter names: - WithPlanExecutionID → WithExecutionID - WithStepOutputName → WithOutputName Also adds PlanExecutionLogs client for streaming log endpoints. Co-Authored-By: Claude Opus 4.6 (1M context) * Add plan execution log streaming (#309) * Add plan execution log streaming Two ways to stream plan execution logs: signadot plan x logs [step-id] signadot logs --plan [--step ] Without a step ID, streams aggregated logs for all steps. With a step ID, streams stdout or stderr (--stream flag) for that step. Extracts ParseSSEStream from the logs package into internal/print/sse.go to avoid an import cycle between logs and planexec packages. Also adds --plan and --step flags to the existing signadot logs command (previously job-only), making --job and --plan mutually exclusive. Co-Authored-By: Claude Opus 4.6 (1M context) * Add plan execution list command (#310) * Add plan execution list command signadot plan x list [--plan ] [--tag ] [--phase ] Lists plan executions with pagination support. Filters: --plan: filter by plan ID --tag: filter by tag name (resolved server-side) --phase: filter by execution phase Also unexports ShowPlanLogs (unused cross-package after import cycle fix). Co-Authored-By: Claude Opus 4.6 (1M context) * Add plan run --attach for structured event streaming (#311) * Add plan run --attach for structured event streaming Streams execution events (logs, outputs, result) to stdout in real-time. Text mode (default): time=12:08:18 type=log step=greet stream=stdout msg="step starting" time=12:08:23 type=output name=greeting value="hello world" time=12:08:23 type=result id=abc123 phase=completed JSON mode (-o json): {"time":"...","type":"log","step":"greet","stream":"stdout","msg":"step starting\n"} Pipe-friendly: plan run --attach | grep type=output stderr reserved for CLI messages (Created execution...). Also fixes non-attach mode to write failure/cancellation details to stderr so stdout stays clean, and rejects -o yaml with --attach. Co-Authored-By: Claude Opus 4.6 (1M context) * Plan outputs list fixes (#312) - Show both plan-level and step-level outputs in plan x outputs (previously only plan-level, which was often empty) - Add SCOPE column (plan/step) and rename TYPE to STORAGE - Compute inline output size from value length - Show READY as true for inline outputs (always available) - Deduplicate: step outputs already shown as plan outputs are skipped Co-authored-by: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- go.mod | 69 ++-- go.sum | 155 +++++---- internal/command/command.go | 2 + internal/command/logs/command.go | 125 ++++--- internal/command/plan/command.go | 31 ++ internal/command/plan/compile.go | 82 +++++ internal/command/plan/create.go | 98 ++++++ internal/command/plan/delete.go | 40 +++ internal/command/plan/get.go | 50 +++ internal/command/plan/printers.go | 69 ++++ internal/command/plan/recompile.go | 60 ++++ internal/command/plan/run.go | 433 ++++++++++++++++++++++++ internal/command/planexec/cancel.go | 51 +++ internal/command/planexec/command.go | 27 ++ internal/command/planexec/get.go | 50 +++ internal/command/planexec/get_output.go | 165 +++++++++ internal/command/planexec/list.go | 81 +++++ internal/command/planexec/logs.go | 114 +++++++ internal/command/planexec/outputs.go | 142 ++++++++ internal/command/planexec/printers.go | 232 +++++++++++++ internal/command/plantag/apply.go | 64 ++++ internal/command/plantag/command.go | 25 ++ internal/command/plantag/delete.go | 40 +++ internal/command/plantag/get.go | 50 +++ internal/command/plantag/list.go | 48 +++ internal/command/plantag/printers.go | 129 +++++++ internal/config/logs.go | 5 +- internal/config/plan.go | 56 +++ internal/config/planexec.go | 56 +++ internal/config/planrun.go | 28 ++ internal/config/plantag.go | 31 ++ internal/print/attach.go | 91 +++++ internal/print/sse.go | 105 ++++++ internal/print/text.go | 15 + 34 files changed, 2652 insertions(+), 167 deletions(-) create mode 100644 internal/command/plan/command.go create mode 100644 internal/command/plan/compile.go create mode 100644 internal/command/plan/create.go create mode 100644 internal/command/plan/delete.go create mode 100644 internal/command/plan/get.go create mode 100644 internal/command/plan/printers.go create mode 100644 internal/command/plan/recompile.go create mode 100644 internal/command/plan/run.go create mode 100644 internal/command/planexec/cancel.go create mode 100644 internal/command/planexec/command.go create mode 100644 internal/command/planexec/get.go create mode 100644 internal/command/planexec/get_output.go create mode 100644 internal/command/planexec/list.go create mode 100644 internal/command/planexec/logs.go create mode 100644 internal/command/planexec/outputs.go create mode 100644 internal/command/planexec/printers.go create mode 100644 internal/command/plantag/apply.go create mode 100644 internal/command/plantag/command.go create mode 100644 internal/command/plantag/delete.go create mode 100644 internal/command/plantag/get.go create mode 100644 internal/command/plantag/list.go create mode 100644 internal/command/plantag/printers.go create mode 100644 internal/config/plan.go create mode 100644 internal/config/planexec.go create mode 100644 internal/config/planrun.go create mode 100644 internal/config/plantag.go create mode 100644 internal/print/attach.go create mode 100644 internal/print/sse.go create mode 100644 internal/print/text.go diff --git a/go.mod b/go.mod index 21870a94..9358d345 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/signadot/cli -go 1.25 +go 1.25.0 require ( github.com/Masterminds/semver v1.5.0 @@ -10,8 +10,8 @@ require ( github.com/charmbracelet/lipgloss v1.1.0 github.com/docker/go-units v0.5.0 github.com/go-git/go-git/v5 v5.16.5 - github.com/go-openapi/runtime v0.29.2 - github.com/go-openapi/strfmt v0.25.0 + github.com/go-openapi/runtime v0.29.3 + github.com/go-openapi/strfmt v0.26.1 github.com/goccy/go-yaml v1.10.0 github.com/golang/protobuf v1.5.4 github.com/google/gops v0.3.28 @@ -21,14 +21,14 @@ require ( github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249 github.com/oklog/run v1.1.0 github.com/panta/machineid v1.0.2 - github.com/signadot/go-sdk v0.3.8-0.20260105152858-7f85937470f8 + github.com/signadot/go-sdk v0.3.8-0.20260402222445-b8b5bc1f40c0 github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.11.0 github.com/theckman/yacspin v0.13.12 github.com/xeonx/timeago v1.0.0-rc5 github.com/zalando/go-keyring v0.2.6 - golang.org/x/term v0.38.0 + golang.org/x/term v0.41.0 google.golang.org/grpc v1.78.0 google.golang.org/protobuf v1.36.11 k8s.io/client-go v0.33.0 @@ -58,18 +58,18 @@ require ( github.com/fxamacker/cbor/v2 v2.7.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.6.2 // indirect - github.com/go-openapi/swag/cmdutils v0.25.4 // indirect - github.com/go-openapi/swag/conv v0.25.4 // indirect - github.com/go-openapi/swag/fileutils v0.25.4 // indirect - github.com/go-openapi/swag/jsonname v0.25.4 // indirect - github.com/go-openapi/swag/jsonutils v0.25.4 // indirect - github.com/go-openapi/swag/loading v0.25.4 // indirect - github.com/go-openapi/swag/mangling v0.25.4 // indirect - github.com/go-openapi/swag/netutils v0.25.4 // indirect - github.com/go-openapi/swag/stringutils v0.25.4 // indirect - github.com/go-openapi/swag/typeutils v0.25.4 // indirect - github.com/go-openapi/swag/yamlutils v0.25.4 // indirect - github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/gnostic-models v0.6.9 // indirect @@ -84,6 +84,7 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/mwitkow/grpc-proxy v0.0.0-20230212185441-f345521cb9c9 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/prataprc/goparsec v0.0.0-20211219142520-daac0e635e7e // indirect @@ -96,8 +97,8 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.48.0 // indirect - golang.org/x/sync v0.19.0 // indirect + golang.org/x/net v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect @@ -113,14 +114,14 @@ require ( github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/analysis v0.24.1 // indirect - github.com/go-openapi/errors v0.22.5 // indirect - github.com/go-openapi/jsonpointer v0.22.4 // indirect - github.com/go-openapi/jsonreference v0.21.4 // indirect - github.com/go-openapi/loads v0.23.2 // indirect - github.com/go-openapi/spec v0.22.2 // indirect - github.com/go-openapi/swag v0.25.4 // indirect - github.com/go-openapi/validate v0.25.1 // indirect + github.com/go-openapi/analysis v0.25.0 // indirect + github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/loads v0.23.3 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/swag v0.25.5 // indirect + github.com/go-openapi/validate v0.25.2 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect @@ -137,7 +138,6 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oklog/ulid v1.3.1 // indirect github.com/pelletier/go-toml v1.9.4 // indirect github.com/pelletier/go-toml/v2 v2.0.8 // indirect github.com/rivo/uniseg v0.4.7 // indirect @@ -146,14 +146,13 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect - go.mongodb.org/mongo-driver v1.17.6 // indirect - go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/metric v1.39.0 // indirect - go.opentelemetry.io/otel/trace v1.39.0 // indirect - golang.org/x/crypto v0.46.0 // indirect + go.opentelemetry.io/otel v1.42.0 // indirect + go.opentelemetry.io/otel/metric v1.42.0 // indirect + go.opentelemetry.io/otel/trace v1.42.0 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sys v0.39.0 // indirect - golang.org/x/text v0.32.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index c022ce97..585bebe1 100644 --- a/go.sum +++ b/go.sum @@ -154,54 +154,54 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/analysis v0.24.1 h1:Xp+7Yn/KOnVWYG8d+hPksOYnCYImE3TieBa7rBOesYM= -github.com/go-openapi/analysis v0.24.1/go.mod h1:dU+qxX7QGU1rl7IYhBC8bIfmWQdX4Buoea4TGtxXY84= -github.com/go-openapi/errors v0.22.5 h1:Yfv4O/PRYpNF3BNmVkEizcHb3uLVVsrDt3LNdgAKRY4= -github.com/go-openapi/errors v0.22.5/go.mod h1:z9S8ASTUqx7+CP1Q8dD8ewGH/1JWFFLX/2PmAYNQLgk= -github.com/go-openapi/jsonpointer v0.22.4 h1:dZtK82WlNpVLDW2jlA1YCiVJFVqkED1MegOUy9kR5T4= -github.com/go-openapi/jsonpointer v0.22.4/go.mod h1:elX9+UgznpFhgBuaMQ7iu4lvvX1nvNsesQ3oxmYTw80= -github.com/go-openapi/jsonreference v0.21.4 h1:24qaE2y9bx/q3uRK/qN+TDwbok1NhbSmGjjySRCHtC8= -github.com/go-openapi/jsonreference v0.21.4/go.mod h1:rIENPTjDbLpzQmQWCj5kKj3ZlmEh+EFVbz3RTUh30/4= -github.com/go-openapi/loads v0.23.2 h1:rJXAcP7g1+lWyBHC7iTY+WAF0rprtM+pm8Jxv1uQJp4= -github.com/go-openapi/loads v0.23.2/go.mod h1:IEVw1GfRt/P2Pplkelxzj9BYFajiWOtY2nHZNj4UnWY= -github.com/go-openapi/runtime v0.29.2 h1:UmwSGWNmWQqKm1c2MGgXVpC2FTGwPDQeUsBMufc5Yj0= -github.com/go-openapi/runtime v0.29.2/go.mod h1:biq5kJXRJKBJxTDJXAa00DOTa/anflQPhT0/wmjuy+0= -github.com/go-openapi/spec v0.22.2 h1:KEU4Fb+Lp1qg0V4MxrSCPv403ZjBl8Lx1a83gIPU8Qc= -github.com/go-openapi/spec v0.22.2/go.mod h1:iIImLODL2loCh3Vnox8TY2YWYJZjMAKYyLH2Mu8lOZs= -github.com/go-openapi/strfmt v0.25.0 h1:7R0RX7mbKLa9EYCTHRcCuIPcaqlyQiWNPTXwClK0saQ= -github.com/go-openapi/strfmt v0.25.0/go.mod h1:nNXct7OzbwrMY9+5tLX4I21pzcmE6ccMGXl3jFdPfn8= -github.com/go-openapi/swag v0.25.4 h1:OyUPUFYDPDBMkqyxOTkqDYFnrhuhi9NR6QVUvIochMU= -github.com/go-openapi/swag v0.25.4/go.mod h1:zNfJ9WZABGHCFg2RnY0S4IOkAcVTzJ6z2Bi+Q4i6qFQ= -github.com/go-openapi/swag/cmdutils v0.25.4 h1:8rYhB5n6WawR192/BfUu2iVlxqVR9aRgGJP6WaBoW+4= -github.com/go-openapi/swag/cmdutils v0.25.4/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= -github.com/go-openapi/swag/conv v0.25.4 h1:/Dd7p0LZXczgUcC/Ikm1+YqVzkEeCc9LnOWjfkpkfe4= -github.com/go-openapi/swag/conv v0.25.4/go.mod h1:3LXfie/lwoAv0NHoEuY1hjoFAYkvlqI/Bn5EQDD3PPU= -github.com/go-openapi/swag/fileutils v0.25.4 h1:2oI0XNW5y6UWZTC7vAxC8hmsK/tOkWXHJQH4lKjqw+Y= -github.com/go-openapi/swag/fileutils v0.25.4/go.mod h1:cdOT/PKbwcysVQ9Tpr0q20lQKH7MGhOEb6EwmHOirUk= -github.com/go-openapi/swag/jsonname v0.25.4 h1:bZH0+MsS03MbnwBXYhuTttMOqk+5KcQ9869Vye1bNHI= -github.com/go-openapi/swag/jsonname v0.25.4/go.mod h1:GPVEk9CWVhNvWhZgrnvRA6utbAltopbKwDu8mXNUMag= -github.com/go-openapi/swag/jsonutils v0.25.4 h1:VSchfbGhD4UTf4vCdR2F4TLBdLwHyUDTd1/q4i+jGZA= -github.com/go-openapi/swag/jsonutils v0.25.4/go.mod h1:7OYGXpvVFPn4PpaSdPHJBtF0iGnbEaTk8AvBkoWnaAY= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4 h1:IACsSvBhiNJwlDix7wq39SS2Fh7lUOCJRmx/4SN4sVo= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.4/go.mod h1:Mt0Ost9l3cUzVv4OEZG+WSeoHwjWLnarzMePNDAOBiM= -github.com/go-openapi/swag/loading v0.25.4 h1:jN4MvLj0X6yhCDduRsxDDw1aHe+ZWoLjW+9ZQWIKn2s= -github.com/go-openapi/swag/loading v0.25.4/go.mod h1:rpUM1ZiyEP9+mNLIQUdMiD7dCETXvkkC30z53i+ftTE= -github.com/go-openapi/swag/mangling v0.25.4 h1:2b9kBJk9JvPgxr36V23FxJLdwBrpijI26Bx5JH4Hp48= -github.com/go-openapi/swag/mangling v0.25.4/go.mod h1:6dxwu6QyORHpIIApsdZgb6wBk/DPU15MdyYj/ikn0Hg= -github.com/go-openapi/swag/netutils v0.25.4 h1:Gqe6K71bGRb3ZQLusdI8p/y1KLgV4M/k+/HzVSqT8H0= -github.com/go-openapi/swag/netutils v0.25.4/go.mod h1:m2W8dtdaoX7oj9rEttLyTeEFFEBvnAx9qHd5nJEBzYg= -github.com/go-openapi/swag/stringutils v0.25.4 h1:O6dU1Rd8bej4HPA3/CLPciNBBDwZj9HiEpdVsb8B5A8= -github.com/go-openapi/swag/stringutils v0.25.4/go.mod h1:GTsRvhJW5xM5gkgiFe0fV3PUlFm0dr8vki6/VSRaZK0= -github.com/go-openapi/swag/typeutils v0.25.4 h1:1/fbZOUN472NTc39zpa+YGHn3jzHWhv42wAJSN91wRw= -github.com/go-openapi/swag/typeutils v0.25.4/go.mod h1:Ou7g//Wx8tTLS9vG0UmzfCsjZjKhpjxayRKTHXf2pTE= -github.com/go-openapi/swag/yamlutils v0.25.4 h1:6jdaeSItEUb7ioS9lFoCZ65Cne1/RZtPBZ9A56h92Sw= -github.com/go-openapi/swag/yamlutils v0.25.4/go.mod h1:MNzq1ulQu+yd8Kl7wPOut/YHAAU/H6hL91fF+E2RFwc= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxEodtNSI1WG1c/m5Akw4= -github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= -github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= -github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= -github.com/go-openapi/validate v0.25.1 h1:sSACUI6Jcnbo5IWqbYHgjibrhhmt3vR6lCzKZnmAgBw= -github.com/go-openapi/validate v0.25.1/go.mod h1:RMVyVFYte0gbSTaZ0N4KmTn6u/kClvAFp+mAVfS/DQc= +github.com/go-openapi/analysis v0.25.0 h1:EnjAq1yO8wEO9HbPmY8vLPEIkdZuuFhCAKBPvCB7bCs= +github.com/go-openapi/analysis v0.25.0/go.mod h1:5WFTRE43WLkPG9r9OtlMfqkkvUTYLVVCIxLlEpyF8kE= +github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= +github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= +github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= +github.com/go-openapi/runtime v0.29.3 h1:h5twGaEqxtQg40ePiYm9vFFH1q06Czd7Ot6ufdK0w/Y= +github.com/go-openapi/runtime v0.29.3/go.mod h1:8A1W0/L5eyNJvKciqZtvIVQvYO66NlB7INMSZ9bw/oI= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= +github.com/go-openapi/strfmt v0.26.1 h1:7zGCHji7zSYDC2tCXIusoxYQz/48jAf2q+sF6wXTG+c= +github.com/go-openapi/strfmt v0.26.1/go.mod h1:Zslk5VZPOISLwmWTMBIS7oiVFem1o1EI6zULY8Uer7Y= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= +github.com/go-openapi/testify/v2 v2.4.1 h1:zB34HDKj4tHwyUQHrUkpV0Q0iXQ6dUCOQtIqn8hE6Iw= +github.com/go-openapi/testify/v2 v2.4.1/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= +github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q= github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8= @@ -211,8 +211,8 @@ github.com/go-playground/validator/v10 v10.4.1 h1:pH2c5ADXtd66mxoE0Zm9SUhxE20r7a github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn9GlaMV7XkbRSipzJ0Ii4= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= -github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-yaml v1.10.0 h1:rBi+5HGuznOxx0JZ+60LDY85gc0dyIJCIMvsMJTKSKQ= github.com/goccy/go-yaml v1.10.0/go.mod h1:h/18Lr6oSQ3mvmqFoWmQ47KChOgpfHpTyIHl3yVmpiY= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= @@ -379,14 +379,15 @@ github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249 h1:NHrXEjTNQY7P0Zfx1a github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249/go.mod h1:mpRZBD8SJ55OIICQ3iWH0Yz3cjzA61JdqMLoWXeB2+8= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= -github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= -github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg= github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw= github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= github.com/panta/machineid v1.0.2 h1:LVYeEq1hZ+FwcM+/H6eB8KfXM2R5b2h1SWdnWwZ0OQw= github.com/panta/machineid v1.0.2/go.mod h1:AROj156fsca3R3rNw3q9h8xFkos25W9P0ZG9gu+3Uf0= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ= @@ -421,8 +422,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/signadot/go-sdk v0.3.8-0.20260105152858-7f85937470f8 h1:MQ6IebgvaT8KaclHmdO18D7r+zQj6FlR8949shHU4Vs= -github.com/signadot/go-sdk v0.3.8-0.20260105152858-7f85937470f8/go.mod h1:eSr+sGcPoeQvBH5v4Yy0+t2PlBHYohLDW+nxFQUj3L0= +github.com/signadot/go-sdk v0.3.8-0.20260402222445-b8b5bc1f40c0 h1:CpkTVrKonGpInT2p5p1cWihob4Nh1rYM52CrTqQS+sI= +github.com/signadot/go-sdk v0.3.8-0.20260402222445-b8b5bc1f40c0/go.mod h1:2+pvoCGoDDO+iPUmUuU2BjYHByHp3IDQ9k3o0liibb4= github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e h1:NiYn5S3cMIhsGh3RzBgRg9NzLDG5qEP7uhSJKtwW7oc= github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e/go.mod h1:cAsgAummH9Q9DrLQ7+S3mqrBv/+ZYKVSEXjR/WfoUJM= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -475,8 +476,6 @@ github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= -go.mongodb.org/mongo-driver v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= -go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= @@ -485,16 +484,16 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= +go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= @@ -509,8 +508,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= -golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -580,8 +579,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= -golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -604,8 +603,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= -golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -653,11 +652,11 @@ golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -667,8 +666,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= -golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -723,8 +722,8 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ= -golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/command/command.go b/internal/command/command.go index 18a05a5d..46557035 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -16,6 +16,7 @@ import ( "github.com/signadot/cli/internal/command/locald" "github.com/signadot/cli/internal/command/logs" "github.com/signadot/cli/internal/command/mcp" + "github.com/signadot/cli/internal/command/plan" "github.com/signadot/cli/internal/command/resourceplugin" "github.com/signadot/cli/internal/command/routegroup" "github.com/signadot/cli/internal/command/sandbox" @@ -58,6 +59,7 @@ func New() *cobra.Command { mcp.New(cfg), smarttest.New(cfg), traffic.New(cfg), + plan.New(cfg), // hidden commands hostedtest.New(cfg), diff --git a/internal/command/logs/command.go b/internal/command/logs/command.go index 5c6831ba..768874f3 100644 --- a/internal/command/logs/command.go +++ b/internal/command/logs/command.go @@ -2,15 +2,16 @@ package logs import ( "context" - "encoding/json" "errors" + "fmt" "io" "github.com/go-openapi/runtime" - "github.com/jclem/sseparser" "github.com/signadot/cli/internal/config" + sdkprint "github.com/signadot/cli/internal/print" "github.com/signadot/go-sdk/client" "github.com/signadot/go-sdk/client/job_logs" + planexeclogs "github.com/signadot/go-sdk/client/plan_execution_logs" "github.com/signadot/go-sdk/utils" "github.com/spf13/cobra" ) @@ -20,7 +21,7 @@ func New(api *config.API) *cobra.Command { cmd := &cobra.Command{ Use: "logs", - Short: "Display job logs", + Short: "Display job or plan execution logs", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { return showLogs(cmd.Context(), cmd.OutOrStdout(), cmd.ErrOrStderr(), cfg) @@ -32,10 +33,21 @@ func New(api *config.API) *cobra.Command { } func showLogs(ctx context.Context, outW, errW io.Writer, cfg *config.Logs) error { + if cfg.Job == "" && cfg.Plan == "" { + return fmt.Errorf("must specify --job or --plan") + } + if cfg.Job != "" && cfg.Plan != "" { + return fmt.Errorf("--job and --plan are mutually exclusive") + } + if err := cfg.InitAPIConfig(); err != nil { return err } + if cfg.Plan != "" { + return showPlanLogs(ctx, outW, cfg) + } + var w io.Writer switch cfg.Stream { case utils.LogTypeStderr: @@ -48,14 +60,63 @@ func showLogs(ctx context.Context, outW, errW io.Writer, cfg *config.Logs) error return err } -type event struct { - Event string `sse:"event"` - Data string `sse:"data"` -} +func showPlanLogs(ctx context.Context, out io.Writer, cfg *config.Logs) error { + transportCfg := cfg.GetBaseTransport() + transportCfg.Consumers = map[string]runtime.Consumer{ + "text/event-stream": runtime.ByteStreamConsumer(), + } + + return cfg.APIClientWithCustomTransport(transportCfg, + func(c *client.SignadotAPI) error { + reader, writer := io.Pipe() + + errch := make(chan error, 2) + + go func() { + _, err := sdkprint.ParseSSEStream(reader, out) + if errors.Is(err, io.ErrClosedPipe) { + err = nil + } + reader.Close() + errch <- err + }() -type message struct { - Message string `json:"message"` - Cursor string `json:"cursor"` + go func() { + var err error + if cfg.Step != "" { + params := planexeclogs.NewStreamPlanExecutionStepLogsParams(). + WithContext(ctx). + WithTimeout(0). + WithOrgName(cfg.Org). + WithExecutionID(cfg.Plan). + WithStepID(cfg.Step). + WithStream(cfg.Stream) + if cfg.TailLines > 0 { + tl := int64(cfg.TailLines) + params.WithTailLines(&tl) + } + _, err = c.PlanExecutionLogs.StreamPlanExecutionStepLogs(params, nil, writer) + } else { + params := planexeclogs.NewStreamPlanExecutionLogsParams(). + WithContext(ctx). + WithTimeout(0). + WithOrgName(cfg.Org). + WithExecutionID(cfg.Plan) + if cfg.TailLines > 0 { + tl := int64(cfg.TailLines) + params.WithTailLines(&tl) + } + _, err = c.PlanExecutionLogs.StreamPlanExecutionLogs(params, nil, writer) + } + if errors.Is(err, io.ErrClosedPipe) { + err = nil + } + writer.Close() + errch <- err + }() + + return errors.Join(<-errch, <-errch) + }) } func ShowLogs(ctx context.Context, cfg *config.API, out io.Writer, jobName, stream, cursor string, tailLines int) (string, error) { @@ -95,7 +156,7 @@ func ShowLogs(ctx context.Context, cfg *config.API, out io.Writer, jobName, stre go func() { // parse the SSE stream - lastCursor, err = parseSSEStream(reader, out) + lastCursor, err = sdkprint.ParseSSEStream(reader, out) if errors.Is(err, io.ErrClosedPipe) { err = nil // ignore ErrClosedPipe error } @@ -119,45 +180,3 @@ func ShowLogs(ctx context.Context, cfg *config.API, out io.Writer, jobName, stre return lastCursor, err } -func parseSSEStream(reader io.Reader, out io.Writer) (string, error) { - scanner := sseparser.NewStreamScanner(reader) - var lastCursor string - - for { - // Then, we call `UnmarshalNext`, and log each completion chunk, until we - // encounter an error or reach the end of the stream. - var e event - _, err := scanner.UnmarshalNext(&e) - if err != nil { - if errors.Is(err, sseparser.ErrStreamEOF) { - err = nil - } - return lastCursor, err - } - - switch e.Event { - case "message": - var m message - err = json.Unmarshal([]byte(e.Data), &m) - if err != nil { - return lastCursor, err - } - if m.Message == "" { - continue - } - out.Write([]byte(m.Message)) - - lastCursor = m.Cursor - case "error": - return lastCursor, errors.New(string(e.Data)) - case "signal": - switch e.Data { - case "EOF": - return lastCursor, nil - case "RESTART": - out.Write([]byte("\n\n-------------------------------------------------------------------------------\n")) - out.Write([]byte("WARNING: The job execution has been restarted...\n\n")) - } - } - } -} diff --git a/internal/command/plan/command.go b/internal/command/plan/command.go new file mode 100644 index 00000000..58529f80 --- /dev/null +++ b/internal/command/plan/command.go @@ -0,0 +1,31 @@ +package plan + +import ( + "github.com/signadot/cli/internal/command/planexec" + "github.com/signadot/cli/internal/command/plantag" + "github.com/signadot/cli/internal/config" + "github.com/spf13/cobra" +) + +func New(api *config.API) *cobra.Command { + cfg := &config.Plan{API: api} + + cmd := &cobra.Command{ + Use: "plan", + Short: "Manage plans (compiled prompts that define runnable workflows)", + } + + // Subcommands + cmd.AddCommand( + newCompile(cfg), + newCreate(cfg), + newGet(cfg), + newDelete(cfg), + newRecompile(cfg), + plantag.New(cfg), + planexec.New(cfg), + newRun(cfg), + ) + + return cmd +} diff --git a/internal/command/plan/compile.go b/internal/command/plan/compile.go new file mode 100644 index 00000000..d0acf475 --- /dev/null +++ b/internal/command/plan/compile.go @@ -0,0 +1,82 @@ +package plan + +import ( + "fmt" + "io" + "os" + "strings" + + "github.com/signadot/cli/internal/command/plantag" + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + sdkplans "github.com/signadot/go-sdk/client/plans" + "github.com/signadot/go-sdk/models" + "github.com/spf13/cobra" +) + +func newCompile(plan *config.Plan) *cobra.Command { + cfg := &config.PlanCompile{Plan: plan} + + cmd := &cobra.Command{ + Use: "compile -f PROMPT_FILE", + Short: "Compile a natural-language prompt into a runnable plan", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return compile(cfg, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } + + cfg.AddFlags(cmd) + return cmd +} + +func compile(cfg *config.PlanCompile, out, log io.Writer) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + + // Read the prompt from file or stdin. + var raw []byte + var err error + if cfg.Filename == "-" { + raw, err = io.ReadAll(os.Stdin) + } else { + raw, err = os.ReadFile(cfg.Filename) + } + if err != nil { + return fmt.Errorf("reading prompt: %w", err) + } + prompt := strings.TrimSpace(string(raw)) + if prompt == "" { + return fmt.Errorf("prompt file %q is empty", cfg.Filename) + } + + params := sdkplans.NewCompilePlanParams(). + WithOrgName(cfg.Org). + WithData(&models.PlanCompileInput{ + Prompt: prompt, + }) + resp, err := cfg.Client.Plans.CompilePlan(params, nil) + if err != nil { + return err + } + + // If --tag was provided, tag the compiled plan. + if cfg.Tag != "" { + if _, err := plantag.ApplyTag(cfg.Plan, resp.Payload.ID, cfg.Tag); err != nil { + return fmt.Errorf("plan compiled (id=%s) but tagging failed: %w", resp.Payload.ID, err) + } + fmt.Fprintf(log, "Tagged plan %s as %q\n", resp.Payload.ID, cfg.Tag) + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printPlanDetails(out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/plan/create.go b/internal/command/plan/create.go new file mode 100644 index 00000000..56fa17d6 --- /dev/null +++ b/internal/command/plan/create.go @@ -0,0 +1,98 @@ +package plan + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/signadot/cli/internal/command/plantag" + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/jsonexact" + "github.com/signadot/cli/internal/print" + "github.com/signadot/cli/internal/utils" + sdkplans "github.com/signadot/go-sdk/client/plans" + "github.com/signadot/go-sdk/models" + "github.com/spf13/cobra" +) + +func newCreate(plan *config.Plan) *cobra.Command { + cfg := &config.PlanCreate{Plan: plan} + + cmd := &cobra.Command{ + Use: "create -f SPEC_FILE", + Short: "Create a plan from a hand-authored spec file", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return create(cfg, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } + + cfg.AddFlags(cmd) + return cmd +} + +func create(cfg *config.PlanCreate, out, log io.Writer) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + + spec, err := loadPlanSpec(cfg.Filename, cfg.TemplateVals) + if err != nil { + return err + } + + params := sdkplans.NewCreatePlanParams(). + WithOrgName(cfg.Org). + WithData(spec) + resp, err := cfg.Client.Plans.CreatePlan(params, nil) + if err != nil { + return err + } + + if cfg.Tag != "" { + if _, err := plantag.ApplyTag(cfg.Plan, resp.Payload.ID, cfg.Tag); err != nil { + return fmt.Errorf("plan created (id=%s) but tagging failed: %w", resp.Payload.ID, err) + } + fmt.Fprintf(log, "Tagged plan %s as %q\n", resp.Payload.ID, cfg.Tag) + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printPlanDetails(out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} + +func loadPlanSpec(file string, tplVals config.TemplateVals) (*models.PlanSpec, error) { + template, err := utils.LoadUnstructuredTemplate(file, tplVals, false) + if err != nil { + return nil, err + } + + // Extract the spec field if present, otherwise treat the whole thing as spec. + m, ok := template.(map[string]any) + if !ok { + return nil, fmt.Errorf("plan file must be a YAML/JSON object") + } + specVal, hasSpec := m["spec"] + if hasSpec { + template = specVal + } + + d, err := json.Marshal(template) + if err != nil { + return nil, err + } + spec := &models.PlanSpec{} + if err := jsonexact.Unmarshal(d, spec); err != nil { + return nil, fmt.Errorf("couldn't parse plan spec - %s", + strings.TrimPrefix(err.Error(), "json: ")) + } + return spec, nil +} diff --git a/internal/command/plan/delete.go b/internal/command/plan/delete.go new file mode 100644 index 00000000..bd71a0fe --- /dev/null +++ b/internal/command/plan/delete.go @@ -0,0 +1,40 @@ +package plan + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + sdkplans "github.com/signadot/go-sdk/client/plans" + "github.com/spf13/cobra" +) + +func newDelete(plan *config.Plan) *cobra.Command { + cfg := &config.PlanDelete{Plan: plan} + + cmd := &cobra.Command{ + Use: "delete PLAN_ID", + Short: "Delete a plan", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return deletePlan(cfg, cmd.ErrOrStderr(), args[0]) + }, + } + + return cmd +} + +func deletePlan(cfg *config.PlanDelete, log io.Writer, planID string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + params := sdkplans.NewDeletePlanParams(). + WithOrgName(cfg.Org). + WithPlanID(planID) + _, err := cfg.Client.Plans.DeletePlan(params, nil) + if err != nil { + return err + } + fmt.Fprintf(log, "Deleted plan %q.\n", planID) + return nil +} diff --git a/internal/command/plan/get.go b/internal/command/plan/get.go new file mode 100644 index 00000000..276ca81f --- /dev/null +++ b/internal/command/plan/get.go @@ -0,0 +1,50 @@ +package plan + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + sdkplans "github.com/signadot/go-sdk/client/plans" + "github.com/spf13/cobra" +) + +func newGet(plan *config.Plan) *cobra.Command { + cfg := &config.PlanGet{Plan: plan} + + cmd := &cobra.Command{ + Use: "get PLAN_ID", + Short: "Get plan details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return getPlan(cfg, cmd.OutOrStdout(), args[0]) + }, + } + + return cmd +} + +func getPlan(cfg *config.PlanGet, out io.Writer, planID string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + params := sdkplans.NewGetPlanParams(). + WithOrgName(cfg.Org). + WithPlanID(planID) + resp, err := cfg.Client.Plans.GetPlan(params, nil) + if err != nil { + return err + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printPlanDetails(out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/plan/printers.go b/internal/command/plan/printers.go new file mode 100644 index 00000000..1d37c427 --- /dev/null +++ b/internal/command/plan/printers.go @@ -0,0 +1,69 @@ +package plan + +import ( + "fmt" + "io" + "slices" + "strings" + "text/tabwriter" + + "github.com/signadot/cli/internal/print" + "github.com/signadot/cli/internal/utils" + "github.com/signadot/go-sdk/models" +) + +func printPlanDetails(out io.Writer, p *models.RunnablePlan) error { + tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) + + fmt.Fprintf(tw, "ID:\t%s\n", p.ID) + if p.Spec != nil { + if p.Spec.Prompt != "" { + fmt.Fprintf(tw, "Prompt:\t%s\n", print.FirstLine(p.Spec.Prompt)) + } + if p.Spec.Runner != "" { + fmt.Fprintf(tw, "Runner:\t%s\n", p.Spec.Runner) + } + if c := p.Spec.Cluster; c != nil { + switch { + case c.FromCluster != "": + fmt.Fprintf(tw, "Cluster:\tfrom param %q\n", c.FromCluster) + case c.FromSandbox != "": + fmt.Fprintf(tw, "Cluster:\tfrom sandbox param %q\n", c.FromSandbox) + case c.FromRouteGroup != "": + fmt.Fprintf(tw, "Cluster:\tfrom route group param %q\n", c.FromRouteGroup) + case c.Pattern != "": + fmt.Fprintf(tw, "Cluster:\tpattern %q\n", c.Pattern) + } + } + fmt.Fprintf(tw, "Steps:\t%d\n", len(p.Spec.Steps)) + if len(p.Spec.Params) > 0 { + names := make([]string, len(p.Spec.Params)) + for i, param := range p.Spec.Params { + names[i] = param.Name + } + fmt.Fprintf(tw, "Params:\t%s\n", strings.Join(names, ", ")) + } + if len(p.Spec.Output) > 0 { + outputNames := make([]string, 0, len(p.Spec.Output)) + for k := range p.Spec.Output { + outputNames = append(outputNames, k) + } + slices.Sort(outputNames) + fmt.Fprintf(tw, "Outputs:\t%s\n", strings.Join(outputNames, ", ")) + } + if len(p.Spec.Requires) > 0 { + fmt.Fprintf(tw, "Requires:\t%s\n", strings.Join(p.Spec.Requires, ", ")) + } + } + if p.Status != nil { + fmt.Fprintf(tw, "Created:\t%s\n", utils.FormatTimestamp(p.Status.CreatedAt)) + if p.Status.CompiledFrom != "" { + fmt.Fprintf(tw, "Compiled From:\t%s\n", p.Status.CompiledFrom) + } + if p.Status.Executions > 0 { + fmt.Fprintf(tw, "Executions:\t%d\n", p.Status.Executions) + } + } + + return tw.Flush() +} diff --git a/internal/command/plan/recompile.go b/internal/command/plan/recompile.go new file mode 100644 index 00000000..3131da6b --- /dev/null +++ b/internal/command/plan/recompile.go @@ -0,0 +1,60 @@ +package plan + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/command/plantag" + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + sdkplans "github.com/signadot/go-sdk/client/plans" + "github.com/spf13/cobra" +) + +func newRecompile(plan *config.Plan) *cobra.Command { + cfg := &config.PlanRecompile{Plan: plan} + + cmd := &cobra.Command{ + Use: "recompile PLAN_ID", + Short: "Recompile a plan from its original prompt", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return recompile(cfg, cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0]) + }, + } + + cfg.AddFlags(cmd) + return cmd +} + +func recompile(cfg *config.PlanRecompile, out, log io.Writer, planID string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + + params := sdkplans.NewRecompilePlanParams(). + WithOrgName(cfg.Org). + WithPlanID(planID) + resp, err := cfg.Client.Plans.RecompilePlan(params, nil) + if err != nil { + return err + } + + if cfg.Tag != "" { + if _, err := plantag.ApplyTag(cfg.Plan, resp.Payload.ID, cfg.Tag); err != nil { + return fmt.Errorf("plan recompiled (id=%s) but tagging failed: %w", resp.Payload.ID, err) + } + fmt.Fprintf(log, "Tagged plan %s as %q\n", resp.Payload.ID, cfg.Tag) + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printPlanDetails(out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/plan/run.go b/internal/command/plan/run.go new file mode 100644 index 00000000..cf15bbcb --- /dev/null +++ b/internal/command/plan/run.go @@ -0,0 +1,433 @@ +package plan + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "github.com/go-openapi/runtime" + "github.com/signadot/cli/internal/command/planexec" + "github.com/signadot/cli/internal/config" + sdkprint "github.com/signadot/cli/internal/print" + "github.com/signadot/cli/internal/spinner" + sdkclient "github.com/signadot/go-sdk/client" + planlogs "github.com/signadot/go-sdk/client/plan_execution_logs" + planexecs "github.com/signadot/go-sdk/client/plan_executions" + plantags "github.com/signadot/go-sdk/client/plan_tags" + "github.com/signadot/go-sdk/models" + "github.com/spf13/cobra" +) + +func newRun(plan *config.Plan) *cobra.Command { + cfg := &config.PlanRun{Plan: plan} + + cmd := &cobra.Command{ + Use: "run [PLAN_ID] [--tag TAG_NAME] [--param key=value ...]", + Short: "Run a plan: create execution, wait for completion, print results", + Long: `Creates an execution of a compiled plan and polls until completion. + +Resolve the plan by ID (positional argument) or by tag name (--tag). +Use --attach to stream structured events (logs, outputs, result) to stdout. +Exit codes: 0 = completed, 1 = failed, 2 = cancelled.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runPlan(cfg, cmd.OutOrStdout(), cmd.ErrOrStderr(), args) + }, + } + + cfg.AddFlags(cmd) + return cmd +} + +func runPlan(cfg *config.PlanRun, out, log io.Writer, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), + os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + defer cancel() + + if cfg.Attach && cfg.OutputFormat == config.OutputFormatYAML { + return fmt.Errorf("--attach does not support -o yaml; use -o json for structured output") + } + + if err := cfg.InitAPIConfig(); err != nil { + return err + } + + // Resolve plan ID. + planID, err := resolvePlanID(cfg, args) + if err != nil { + return err + } + + // Build params. + params := buildParams(cfg.Params) + + // Create execution. + spec := &models.PlanExecutionSpec{ + PlanID: planID, + Params: params, + } + createParams := planexecs.NewCreatePlanExecutionParams(). + WithContext(ctx). + WithOrgName(cfg.Org). + WithData(spec) + createResp, err := cfg.Client.PlanExecutions.CreatePlanExecution(createParams, nil) + if err != nil { + return fmt.Errorf("creating execution: %w", err) + } + execID := createResp.Payload.ID + fmt.Fprintf(log, "Created execution %s for plan %s\n", execID, planID) + + // Fire-and-forget mode. + if !cfg.Wait { + return writeRunOutput(cfg, out, createResp.Payload) + } + + // Wait for completion: attach streams structured events, otherwise poll with spinner. + var exec *models.PlanExecution + if cfg.Attach { + exec, err = attachExecution(ctx, cfg, out, log, execID) + } else { + exec, err = pollExecution(ctx, cfg, log, execID) + } + if err != nil { + // On interrupt or timeout, try to cancel the execution. + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + fmt.Fprintf(log, "\nCancelling execution %s...\n", execID) + cancelCtx, cancelCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelCancel() + cancelParams := planexecs.NewCancelPlanExecutionParams(). + WithContext(cancelCtx). + WithOrgName(cfg.Org). + WithExecutionID(execID) + cfg.Client.PlanExecutions.CancelPlanExecution(cancelParams, nil) + os.Exit(2) + } + return err + } + + // Export outputs if --output-dir specified. + if cfg.OutputDir != "" { + if err := exportOutputs(cfg, log, exec); err != nil { + fmt.Fprintf(log, "Warning: output export failed: %v\n", err) + } + } + + // In attach mode, events were already emitted to stdout. Just exit. + if cfg.Attach { + switch exec.Status.Phase { + case models.PlansExecutionPhaseFailed: + os.Exit(1) + case models.PlansExecutionPhaseCancelled: + os.Exit(2) + } + return nil + } + + // Print result and exit with appropriate code. + // On failure/cancellation, write details to stderr so stdout stays clean. + switch exec.Status.Phase { + case models.PlansExecutionPhaseFailed: + if err := writeRunOutput(cfg, log, exec); err != nil { + fmt.Fprintf(log, "error rendering output: %v\n", err) + } + os.Exit(1) + case models.PlansExecutionPhaseCancelled: + if err := writeRunOutput(cfg, log, exec); err != nil { + fmt.Fprintf(log, "error rendering output: %v\n", err) + } + os.Exit(2) + default: + return writeRunOutput(cfg, out, exec) + } + return nil +} + +func resolvePlanID(cfg *config.PlanRun, args []string) (string, error) { + if cfg.Tag != "" && len(args) > 0 { + return "", fmt.Errorf("specify either a plan ID argument or --tag, not both") + } + if cfg.Tag == "" && len(args) == 0 { + return "", fmt.Errorf("specify a plan ID argument or --tag") + } + if cfg.Tag != "" { + params := plantags.NewGetPlanTagParams(). + WithOrgName(cfg.Org). + WithPlanTagName(cfg.Tag) + resp, err := cfg.Client.PlanTags.GetPlanTag(params, nil) + if err != nil { + return "", fmt.Errorf("resolving tag %q: %w", cfg.Tag, err) + } + if resp.Payload.Spec == nil || resp.Payload.Spec.PlanID == "" { + return "", fmt.Errorf("tag %q has no plan ID", cfg.Tag) + } + return resp.Payload.Spec.PlanID, nil + } + return args[0], nil +} + +func buildParams(tplVals config.TemplateVals) map[string]any { + if len(tplVals) == 0 { + return nil + } + params := make(map[string]any, len(tplVals)) + for _, tv := range tplVals { + // If value looks like JSON, pass through as-is. + v := tv.Val + if looksLikeJSON(v) { + var raw json.RawMessage + if json.Unmarshal([]byte(v), &raw) == nil { + params[tv.Var] = raw + continue + } + } + params[tv.Var] = v + } + return params +} + +func looksLikeJSON(s string) bool { + if len(s) == 0 { + return false + } + switch s[0] { + case '{', '[', '"': + return true + } + switch s { + case "true", "false", "null": + return true + } + // Check if it's a number. + if s[0] == '-' || (s[0] >= '0' && s[0] <= '9') { + var v json.RawMessage + return json.Unmarshal([]byte(s), &v) == nil + } + return false +} + +func isTerminal(phase models.PlansExecutionPhase) bool { + switch phase { + case models.PlansExecutionPhaseCompleted, + models.PlansExecutionPhaseFailed, + models.PlansExecutionPhaseCancelled: + return true + } + return false +} + +func pollExecution(ctx context.Context, cfg *config.PlanRun, log io.Writer, execID string) (*models.PlanExecution, error) { + if cfg.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cfg.Timeout) + defer cancel() + } + + spin := spinner.Start(log, "Execution") + defer spin.Stop() + + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + params := planexecs.NewGetPlanExecutionParams(). + WithContext(ctx). + WithOrgName(cfg.Org). + WithExecutionID(execID) + resp, err := cfg.Client.PlanExecutions.GetPlanExecution(params, nil) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + spin.StopFail() + return nil, err + } + spin.Messagef("error: %v", err) + } else { + ex := resp.Payload + if isTerminal(ex.Status.Phase) { + switch ex.Status.Phase { + case models.PlansExecutionPhaseCompleted: + spin.StopMessage(string(ex.Status.Phase)) + default: + spin.StopFail() + } + return ex, nil + } + msg := string(ex.Status.Phase) + if sc := ex.Status.StepCounts; sc != nil { + total := sc.Init + sc.Waiting + sc.Running + sc.Completed + sc.Failed + sc.Skipped + msg = fmt.Sprintf("%s (%d/%d steps completed)", msg, sc.Completed, total) + } + spin.Message(msg) + } + + select { + case <-ticker.C: + case <-ctx.Done(): + spin.StopFail() + return nil, ctx.Err() + } + } +} + +func attachExecution(ctx context.Context, cfg *config.PlanRun, out, log io.Writer, execID string) (*models.PlanExecution, error) { + if cfg.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cfg.Timeout) + defer cancel() + } + + jsonMode := cfg.OutputFormat == config.OutputFormatJSON + aw := sdkprint.NewAttachWriter(out, jsonMode) + + // Stream aggregated logs in background, emitting structured events. + logCtx, logCancel := context.WithCancel(ctx) + defer logCancel() + + logDone := make(chan error, 1) + go func() { + transportCfg := cfg.GetBaseTransport() + transportCfg.Consumers = map[string]runtime.Consumer{ + "text/event-stream": runtime.ByteStreamConsumer(), + } + err := cfg.APIClientWithCustomTransport(transportCfg, + func(c *sdkclient.SignadotAPI) error { + reader, writer := io.Pipe() + errch := make(chan error, 2) + + go func() { + _, err := sdkprint.ParseSSEAttach(reader, aw) + if errors.Is(err, io.ErrClosedPipe) { + err = nil + } + reader.Close() + errch <- err + }() + + go func() { + params := planlogs.NewStreamPlanExecutionLogsParams(). + WithContext(logCtx). + WithTimeout(0). + WithOrgName(cfg.Org). + WithExecutionID(execID) + _, err := c.PlanExecutionLogs.StreamPlanExecutionLogs(params, nil, writer) + if errors.Is(err, io.ErrClosedPipe) || errors.Is(err, context.Canceled) { + err = nil + } + writer.Close() + errch <- err + }() + + return errors.Join(<-errch, <-errch) + }) + logDone <- err + }() + + // Poll for terminal phase. + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + params := planexecs.NewGetPlanExecutionParams(). + WithContext(ctx). + WithOrgName(cfg.Org). + WithExecutionID(execID) + resp, err := cfg.Client.PlanExecutions.GetPlanExecution(params, nil) + if err != nil { + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + logCancel() + <-logDone + return nil, err + } + } else if isTerminal(resp.Payload.Status.Phase) { + logCancel() + <-logDone + + ex := resp.Payload + // Emit output events for resolved plan-level outputs. + if ex.Status != nil { + for _, o := range ex.Status.Outputs { + aw.Emit(sdkprint.AttachEvent{ + Type: "output", + Name: o.Name, + Value: o.Value, + }) + } + } + // Emit result event. + resultEvent := sdkprint.AttachEvent{ + Type: "result", + ID: ex.ID, + Phase: string(ex.Status.Phase), + } + if ex.Status.Error != "" { + resultEvent.Error = ex.Status.Error + } + aw.Emit(resultEvent) + + return ex, nil + } + + select { + case <-ticker.C: + case <-ctx.Done(): + logCancel() + <-logDone + return nil, ctx.Err() + } + } +} + +func writeRunOutput(cfg *config.PlanRun, out io.Writer, exec *models.PlanExecution) error { + switch cfg.OutputFormat { + case config.OutputFormatJSON: + return sdkprint.RawJSON(out, exec) + case config.OutputFormatYAML: + return sdkprint.RawYAML(out, exec) + default: + return planexec.PrintRunResult(out, exec) + } +} + +func exportOutputs(cfg *config.PlanRun, log io.Writer, exec *models.PlanExecution) error { + if exec.Status == nil || len(exec.Status.Outputs) == 0 { + return nil + } + + if err := os.MkdirAll(cfg.OutputDir, 0o755); err != nil { + return err + } + + transportCfg := cfg.GetBaseTransport() + transportCfg.OverrideConsumers = true + transportCfg.Consumers = map[string]runtime.Consumer{ + "*/*": runtime.ByteStreamConsumer(), + } + + return cfg.APIClientWithCustomTransport(transportCfg, + func(c *sdkclient.SignadotAPI) error { + for _, o := range exec.Status.Outputs { + outPath := filepath.Join(cfg.OutputDir, o.Name) + f, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("creating %s: %w", outPath, err) + } + params := planexecs.NewGetPlanExecutionOutputParams(). + WithOrgName(cfg.Org). + WithExecutionID(exec.ID). + WithOutputName(o.Name) + _, _, err = c.PlanExecutions.GetPlanExecutionOutput(params, nil, f) + f.Close() + if err != nil { + return fmt.Errorf("downloading %q: %w", o.Name, err) + } + fmt.Fprintf(log, "Exported %s\n", outPath) + } + return nil + }) +} diff --git a/internal/command/planexec/cancel.go b/internal/command/planexec/cancel.go new file mode 100644 index 00000000..993cb390 --- /dev/null +++ b/internal/command/planexec/cancel.go @@ -0,0 +1,51 @@ +package planexec + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + planexecs "github.com/signadot/go-sdk/client/plan_executions" + "github.com/spf13/cobra" +) + +func newCancel(exec *config.PlanExecution) *cobra.Command { + cfg := &config.PlanExecCancel{PlanExecution: exec} + + cmd := &cobra.Command{ + Use: "cancel EXECUTION_ID", + Short: "Cancel a running plan execution", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return cancelExec(cfg, cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0]) + }, + } + + return cmd +} + +func cancelExec(cfg *config.PlanExecCancel, out, log io.Writer, execID string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + params := planexecs.NewCancelPlanExecutionParams(). + WithOrgName(cfg.Org). + WithExecutionID(execID) + resp, err := cfg.Client.PlanExecutions.CancelPlanExecution(params, nil) + if err != nil { + return err + } + fmt.Fprintf(log, "Cancelled execution %q.\n", execID) + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printExecDetails(out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/planexec/command.go b/internal/command/planexec/command.go new file mode 100644 index 00000000..72ea5963 --- /dev/null +++ b/internal/command/planexec/command.go @@ -0,0 +1,27 @@ +package planexec + +import ( + "github.com/signadot/cli/internal/config" + "github.com/spf13/cobra" +) + +func New(plan *config.Plan) *cobra.Command { + cfg := &config.PlanExecution{Plan: plan} + + cmd := &cobra.Command{ + Use: "execution", + Short: "Manage plan executions (runs of compiled plans)", + Aliases: []string{"x"}, + } + + cmd.AddCommand( + newList(cfg), + newGet(cfg), + newCancel(cfg), + newOutputs(cfg), + newGetOutput(cfg), + newLogs(cfg), + ) + + return cmd +} diff --git a/internal/command/planexec/get.go b/internal/command/planexec/get.go new file mode 100644 index 00000000..2654f092 --- /dev/null +++ b/internal/command/planexec/get.go @@ -0,0 +1,50 @@ +package planexec + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + planexecs "github.com/signadot/go-sdk/client/plan_executions" + "github.com/spf13/cobra" +) + +func newGet(exec *config.PlanExecution) *cobra.Command { + cfg := &config.PlanExecGet{PlanExecution: exec} + + cmd := &cobra.Command{ + Use: "get EXECUTION_ID", + Short: "Get plan execution details", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return getExec(cfg, cmd.OutOrStdout(), args[0]) + }, + } + + return cmd +} + +func getExec(cfg *config.PlanExecGet, out io.Writer, execID string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + params := planexecs.NewGetPlanExecutionParams(). + WithOrgName(cfg.Org). + WithExecutionID(execID) + resp, err := cfg.Client.PlanExecutions.GetPlanExecution(params, nil) + if err != nil { + return err + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printExecDetails(out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/planexec/get_output.go b/internal/command/planexec/get_output.go new file mode 100644 index 00000000..6859f8b6 --- /dev/null +++ b/internal/command/planexec/get_output.go @@ -0,0 +1,165 @@ +package planexec + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/go-openapi/runtime" + "github.com/signadot/cli/internal/config" + "github.com/signadot/go-sdk/client" + planexecs "github.com/signadot/go-sdk/client/plan_executions" + "github.com/spf13/cobra" +) + +func newGetOutput(exec *config.PlanExecution) *cobra.Command { + cfg := &config.PlanExecGetOutput{PlanExecution: exec} + + cmd := &cobra.Command{ + Use: "get-output EXECUTION_ID [NAME]", + Short: "Download a plan execution output", + Long: `Download an output by name, or export all outputs to a directory. + +Single output: + signadot plan x get-output # plan-level output + signadot plan x get-output / # step-level output + +Bulk export: + signadot plan x get-output --all --dir ./outputs/ + signadot plan x get-output --all --dir ./outputs/ --metadata`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + if cfg.All { + if len(args) != 1 { + return fmt.Errorf("--all expects exactly one argument (execution ID)") + } + if cfg.Dir == "" { + return fmt.Errorf("--all requires --dir") + } + return getAllOutputs(cfg, cmd.ErrOrStderr(), args[0]) + } + if len(args) != 2 { + return fmt.Errorf("expected EXECUTION_ID and NAME arguments") + } + return getOutput(cfg, os.Stdout, args[0], args[1]) + }, + } + + cmd.Flags().BoolVar(&cfg.All, "all", false, "export all outputs") + cmd.Flags().StringVar(&cfg.Dir, "dir", "", "directory to export outputs to (requires --all)") + cmd.Flags().BoolVar(&cfg.Metadata, "metadata", false, "write metadata sidecar JSON files (requires --all)") + + return cmd +} + +func getOutput(cfg *config.PlanExecGetOutput, out io.Writer, execID, name string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + + transportCfg := cfg.GetBaseTransport() + transportCfg.OverrideConsumers = true + transportCfg.Consumers = map[string]runtime.Consumer{ + "*/*": runtime.ByteStreamConsumer(), + } + + return cfg.APIClientWithCustomTransport(transportCfg, + func(c *client.SignadotAPI) error { + // If name contains '/', treat as step_id/output_name. + if stepID, outputName, ok := strings.Cut(name, "/"); ok { + params := planexecs.NewGetStepOutputParams(). + WithTimeout(4*time.Minute). + WithOrgName(cfg.Org). + WithExecutionID(execID). + WithStepID(stepID). + WithOutputName(outputName) + _, _, err := c.PlanExecutions.GetStepOutput(params, nil, out) + if err != nil { + return fmt.Errorf("downloading output %q: %w", name, err) + } + return nil + } + + params := planexecs.NewGetPlanExecutionOutputParams(). + WithTimeout(4*time.Minute). + WithOrgName(cfg.Org). + WithExecutionID(execID). + WithOutputName(name) + _, _, err := c.PlanExecutions.GetPlanExecutionOutput(params, nil, out) + if err != nil { + return fmt.Errorf("downloading output %q: %w", name, err) + } + return nil + }) +} + +func getAllOutputs(cfg *config.PlanExecGetOutput, log io.Writer, execID string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + + // Fetch execution to get output list. + getParams := planexecs.NewGetPlanExecutionParams(). + WithOrgName(cfg.Org). + WithExecutionID(execID) + resp, err := cfg.Client.PlanExecutions.GetPlanExecution(getParams, nil) + if err != nil { + return err + } + + outputs := resp.Payload.Status.Outputs + if len(outputs) == 0 { + fmt.Fprintln(log, "No outputs.") + return nil + } + + if err := os.MkdirAll(cfg.Dir, 0o755); err != nil { + return err + } + + transportCfg := cfg.GetBaseTransport() + transportCfg.OverrideConsumers = true + transportCfg.Consumers = map[string]runtime.Consumer{ + "*/*": runtime.ByteStreamConsumer(), + } + + return cfg.APIClientWithCustomTransport(transportCfg, + func(c *client.SignadotAPI) error { + for _, o := range outputs { + outPath := filepath.Join(cfg.Dir, o.Name) + f, err := os.Create(outPath) + if err != nil { + return fmt.Errorf("creating %s: %w", outPath, err) + } + params := planexecs.NewGetPlanExecutionOutputParams(). + WithTimeout(4*time.Minute). + WithOrgName(cfg.Org). + WithExecutionID(execID). + WithOutputName(o.Name) + _, _, err = c.PlanExecutions.GetPlanExecutionOutput(params, nil, f) + f.Close() + if err != nil { + return fmt.Errorf("downloading %q: %w", o.Name, err) + } + fmt.Fprintf(log, "Exported %s\n", outPath) + + // Write metadata sidecar if requested. + if cfg.Metadata && o.Metadata != nil { + metaPath := outPath + ".meta.json" + metaJSON, err := json.MarshalIndent(o.Metadata, "", " ") + if err != nil { + return fmt.Errorf("marshaling metadata for %q: %w", o.Name, err) + } + if err := os.WriteFile(metaPath, metaJSON, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", metaPath, err) + } + fmt.Fprintf(log, "Exported %s\n", metaPath) + } + } + return nil + }) +} diff --git a/internal/command/planexec/list.go b/internal/command/planexec/list.go new file mode 100644 index 00000000..4e51414c --- /dev/null +++ b/internal/command/planexec/list.go @@ -0,0 +1,81 @@ +package planexec + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + planexecs "github.com/signadot/go-sdk/client/plan_executions" + "github.com/signadot/go-sdk/models" + "github.com/spf13/cobra" +) + +func newList(exec *config.PlanExecution) *cobra.Command { + cfg := &config.PlanExecList{PlanExecution: exec} + + cmd := &cobra.Command{ + Use: "list", + Short: "List plan executions", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return listExecs(cfg, cmd.OutOrStdout()) + }, + } + + cfg.AddFlags(cmd) + return cmd +} + +func listExecs(cfg *config.PlanExecList, out io.Writer) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + + var results []*models.PlanExecutionQueryResult + var cursor *string + + for { + params := planexecs.NewListPlanExecutionsParams(). + WithOrgName(cfg.Org) + if cfg.PlanID != "" { + params.WithPlanID(&cfg.PlanID) + } + if cfg.Tag != "" { + params.WithTag(&cfg.Tag) + } + if cfg.Phase != "" { + params.WithPhase(&cfg.Phase) + } + if cursor != nil { + params.WithCursor(cursor) + } + + resp, err := cfg.Client.PlanExecutions.ListPlanExecutions(params, nil) + if err != nil { + return err + } + results = append(results, resp.Payload...) + + // If fewer results than default page size, we're done. + if len(resp.Payload) == 0 { + break + } + last := resp.Payload[len(resp.Payload)-1] + if last.Cursor == "" { + break + } + cursor = &last.Cursor + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printExecTable(out, results) + case config.OutputFormatJSON: + return print.RawJSON(out, results) + case config.OutputFormatYAML: + return print.RawYAML(out, results) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/planexec/logs.go b/internal/command/planexec/logs.go new file mode 100644 index 00000000..da67ed6b --- /dev/null +++ b/internal/command/planexec/logs.go @@ -0,0 +1,114 @@ +package planexec + +import ( + "context" + "errors" + "io" + "os" + "os/signal" + "syscall" + + "github.com/go-openapi/runtime" + "github.com/signadot/cli/internal/config" + sdkprint "github.com/signadot/cli/internal/print" + "github.com/signadot/go-sdk/client" + planlogs "github.com/signadot/go-sdk/client/plan_execution_logs" + "github.com/spf13/cobra" +) + +func newLogs(exec *config.PlanExecution) *cobra.Command { + cfg := &config.PlanExecLogs{PlanExecution: exec} + + cmd := &cobra.Command{ + Use: "logs EXECUTION_ID [STEP_ID]", + Short: "Stream plan execution logs", + Long: `Stream logs for a plan execution. + +Without a step ID, streams aggregated logs for all steps. +With a step ID, streams logs for that specific step.`, + Args: cobra.RangeArgs(1, 2), + RunE: func(cmd *cobra.Command, args []string) error { + return streamLogs(cfg, cmd.OutOrStdout(), args) + }, + } + + cfg.AddFlags(cmd) + return cmd +} + +func showPlanLogs(ctx context.Context, cfg *config.API, out io.Writer, execID, stepID, stream string, tailLines int) error { + transportCfg := cfg.GetBaseTransport() + transportCfg.Consumers = map[string]runtime.Consumer{ + "text/event-stream": runtime.ByteStreamConsumer(), + } + + return cfg.APIClientWithCustomTransport(transportCfg, + func(c *client.SignadotAPI) error { + reader, writer := io.Pipe() + + errch := make(chan error, 2) + + go func() { + _, err := sdkprint.ParseSSEStream(reader, out) + if errors.Is(err, io.ErrClosedPipe) { + err = nil + } + reader.Close() + errch <- err + }() + + go func() { + var err error + if stepID != "" { + params := planlogs.NewStreamPlanExecutionStepLogsParams(). + WithContext(ctx). + WithTimeout(0). + WithOrgName(cfg.Org). + WithExecutionID(execID). + WithStepID(stepID). + WithStream(stream) + if tailLines > 0 { + tl := int64(tailLines) + params.WithTailLines(&tl) + } + _, err = c.PlanExecutionLogs.StreamPlanExecutionStepLogs(params, nil, writer) + } else { + params := planlogs.NewStreamPlanExecutionLogsParams(). + WithContext(ctx). + WithTimeout(0). + WithOrgName(cfg.Org). + WithExecutionID(execID) + if tailLines > 0 { + tl := int64(tailLines) + params.WithTailLines(&tl) + } + _, err = c.PlanExecutionLogs.StreamPlanExecutionLogs(params, nil, writer) + } + if errors.Is(err, io.ErrClosedPipe) { + err = nil + } + writer.Close() + errch <- err + }() + + return errors.Join(<-errch, <-errch) + }) +} + +func streamLogs(cfg *config.PlanExecLogs, out io.Writer, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), + os.Interrupt, syscall.SIGTERM, syscall.SIGHUP) + defer cancel() + + if err := cfg.InitAPIConfig(); err != nil { + return err + } + + execID := args[0] + stepID := "" + if len(args) > 1 { + stepID = args[1] + } + + return showPlanLogs(ctx, cfg.API, out, execID, stepID, cfg.Stream, int(cfg.TailLines)) +} diff --git a/internal/command/planexec/outputs.go b/internal/command/planexec/outputs.go new file mode 100644 index 00000000..68737b47 --- /dev/null +++ b/internal/command/planexec/outputs.go @@ -0,0 +1,142 @@ +package planexec + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + planexecs "github.com/signadot/go-sdk/client/plan_executions" + "github.com/signadot/go-sdk/models" + "github.com/spf13/cobra" +) + +func newOutputs(exec *config.PlanExecution) *cobra.Command { + cfg := &config.PlanExecOutputs{PlanExecution: exec} + + cmd := &cobra.Command{ + Use: "outputs EXECUTION_ID", + Short: "List all outputs of a plan execution (plan-level and step-level)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return listOutputs(cfg, cmd.OutOrStdout(), args[0]) + }, + } + + return cmd +} + +// allOutput unifies plan-level and step-level outputs for display. +type allOutput struct { + Name string `json:"name"` + Step string `json:"step"` + Scope string `json:"scope"` // "plan" or "step" + Type string `json:"type"` // "inline" or "artifact" + Size int64 `json:"size,omitempty"` + Ready *bool `json:"ready,omitempty"` +} + +func listOutputs(cfg *config.PlanExecOutputs, out io.Writer, execID string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + params := planexecs.NewGetPlanExecutionParams(). + WithOrgName(cfg.Org). + WithExecutionID(execID) + resp, err := cfg.Client.PlanExecutions.GetPlanExecution(params, nil) + if err != nil { + return err + } + + all := collectAllOutputs(resp.Payload) + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printAllOutputsTable(out, all) + case config.OutputFormatJSON: + return print.RawJSON(out, all) + case config.OutputFormatYAML: + return print.RawYAML(out, all) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} + +func collectAllOutputs(ex *models.PlanExecution) []allOutput { + if ex.Status == nil { + return nil + } + + // Track plan-level output names to avoid duplicating them in step section. + planOutputNames := map[string]bool{} + var all []allOutput + + // Plan-level outputs. + for _, o := range ex.Status.Outputs { + step := "" + if o.StepRef != nil { + step = o.StepRef.StepID + } + planOutputNames[step+"/"+o.Name] = true + all = append(all, allOutput{ + Name: o.Name, + Step: step, + Scope: "plan", + Type: outputType(o.Artifact), + Size: outputSize(o.Artifact, o.Value), + Ready: outputReady(o.Artifact), + }) + } + + // Step-level outputs (skip those already shown as plan-level). + for _, s := range ex.Status.Steps { + for _, o := range s.Outputs { + key := s.ID + "/" + o.Name + if planOutputNames[key] { + continue + } + all = append(all, allOutput{ + Name: o.Name, + Step: s.ID, + Scope: "step", + Type: outputType(o.Artifact), + Size: outputSize(o.Artifact, o.Value), + Ready: outputReady(o.Artifact), + }) + } + } + + return all +} + +func outputType(a *models.PlanArtifactRef) string { + if a != nil { + return "artifact" + } + return "inline" +} + +func outputSize(a *models.PlanArtifactRef, value any) int64 { + if a != nil { + return a.Size + } + if value != nil { + if s, ok := value.(string); ok { + return int64(len(s)) + } + b, err := json.Marshal(value) + if err == nil { + return int64(len(b)) + } + } + return 0 +} + +func outputReady(a *models.PlanArtifactRef) *bool { + t := true + if a != nil { + return &a.Ready + } + return &t +} diff --git a/internal/command/planexec/printers.go b/internal/command/planexec/printers.go new file mode 100644 index 00000000..025ca6a6 --- /dev/null +++ b/internal/command/planexec/printers.go @@ -0,0 +1,232 @@ +package planexec + +import ( + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/docker/go-units" + "github.com/signadot/cli/internal/sdtab" + "github.com/signadot/cli/internal/utils" + "github.com/signadot/go-sdk/models" + "github.com/xeonx/timeago" +) + +// PrintRunResult prints execution details with output summary for plan run. +// Inline values are printed to stdout; artifact outputs are listed by reference. +func PrintRunResult(out io.Writer, ex *models.PlanExecution) error { + if err := printExecDetails(out, ex); err != nil { + return err + } + + if ex.Status == nil || len(ex.Status.Outputs) == 0 { + return nil + } + + // Print inline values directly. + for _, o := range ex.Status.Outputs { + if o.Value != nil { + fmt.Fprintf(out, "\n--- %s ---\n%v\n", o.Name, o.Value) + } + } + + // List artifact outputs by reference. + var artifacts []*models.PlanOutputStatus + for _, o := range ex.Status.Outputs { + if o.Artifact != nil { + artifacts = append(artifacts, o) + } + } + if len(artifacts) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, "Artifact outputs:") + return printOutputsTable(out, artifacts) + } + return nil +} + +func printExecDetails(out io.Writer, ex *models.PlanExecution) error { + tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) + + fmt.Fprintf(tw, "ID:\t%s\n", ex.ID) + if ex.Spec != nil { + fmt.Fprintf(tw, "Plan:\t%s\n", ex.Spec.PlanID) + if ex.Spec.Cluster != "" { + fmt.Fprintf(tw, "Cluster:\t%s\n", ex.Spec.Cluster) + } + if ex.Spec.Runner != "" { + fmt.Fprintf(tw, "Runner:\t%s\n", ex.Spec.Runner) + } + } + if ex.Status != nil { + fmt.Fprintf(tw, "Phase:\t%s\n", ex.Status.Phase) + fmt.Fprintf(tw, "Created:\t%s\n", utils.FormatTimestamp(ex.Status.CreatedAt)) + if ex.Status.UpdatedAt != "" { + fmt.Fprintf(tw, "Updated:\t%s\n", utils.FormatTimestamp(ex.Status.UpdatedAt)) + } + if ex.Status.CompletedAt != "" { + fmt.Fprintf(tw, "Completed:\t%s\n", utils.FormatTimestamp(ex.Status.CompletedAt)) + } + if sc := ex.Status.StepCounts; sc != nil { + total := sc.Init + sc.Waiting + sc.Running + sc.Completed + sc.Failed + sc.Skipped + fmt.Fprintf(tw, "Steps:\t%d/%d completed", sc.Completed, total) + if sc.Failed > 0 { + fmt.Fprintf(tw, ", %d failed", sc.Failed) + } + if sc.Running > 0 { + fmt.Fprintf(tw, ", %d running", sc.Running) + } + fmt.Fprintln(tw) + } + if ex.Status.Error != "" { + fmt.Fprintf(tw, "Error:\t%s\n", ex.Status.Error) + } + } + + if err := tw.Flush(); err != nil { + return err + } + + // Print step status table if steps are present. + if ex.Status != nil && len(ex.Status.Steps) > 0 { + fmt.Fprintln(out) + return printStepTable(out, ex.Status.Steps) + } + + return nil +} + +type stepRow struct { + ID string `sdtab:"STEP"` + Phase string `sdtab:"PHASE"` + Error string `sdtab:"ERROR,trunc"` +} + +func printStepTable(out io.Writer, steps []*models.PlanStepStatus) error { + t := sdtab.New[stepRow](out) + t.AddHeader() + for _, s := range steps { + t.AddRow(stepRow{ + ID: s.ID, + Phase: string(s.Phase), + Error: s.Error, + }) + } + return t.Flush() +} + +type outputRow struct { + Name string `sdtab:"NAME"` + Step string `sdtab:"STEP"` + Type string `sdtab:"TYPE"` + Size string `sdtab:"SIZE"` + Ready string `sdtab:"READY"` +} + +func printOutputsTable(out io.Writer, outputs []*models.PlanOutputStatus) error { + t := sdtab.New[outputRow](out) + t.AddHeader() + for _, o := range outputs { + step := "" + if o.StepRef != nil { + step = o.StepRef.StepID + } + typ := "inline" + size := "" + ready := "-" + if o.Artifact != nil { + typ = "artifact" + size = units.HumanSize(float64(o.Artifact.Size)) + if o.Artifact.Ready { + ready = "true" + } else { + ready = "false" + } + } + t.AddRow(outputRow{ + Name: o.Name, + Step: step, + Type: typ, + Size: size, + Ready: ready, + }) + } + return t.Flush() +} + +type allOutputRow struct { + Name string `sdtab:"NAME"` + Step string `sdtab:"STEP"` + Scope string `sdtab:"SCOPE"` + Storage string `sdtab:"STORAGE"` + Size string `sdtab:"SIZE"` + Ready string `sdtab:"READY"` +} + +func printAllOutputsTable(out io.Writer, outputs []allOutput) error { + t := sdtab.New[allOutputRow](out) + t.AddHeader() + for _, o := range outputs { + size := "" + ready := "-" + if o.Size > 0 { + size = units.HumanSize(float64(o.Size)) + } + if o.Ready != nil { + if *o.Ready { + ready = "true" + } else { + ready = "false" + } + } + t.AddRow(allOutputRow{ + Name: o.Name, + Step: o.Step, + Scope: o.Scope, + Storage: o.Type, + Size: size, + Ready: ready, + }) + } + return t.Flush() +} + +type execRow struct { + ID string `sdtab:"ID"` + Plan string `sdtab:"PLAN"` + Phase string `sdtab:"PHASE"` + Steps string `sdtab:"STEPS"` + Created string `sdtab:"CREATED"` +} + +func printExecTable(out io.Writer, results []*models.PlanExecutionQueryResult) error { + t := sdtab.New[execRow](out) + t.AddHeader() + for _, r := range results { + var plan, phase, steps, created string + if r.Spec != nil { + plan = r.Spec.PlanID + } + if r.Status != nil { + phase = string(r.Status.Phase) + if sc := r.Status.StepCounts; sc != nil { + total := sc.Init + sc.Waiting + sc.Running + sc.Completed + sc.Failed + sc.Skipped + steps = fmt.Sprintf("%d/%d", sc.Completed, total) + } + if r.Status.CreatedAt != "" { + if ts, err := time.Parse(time.RFC3339, r.Status.CreatedAt); err == nil { + created = timeago.NoMax(timeago.English).Format(ts) + } + } + } + t.AddRow(execRow{ + ID: r.ID, + Plan: plan, + Phase: phase, + Steps: steps, + Created: created, + }) + } + return t.Flush() +} diff --git a/internal/command/plantag/apply.go b/internal/command/plantag/apply.go new file mode 100644 index 00000000..6b772900 --- /dev/null +++ b/internal/command/plantag/apply.go @@ -0,0 +1,64 @@ +package plantag + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + plantags "github.com/signadot/go-sdk/client/plan_tags" + "github.com/signadot/go-sdk/models" + "github.com/spf13/cobra" +) + +func newApply(tag *config.PlanTag) *cobra.Command { + cfg := &config.PlanTagApply{PlanTag: tag} + + cmd := &cobra.Command{ + Use: "apply TAG_NAME --plan PLAN_ID", + Short: "Create or move a plan tag", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return applyTag(cfg, cmd.OutOrStdout(), args[0]) + }, + } + + cfg.AddFlags(cmd) + return cmd +} + +// ApplyTag creates or moves a plan tag. Caller must have already called InitAPIConfig. +func ApplyTag(cfg *config.Plan, planID, tagName string) (*models.PlanTag, error) { + params := plantags.NewPutPlanTagParams(). + WithOrgName(cfg.Org). + WithPlanTagName(tagName). + WithData(&models.PlanTagSpec{ + PlanID: planID, + }) + resp, err := cfg.Client.PlanTags.PutPlanTag(params, nil) + if err != nil { + return nil, err + } + return resp.Payload, nil +} + +func applyTag(cfg *config.PlanTagApply, out io.Writer, tagName string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + tag, err := ApplyTag(cfg.Plan, cfg.PlanID, tagName) + if err != nil { + return err + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printTagDetails(out, tag) + case config.OutputFormatJSON: + return print.RawJSON(out, tag) + case config.OutputFormatYAML: + return print.RawYAML(out, tag) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/plantag/command.go b/internal/command/plantag/command.go new file mode 100644 index 00000000..50a2f3f3 --- /dev/null +++ b/internal/command/plantag/command.go @@ -0,0 +1,25 @@ +package plantag + +import ( + "github.com/signadot/cli/internal/config" + "github.com/spf13/cobra" +) + +func New(plan *config.Plan) *cobra.Command { + cfg := &config.PlanTag{Plan: plan} + + cmd := &cobra.Command{ + Use: "tag", + Short: "Manage plan tags (named references to plans, like Docker tags)", + Aliases: []string{"t"}, + } + + cmd.AddCommand( + newList(cfg), + newGet(cfg), + newApply(cfg), + newDelete(cfg), + ) + + return cmd +} diff --git a/internal/command/plantag/delete.go b/internal/command/plantag/delete.go new file mode 100644 index 00000000..664f10ca --- /dev/null +++ b/internal/command/plantag/delete.go @@ -0,0 +1,40 @@ +package plantag + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + plantags "github.com/signadot/go-sdk/client/plan_tags" + "github.com/spf13/cobra" +) + +func newDelete(tag *config.PlanTag) *cobra.Command { + cfg := &config.PlanTagDelete{PlanTag: tag} + + cmd := &cobra.Command{ + Use: "delete TAG_NAME", + Short: "Delete a plan tag", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return deleteTag(cfg, cmd.ErrOrStderr(), args[0]) + }, + } + + return cmd +} + +func deleteTag(cfg *config.PlanTagDelete, log io.Writer, tagName string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + params := plantags.NewDeletePlanTagParams(). + WithOrgName(cfg.Org). + WithPlanTagName(tagName) + _, err := cfg.Client.PlanTags.DeletePlanTag(params, nil) + if err != nil { + return err + } + fmt.Fprintf(log, "Deleted plan tag %q.\n", tagName) + return nil +} diff --git a/internal/command/plantag/get.go b/internal/command/plantag/get.go new file mode 100644 index 00000000..beb57f1b --- /dev/null +++ b/internal/command/plantag/get.go @@ -0,0 +1,50 @@ +package plantag + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + plantags "github.com/signadot/go-sdk/client/plan_tags" + "github.com/spf13/cobra" +) + +func newGet(tag *config.PlanTag) *cobra.Command { + cfg := &config.PlanTagGet{PlanTag: tag} + + cmd := &cobra.Command{ + Use: "get TAG_NAME", + Short: "Get a plan tag", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return getTag(cfg, cmd.OutOrStdout(), args[0]) + }, + } + + return cmd +} + +func getTag(cfg *config.PlanTagGet, out io.Writer, tagName string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + params := plantags.NewGetPlanTagParams(). + WithOrgName(cfg.Org). + WithPlanTagName(tagName) + resp, err := cfg.Client.PlanTags.GetPlanTag(params, nil) + if err != nil { + return err + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printTagDetails(out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/plantag/list.go b/internal/command/plantag/list.go new file mode 100644 index 00000000..d87e31dd --- /dev/null +++ b/internal/command/plantag/list.go @@ -0,0 +1,48 @@ +package plantag + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + plantags "github.com/signadot/go-sdk/client/plan_tags" + "github.com/spf13/cobra" +) + +func newList(tag *config.PlanTag) *cobra.Command { + cfg := &config.PlanTagList{PlanTag: tag} + + cmd := &cobra.Command{ + Use: "list", + Short: "List plan tags", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return listTags(cfg, cmd.OutOrStdout()) + }, + } + + return cmd +} + +func listTags(cfg *config.PlanTagList, out io.Writer) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + resp, err := cfg.Client.PlanTags.ListPlanTags( + plantags.NewListPlanTagsParams().WithOrgName(cfg.Org), nil) + if err != nil { + return err + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printTagTable(out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/plantag/printers.go b/internal/command/plantag/printers.go new file mode 100644 index 00000000..015dd242 --- /dev/null +++ b/internal/command/plantag/printers.go @@ -0,0 +1,129 @@ +package plantag + +import ( + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/signadot/cli/internal/print" + "github.com/signadot/cli/internal/sdtab" + "github.com/signadot/cli/internal/utils" + "github.com/signadot/go-sdk/models" + "github.com/xeonx/timeago" +) + +type tagRow struct { + Name string `sdtab:"NAME"` + PlanID string `sdtab:"PLAN ID"` + Created string `sdtab:"CREATED"` + Updated string `sdtab:"UPDATED"` +} + +func printTagTable(out io.Writer, tags []*models.PlanTag) error { + t := sdtab.New[tagRow](out) + t.AddHeader() + for _, tag := range tags { + var planID, created, updated string + if tag.Spec != nil { + planID = tag.Spec.PlanID + } + if tag.Status != nil { + if tag.Status.CreatedAt != "" { + if ts, err := time.Parse(time.RFC3339, tag.Status.CreatedAt); err == nil { + created = timeago.NoMax(timeago.English).Format(ts) + } + } + if tag.Status.UpdatedAt != "" { + if ts, err := time.Parse(time.RFC3339, tag.Status.UpdatedAt); err == nil { + updated = timeago.NoMax(timeago.English).Format(ts) + } + } + } + t.AddRow(tagRow{ + Name: tag.Name, + PlanID: planID, + Created: created, + Updated: updated, + }) + } + return t.Flush() +} + +func printTagDetails(out io.Writer, tag *models.PlanTag) error { + tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) + + fmt.Fprintf(tw, "Name:\t%s\n", tag.Name) + if tag.Spec != nil { + fmt.Fprintf(tw, "Plan ID:\t%s\n", tag.Spec.PlanID) + } + if tag.Status != nil { + fmt.Fprintf(tw, "Created:\t%s\n", utils.FormatTimestamp(tag.Status.CreatedAt)) + fmt.Fprintf(tw, "Updated:\t%s\n", utils.FormatTimestamp(tag.Status.UpdatedAt)) + } + + if err := tw.Flush(); err != nil { + return err + } + + // If the tag has an inlined plan, show its details. + if tag.Plan != nil { + fmt.Fprintln(out) + fmt.Fprintln(out, "Plan:") + tw = tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) + fmt.Fprintf(tw, " ID:\t%s\n", tag.Plan.ID) + if tag.Plan.Spec != nil { + fmt.Fprintf(tw, " Steps:\t%d\n", len(tag.Plan.Spec.Steps)) + if tag.Plan.Spec.Prompt != "" { + fmt.Fprintf(tw, " Prompt:\t%s\n", print.FirstLine(tag.Plan.Spec.Prompt)) + } + } + if tag.Plan.Status != nil { + fmt.Fprintf(tw, " Created:\t%s\n", utils.FormatTimestamp(tag.Plan.Status.CreatedAt)) + } + if err := tw.Flush(); err != nil { + return err + } + } + + // Print tag history if present. + if len(tag.History) > 1 { + fmt.Fprintln(out) + fmt.Fprintln(out, "History:") + return printHistoryTable(out, tag.History) + } + + return nil +} + +type historyRow struct { + PlanID string `sdtab:"PLAN ID"` + TaggedAt string `sdtab:"TAGGED"` + UntaggedAt string `sdtab:"UNTAGGED"` +} + +func printHistoryTable(out io.Writer, history []*models.TagMapping) error { + t := sdtab.New[historyRow](out) + t.AddHeader() + for _, h := range history { + tagged := "" + if h.TaggedAt != "" { + if ts, err := time.Parse(time.RFC3339, h.TaggedAt); err == nil { + tagged = timeago.NoMax(timeago.English).Format(ts) + } + } + untagged := "(current)" + if h.UntaggedAt != "" { + if ts, err := time.Parse(time.RFC3339, h.UntaggedAt); err == nil { + untagged = timeago.NoMax(timeago.English).Format(ts) + } + } + t.AddRow(historyRow{ + PlanID: h.PlanID, + TaggedAt: tagged, + UntaggedAt: untagged, + }) + } + return t.Flush() +} + diff --git a/internal/config/logs.go b/internal/config/logs.go index efc17ef5..f5ce6931 100644 --- a/internal/config/logs.go +++ b/internal/config/logs.go @@ -8,13 +8,16 @@ type Logs struct { *API Job string + Plan string + Step string Stream string TailLines uint } func (c *Logs) AddFlags(cmd *cobra.Command) { cmd.Flags().StringVarP(&c.Job, "job", "j", "", "job name whose log lines will be displayed") - cmd.MarkFlagRequired("job") + cmd.Flags().StringVar(&c.Plan, "plan", "", "plan execution ID whose log lines will be displayed") + cmd.Flags().StringVar(&c.Step, "step", "", "step ID (used with --plan for step-level logs)") cmd.Flags().StringVarP(&c.Stream, "stream", "s", "stdout", "stream from where to display log lines (stdout or stderr)") cmd.Flags().UintVarP(&c.TailLines, "tail", "t", 0, "lines of recent log file to display, defaults to 0, showing all log lines") diff --git a/internal/config/plan.go b/internal/config/plan.go new file mode 100644 index 00000000..4e9b726e --- /dev/null +++ b/internal/config/plan.go @@ -0,0 +1,56 @@ +package config + +import "github.com/spf13/cobra" + +type Plan struct { + *API +} + +type PlanCompile struct { + *Plan + + // Flags + Filename string + Tag string +} + +func (c *PlanCompile) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&c.Filename, "filename", "f", "", "file containing the prompt to compile") + cmd.MarkFlagRequired("filename") + cmd.Flags().StringVar(&c.Tag, "tag", "", "tag the compiled plan with this name") +} + +type PlanCreate struct { + *Plan + + // Flags + Filename string + Tag string + TemplateVals TemplateVals +} + +func (c *PlanCreate) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&c.Filename, "filename", "f", "", "YAML or JSON file containing the plan spec") + cmd.MarkFlagRequired("filename") + cmd.Flags().StringVar(&c.Tag, "tag", "", "tag the created plan with this name") + cmd.Flags().Var(&c.TemplateVals, "set", "--set var=val") +} + +type PlanGet struct { + *Plan +} + +type PlanRecompile struct { + *Plan + + // Flags + Tag string +} + +func (c *PlanRecompile) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&c.Tag, "tag", "", "tag the recompiled plan with this name") +} + +type PlanDelete struct { + *Plan +} diff --git a/internal/config/planexec.go b/internal/config/planexec.go new file mode 100644 index 00000000..94f97544 --- /dev/null +++ b/internal/config/planexec.go @@ -0,0 +1,56 @@ +package config + +import "github.com/spf13/cobra" + +type PlanExecution struct { + *Plan +} + +type PlanExecGet struct { + *PlanExecution +} + +type PlanExecCancel struct { + *PlanExecution +} + +type PlanExecOutputs struct { + *PlanExecution +} + +type PlanExecGetOutput struct { + *PlanExecution + + // Flags + All bool + Dir string + Metadata bool +} + +type PlanExecList struct { + *PlanExecution + + // Flags + PlanID string + Tag string + Phase string +} + +func (c *PlanExecList) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&c.PlanID, "plan", "", "filter by plan ID") + cmd.Flags().StringVar(&c.Tag, "tag", "", "filter by plan tag name") + cmd.Flags().StringVar(&c.Phase, "phase", "", "filter by execution phase") +} + +type PlanExecLogs struct { + *PlanExecution + + // Flags + Stream string + TailLines uint +} + +func (c *PlanExecLogs) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&c.Stream, "stream", "s", "stdout", "stream type (stdout or stderr), only used with a step ID") + cmd.Flags().UintVarP(&c.TailLines, "tail", "t", 0, "number of lines from the end to show (0 = all)") +} diff --git a/internal/config/planrun.go b/internal/config/planrun.go new file mode 100644 index 00000000..50f1bae8 --- /dev/null +++ b/internal/config/planrun.go @@ -0,0 +1,28 @@ +package config + +import ( + "time" + + "github.com/spf13/cobra" +) + +type PlanRun struct { + *Plan + + // Flags + Tag string + Params TemplateVals + Wait bool + Attach bool + Timeout time.Duration + OutputDir string +} + +func (c *PlanRun) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&c.Tag, "tag", "", "run the plan referenced by this tag (alternative to plan ID argument)") + cmd.Flags().Var(&c.Params, "param", "parameter in key=value form (can be repeated)") + cmd.Flags().BoolVar(&c.Wait, "wait", true, "wait for execution to complete") + cmd.Flags().BoolVar(&c.Attach, "attach", false, "stream structured events (logs, outputs, result) to stdout") + cmd.Flags().DurationVar(&c.Timeout, "timeout", 0, "timeout for waiting (0 means no timeout)") + cmd.Flags().StringVar(&c.OutputDir, "output-dir", "", "directory to export all outputs to on completion") +} diff --git a/internal/config/plantag.go b/internal/config/plantag.go new file mode 100644 index 00000000..35e7cdbf --- /dev/null +++ b/internal/config/plantag.go @@ -0,0 +1,31 @@ +package config + +import "github.com/spf13/cobra" + +type PlanTag struct { + *Plan +} + +type PlanTagApply struct { + *PlanTag + + // Flags + PlanID string +} + +func (c *PlanTagApply) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVar(&c.PlanID, "plan", "", "plan ID to tag") + cmd.MarkFlagRequired("plan") +} + +type PlanTagGet struct { + *PlanTag +} + +type PlanTagList struct { + *PlanTag +} + +type PlanTagDelete struct { + *PlanTag +} diff --git a/internal/print/attach.go b/internal/print/attach.go new file mode 100644 index 00000000..e3d31be3 --- /dev/null +++ b/internal/print/attach.go @@ -0,0 +1,91 @@ +package print + +import ( + "encoding/json" + "fmt" + "io" + "strings" + "sync" + "time" +) + +// AttachEvent represents a structured event emitted during --attach mode. +type AttachEvent struct { + Time time.Time `json:"time"` + Type string `json:"type"` // "log", "output", "result" + Step string `json:"step,omitempty"` // for log events + Stream string `json:"stream,omitempty"` // "stdout" or "stderr", for log events + Msg string `json:"msg,omitempty"` // for log events + Name string `json:"name,omitempty"` // for output events + Value any `json:"value,omitempty"` // for output events + ID string `json:"id,omitempty"` // for result events + Phase string `json:"phase,omitempty"` // for result events + Error string `json:"error,omitempty"` // for result events (if failed) +} + +// AttachWriter writes structured events to an io.Writer in either +// JSON (one object per line) or slog-style text format. +type AttachWriter struct { + mu sync.Mutex + out io.Writer + json bool +} + +// NewAttachWriter creates an AttachWriter. If jsonMode is true, events +// are written as JSON lines; otherwise as slog-style text. +func NewAttachWriter(out io.Writer, jsonMode bool) *AttachWriter { + return &AttachWriter{out: out, json: jsonMode} +} + +// Emit writes an event. +func (w *AttachWriter) Emit(e AttachEvent) { + if e.Time.IsZero() { + e.Time = time.Now().UTC() + } + w.mu.Lock() + defer w.mu.Unlock() + + if w.json { + data, _ := json.Marshal(e) + w.out.Write(data) + w.out.Write([]byte("\n")) + } else { + w.out.Write([]byte(formatText(e))) + w.out.Write([]byte("\n")) + } +} + +func formatText(e AttachEvent) string { + var b strings.Builder + fmt.Fprintf(&b, "time=%s", e.Time.Format(time.TimeOnly)) + fmt.Fprintf(&b, " type=%s", e.Type) + + switch e.Type { + case "log": + if e.Step != "" { + fmt.Fprintf(&b, " step=%s", e.Step) + } + if e.Stream != "" { + fmt.Fprintf(&b, " stream=%s", e.Stream) + } + fmt.Fprintf(&b, " msg=%s", quoteIfNeeded(strings.TrimRight(e.Msg, "\n"))) + case "output": + fmt.Fprintf(&b, " name=%s", e.Name) + fmt.Fprintf(&b, " value=%s", quoteIfNeeded(fmt.Sprint(e.Value))) + case "result": + fmt.Fprintf(&b, " id=%s", e.ID) + fmt.Fprintf(&b, " phase=%s", e.Phase) + if e.Error != "" { + fmt.Fprintf(&b, " error=%s", quoteIfNeeded(e.Error)) + } + } + + return b.String() +} + +func quoteIfNeeded(s string) string { + if s == "" || strings.ContainsAny(s, " \t\n\"=") { + return fmt.Sprintf("%q", s) + } + return s +} diff --git a/internal/print/sse.go b/internal/print/sse.go new file mode 100644 index 00000000..20224dc0 --- /dev/null +++ b/internal/print/sse.go @@ -0,0 +1,105 @@ +package print + +import ( + "encoding/json" + "errors" + "io" + + "github.com/jclem/sseparser" +) + +type sseEvent struct { + Event string `sse:"event"` + Data string `sse:"data"` +} + +type sseMessage struct { + Message string `json:"message"` + Cursor string `json:"cursor"` + Step string `json:"step,omitempty"` + Stream string `json:"stream,omitempty"` +} + +// ParseSSEAttach reads SSE events and emits structured AttachEvents. +// Used by plan run --attach to produce structured output. +func ParseSSEAttach(reader io.Reader, w *AttachWriter) (string, error) { + scanner := sseparser.NewStreamScanner(reader) + var lastCursor string + + for { + var e sseEvent + _, err := scanner.UnmarshalNext(&e) + if err != nil { + if errors.Is(err, sseparser.ErrStreamEOF) { + err = nil + } + return lastCursor, err + } + + switch e.Event { + case "message": + var m sseMessage + if err := json.Unmarshal([]byte(e.Data), &m); err != nil { + return lastCursor, err + } + if m.Message == "" { + continue + } + w.Emit(AttachEvent{ + Type: "log", + Step: m.Step, + Stream: m.Stream, + Msg: m.Message, + }) + lastCursor = m.Cursor + case "error": + return lastCursor, errors.New(e.Data) + case "signal": + if e.Data == "EOF" { + return lastCursor, nil + } + } + } +} + +// ParseSSEStream reads SSE events and writes message content to out. +// Returns the last cursor and any error. +func ParseSSEStream(reader io.Reader, out io.Writer) (string, error) { + scanner := sseparser.NewStreamScanner(reader) + var lastCursor string + + for { + var e sseEvent + _, err := scanner.UnmarshalNext(&e) + if err != nil { + if errors.Is(err, sseparser.ErrStreamEOF) { + err = nil + } + return lastCursor, err + } + + switch e.Event { + case "message": + var m sseMessage + err = json.Unmarshal([]byte(e.Data), &m) + if err != nil { + return lastCursor, err + } + if m.Message == "" { + continue + } + out.Write([]byte(m.Message)) + lastCursor = m.Cursor + case "error": + return lastCursor, errors.New(string(e.Data)) + case "signal": + switch e.Data { + case "EOF": + return lastCursor, nil + case "RESTART": + out.Write([]byte("\n\n-------------------------------------------------------------------------------\n")) + out.Write([]byte("WARNING: The execution has been restarted...\n\n")) + } + } + } +} diff --git a/internal/print/text.go b/internal/print/text.go new file mode 100644 index 00000000..c45b8ab2 --- /dev/null +++ b/internal/print/text.go @@ -0,0 +1,15 @@ +package print + +import "strings" + +// FirstLine returns the first line of s, trimmed and truncated to 80 chars. +func FirstLine(s string) string { + s = strings.TrimSpace(s) + if i := strings.IndexByte(s, '\n'); i >= 0 { + s = s[:i] + } + if len(s) > 80 { + s = s[:77] + "..." + } + return s +} From 7926bc94251d24c8bdf2684b27b9ba108c793865 Mon Sep 17 00:00:00 2001 From: David Orozco <34346877+davixcky@users.noreply.github.com> Date: Wed, 8 Apr 2026 11:21:30 -0500 Subject: [PATCH 2/7] Fix get-output --all --dir to include step-scoped outputs (#315) The getAllOutputs() function only iterated over plan-level outputs (resp.Payload.Status.Outputs), causing "No outputs." when only step-scoped outputs existed. Now also collects outputs from resp.Payload.Status.Steps[*].Outputs, downloads them via the GetStepOutput API, and writes them into // subdirectories to avoid name collisions. Co-authored-by: Claude Opus 4.6 (1M context) --- internal/command/planexec/get_output.go | 94 +++++++++++++++++++------ 1 file changed, 74 insertions(+), 20 deletions(-) diff --git a/internal/command/planexec/get_output.go b/internal/command/planexec/get_output.go index 6859f8b6..f910bf24 100644 --- a/internal/command/planexec/get_output.go +++ b/internal/command/planexec/get_output.go @@ -13,6 +13,7 @@ import ( "github.com/signadot/cli/internal/config" "github.com/signadot/go-sdk/client" planexecs "github.com/signadot/go-sdk/client/plan_executions" + "github.com/signadot/go-sdk/models" "github.com/spf13/cobra" ) @@ -111,12 +112,16 @@ func getAllOutputs(cfg *config.PlanExecGetOutput, log io.Writer, execID string) return err } - outputs := resp.Payload.Status.Outputs - if len(outputs) == 0 { + // Reuse collectAllOutputs to gather plan-level and step-level outputs. + all := collectAllOutputs(resp.Payload) + if len(all) == 0 { fmt.Fprintln(log, "No outputs.") return nil } + // Build a metadata lookup from the raw response for sidecar export. + metadataMap := buildMetadataMap(resp.Payload) + if err := os.MkdirAll(cfg.Dir, 0o755); err != nil { return err } @@ -129,37 +134,86 @@ func getAllOutputs(cfg *config.PlanExecGetOutput, log io.Writer, execID string) return cfg.APIClientWithCustomTransport(transportCfg, func(c *client.SignadotAPI) error { - for _, o := range outputs { - outPath := filepath.Join(cfg.Dir, o.Name) + for _, o := range all { + // Determine file path: plan-level → /, step-level → //. + var outPath string + if o.Step != "" { + stepDir := filepath.Join(cfg.Dir, o.Step) + if err := os.MkdirAll(stepDir, 0o755); err != nil { + return fmt.Errorf("creating %s: %w", stepDir, err) + } + outPath = filepath.Join(stepDir, o.Name) + } else { + outPath = filepath.Join(cfg.Dir, o.Name) + } + f, err := os.Create(outPath) if err != nil { return fmt.Errorf("creating %s: %w", outPath, err) } - params := planexecs.NewGetPlanExecutionOutputParams(). - WithTimeout(4*time.Minute). - WithOrgName(cfg.Org). - WithExecutionID(execID). - WithOutputName(o.Name) - _, _, err = c.PlanExecutions.GetPlanExecutionOutput(params, nil, f) + + // Download using the appropriate API based on scope. + qualName := o.Name + if o.Scope == "step" { + qualName = o.Step + "/" + o.Name + params := planexecs.NewGetStepOutputParams(). + WithTimeout(4*time.Minute). + WithOrgName(cfg.Org). + WithExecutionID(execID). + WithStepID(o.Step). + WithOutputName(o.Name) + _, _, err = c.PlanExecutions.GetStepOutput(params, nil, f) + } else { + params := planexecs.NewGetPlanExecutionOutputParams(). + WithTimeout(4*time.Minute). + WithOrgName(cfg.Org). + WithExecutionID(execID). + WithOutputName(o.Name) + _, _, err = c.PlanExecutions.GetPlanExecutionOutput(params, nil, f) + } f.Close() if err != nil { - return fmt.Errorf("downloading %q: %w", o.Name, err) + return fmt.Errorf("downloading %q: %w", qualName, err) } fmt.Fprintf(log, "Exported %s\n", outPath) // Write metadata sidecar if requested. - if cfg.Metadata && o.Metadata != nil { - metaPath := outPath + ".meta.json" - metaJSON, err := json.MarshalIndent(o.Metadata, "", " ") - if err != nil { - return fmt.Errorf("marshaling metadata for %q: %w", o.Name, err) - } - if err := os.WriteFile(metaPath, metaJSON, 0o644); err != nil { - return fmt.Errorf("writing %s: %w", metaPath, err) + if cfg.Metadata { + if meta := metadataMap[qualName]; meta != nil { + metaPath := outPath + ".meta.json" + metaJSON, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return fmt.Errorf("marshaling metadata for %q: %w", qualName, err) + } + if err := os.WriteFile(metaPath, metaJSON, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", metaPath, err) + } + fmt.Fprintf(log, "Exported %s\n", metaPath) } - fmt.Fprintf(log, "Exported %s\n", metaPath) } } return nil }) } + +// buildMetadataMap extracts metadata from plan-level and step-level outputs, +// keyed by "name" (plan-level) or "step/name" (step-level). +func buildMetadataMap(ex *models.PlanExecution) map[string]any { + m := map[string]any{} + if ex.Status == nil { + return m + } + for _, o := range ex.Status.Outputs { + if o.Metadata != nil { + m[o.Name] = o.Metadata + } + } + for _, s := range ex.Status.Steps { + for _, o := range s.Outputs { + if o.Metadata != nil { + m[s.ID+"/"+o.Name] = o.Metadata + } + } + } + return m +} From 9163e249f934d3e66640f011f9c3f363e2806080 Mon Sep 17 00:00:00 2001 From: Daniel De Vera <118383315+daniel-de-vera@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:26:01 -0300 Subject: [PATCH 3/7] Add --cluster, --sandbox, and --route-group flags to plan run (#316) * Add --cluster flag to `signadot plan run` Plans without cluster affinity now require an explicit cluster in the execution request. This adds a --cluster flag that maps to the Cluster field in PlanExecutionSpec. Co-Authored-By: Claude Opus 4.6 (1M context) * Update go-sdk to v0.3.8-0.20260407201055-78b3ac589bac Picks up the latest SDK with cluster field support in PlanExecutionSpec. Co-Authored-By: Claude Opus 4.6 (1M context) * Add --sandbox and --route-group flags to plan run, update go-sdk - Add --sandbox and --route-group mutually exclusive flags that translate to the correct plan param based on cluster affinity (FromAnyTarget for polymorphic JSON, FromSandbox/FromRouteGroup for plain strings) - Consolidate resolvePlanID + fetchPlanSpec into resolvePlan, which returns the full RunnablePlan (avoiding double fetch when using --tag) - Update go-sdk to v0.3.8-0.20260409131400-73b58199c972 for FromAnyTarget and RoutingTarget types Co-Authored-By: Claude Opus 4.6 (1M context) --------- Co-authored-by: Claude Opus 4.6 (1M context) --- go.mod | 10 ++-- go.sum | 24 ++++----- internal/command/plan/run.go | 95 ++++++++++++++++++++++++++++++------ internal/config/planrun.go | 13 +++-- 4 files changed, 108 insertions(+), 34 deletions(-) diff --git a/go.mod b/go.mod index 9358d345..aee0ea56 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249 github.com/oklog/run v1.1.0 github.com/panta/machineid v1.0.2 - github.com/signadot/go-sdk v0.3.8-0.20260402222445-b8b5bc1f40c0 + github.com/signadot/go-sdk v0.3.8-0.20260409131400-73b58199c972 github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.11.0 @@ -146,13 +146,13 @@ require ( github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/subosito/gotenv v1.4.2 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 585bebe1..e44bab5e 100644 --- a/go.sum +++ b/go.sum @@ -422,8 +422,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/signadot/go-sdk v0.3.8-0.20260402222445-b8b5bc1f40c0 h1:CpkTVrKonGpInT2p5p1cWihob4Nh1rYM52CrTqQS+sI= -github.com/signadot/go-sdk v0.3.8-0.20260402222445-b8b5bc1f40c0/go.mod h1:2+pvoCGoDDO+iPUmUuU2BjYHByHp3IDQ9k3o0liibb4= +github.com/signadot/go-sdk v0.3.8-0.20260409131400-73b58199c972 h1:wnTmmIpMSPw8QDCuAKPRivdUka/I5J7efoApozXpmog= +github.com/signadot/go-sdk v0.3.8-0.20260409131400-73b58199c972/go.mod h1:hb509KxoXFyiH5CZzNMJdX3x0CO1pg8bKGu25Nw47/k= github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e h1:NiYn5S3cMIhsGh3RzBgRg9NzLDG5qEP7uhSJKtwW7oc= github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e/go.mod h1:cAsgAummH9Q9DrLQ7+S3mqrBv/+ZYKVSEXjR/WfoUJM= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -484,16 +484,16 @@ go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= -go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= -go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= -go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8= go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= -go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= @@ -666,8 +666,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -722,8 +722,8 @@ golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= +golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/command/plan/run.go b/internal/command/plan/run.go index cf15bbcb..bcd97734 100644 --- a/internal/command/plan/run.go +++ b/internal/command/plan/run.go @@ -20,6 +20,7 @@ import ( sdkclient "github.com/signadot/go-sdk/client" planlogs "github.com/signadot/go-sdk/client/plan_execution_logs" planexecs "github.com/signadot/go-sdk/client/plan_executions" + sdkplans "github.com/signadot/go-sdk/client/plans" plantags "github.com/signadot/go-sdk/client/plan_tags" "github.com/signadot/go-sdk/models" "github.com/spf13/cobra" @@ -59,19 +60,30 @@ func runPlan(cfg *config.PlanRun, out, log io.Writer, args []string) error { return err } - // Resolve plan ID. - planID, err := resolvePlanID(cfg, args) + // Resolve and fetch plan. + plan, err := resolvePlan(ctx, cfg, args) if err != nil { return err } + planID := plan.ID + planSpec := plan.Spec // Build params. params := buildParams(cfg.Params) + if params == nil && (cfg.Sandbox != "" || cfg.RouteGroup != "") { + params = make(map[string]any) + } + + // Apply --sandbox / --route-group to params. + if err := applyRoutingFlags(cfg, planSpec, params); err != nil { + return err + } // Create execution. spec := &models.PlanExecutionSpec{ - PlanID: planID, - Params: params, + PlanID: planID, + Cluster: cfg.Cluster, + Params: params, } createParams := planexecs.NewCreatePlanExecutionParams(). WithContext(ctx). @@ -149,27 +161,38 @@ func runPlan(cfg *config.PlanRun, out, log io.Writer, args []string) error { return nil } -func resolvePlanID(cfg *config.PlanRun, args []string) (string, error) { +func resolvePlan(ctx context.Context, cfg *config.PlanRun, args []string) (*models.RunnablePlan, error) { if cfg.Tag != "" && len(args) > 0 { - return "", fmt.Errorf("specify either a plan ID argument or --tag, not both") + return nil, fmt.Errorf("specify either a plan ID argument or --tag, not both") } if cfg.Tag == "" && len(args) == 0 { - return "", fmt.Errorf("specify a plan ID argument or --tag") + return nil, fmt.Errorf("specify a plan ID argument or --tag") } + if cfg.Tag != "" { - params := plantags.NewGetPlanTagParams(). + tagParams := plantags.NewGetPlanTagParams(). + WithContext(ctx). WithOrgName(cfg.Org). WithPlanTagName(cfg.Tag) - resp, err := cfg.Client.PlanTags.GetPlanTag(params, nil) + resp, err := cfg.Client.PlanTags.GetPlanTag(tagParams, nil) if err != nil { - return "", fmt.Errorf("resolving tag %q: %w", cfg.Tag, err) + return nil, fmt.Errorf("resolving tag %q: %w", cfg.Tag, err) } - if resp.Payload.Spec == nil || resp.Payload.Spec.PlanID == "" { - return "", fmt.Errorf("tag %q has no plan ID", cfg.Tag) + if resp.Payload.Plan == nil { + return nil, fmt.Errorf("tag %q has no plan", cfg.Tag) } - return resp.Payload.Spec.PlanID, nil + return resp.Payload.Plan, nil } - return args[0], nil + + getParams := sdkplans.NewGetPlanParams(). + WithContext(ctx). + WithOrgName(cfg.Org). + WithPlanID(args[0]) + resp, err := cfg.Client.Plans.GetPlan(getParams, nil) + if err != nil { + return nil, fmt.Errorf("fetching plan: %w", err) + } + return resp.Payload, nil } func buildParams(tplVals config.TemplateVals) map[string]any { @@ -212,6 +235,50 @@ func looksLikeJSON(s string) bool { return false } +func applyRoutingFlags(cfg *config.PlanRun, planSpec *models.PlanSpec, params map[string]any) error { + if cfg.Sandbox == "" && cfg.RouteGroup == "" { + return nil + } + + aff := planSpec.Cluster + if aff == nil { + if cfg.Sandbox != "" { + return fmt.Errorf("plan does not accept a sandbox parameter") + } + return fmt.Errorf("plan does not accept a route group parameter") + } + + if cfg.Sandbox != "" { + if aff.FromAnyTarget != "" { + raw, err := json.Marshal(models.RoutingTarget{Sandbox: cfg.Sandbox}) + if err != nil { + return fmt.Errorf("marshalling routing target: %w", err) + } + params[aff.FromAnyTarget] = json.RawMessage(raw) + } else if aff.FromSandbox != "" { + params[aff.FromSandbox] = cfg.Sandbox + } else { + return fmt.Errorf("plan does not accept a sandbox parameter") + } + } + + if cfg.RouteGroup != "" { + if aff.FromAnyTarget != "" { + raw, err := json.Marshal(models.RoutingTarget{RouteGroup: cfg.RouteGroup}) + if err != nil { + return fmt.Errorf("marshalling routing target: %w", err) + } + params[aff.FromAnyTarget] = json.RawMessage(raw) + } else if aff.FromRouteGroup != "" { + params[aff.FromRouteGroup] = cfg.RouteGroup + } else { + return fmt.Errorf("plan does not accept a route group parameter") + } + } + + return nil +} + func isTerminal(phase models.PlansExecutionPhase) bool { switch phase { case models.PlansExecutionPhaseCompleted, diff --git a/internal/config/planrun.go b/internal/config/planrun.go index 50f1bae8..fd86a60e 100644 --- a/internal/config/planrun.go +++ b/internal/config/planrun.go @@ -10,9 +10,12 @@ type PlanRun struct { *Plan // Flags - Tag string - Params TemplateVals - Wait bool + Tag string + Cluster string + Sandbox string + RouteGroup string + Params TemplateVals + Wait bool Attach bool Timeout time.Duration OutputDir string @@ -20,6 +23,10 @@ type PlanRun struct { func (c *PlanRun) AddFlags(cmd *cobra.Command) { cmd.Flags().StringVar(&c.Tag, "tag", "", "run the plan referenced by this tag (alternative to plan ID argument)") + cmd.Flags().StringVar(&c.Cluster, "cluster", "", "target cluster for the execution") + cmd.Flags().StringVar(&c.Sandbox, "sandbox", "", "run in the context of a sandbox") + cmd.Flags().StringVar(&c.RouteGroup, "route-group", "", "run in the context of a route group") + cmd.MarkFlagsMutuallyExclusive("sandbox", "route-group") cmd.Flags().Var(&c.Params, "param", "parameter in key=value form (can be repeated)") cmd.Flags().BoolVar(&c.Wait, "wait", true, "wait for execution to complete") cmd.Flags().BoolVar(&c.Attach, "attach", false, "stream structured events (logs, outputs, result) to stdout") From 6b7df37b375021c331fd4dff7dab2777dd32892a Mon Sep 17 00:00:00 2001 From: David Orozco <34346877+davixcky@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:01:10 -0500 Subject: [PATCH 4/7] Suppress human-readable status messages when using -o json/yaml (#314) When structured output formats (JSON/YAML) are requested, skip printing "Tagged plan..." and "Created execution..." messages that would pollute stdout and break machine parsing. Also silence the spinner during polling in non-default output modes. Co-authored-by: Claude Opus 4.6 (1M context) --- internal/command/plan/compile.go | 4 +++- internal/command/plan/create.go | 4 +++- internal/command/plan/recompile.go | 4 +++- internal/command/plan/run.go | 10 ++++++++-- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/internal/command/plan/compile.go b/internal/command/plan/compile.go index d0acf475..a67e298c 100644 --- a/internal/command/plan/compile.go +++ b/internal/command/plan/compile.go @@ -66,7 +66,9 @@ func compile(cfg *config.PlanCompile, out, log io.Writer) error { if _, err := plantag.ApplyTag(cfg.Plan, resp.Payload.ID, cfg.Tag); err != nil { return fmt.Errorf("plan compiled (id=%s) but tagging failed: %w", resp.Payload.ID, err) } - fmt.Fprintf(log, "Tagged plan %s as %q\n", resp.Payload.ID, cfg.Tag) + if cfg.OutputFormat == config.OutputFormatDefault { + fmt.Fprintf(log, "Tagged plan %s as %q\n", resp.Payload.ID, cfg.Tag) + } } switch cfg.OutputFormat { diff --git a/internal/command/plan/create.go b/internal/command/plan/create.go index 56fa17d6..37d6df0e 100644 --- a/internal/command/plan/create.go +++ b/internal/command/plan/create.go @@ -54,7 +54,9 @@ func create(cfg *config.PlanCreate, out, log io.Writer) error { if _, err := plantag.ApplyTag(cfg.Plan, resp.Payload.ID, cfg.Tag); err != nil { return fmt.Errorf("plan created (id=%s) but tagging failed: %w", resp.Payload.ID, err) } - fmt.Fprintf(log, "Tagged plan %s as %q\n", resp.Payload.ID, cfg.Tag) + if cfg.OutputFormat == config.OutputFormatDefault { + fmt.Fprintf(log, "Tagged plan %s as %q\n", resp.Payload.ID, cfg.Tag) + } } switch cfg.OutputFormat { diff --git a/internal/command/plan/recompile.go b/internal/command/plan/recompile.go index 3131da6b..3799d794 100644 --- a/internal/command/plan/recompile.go +++ b/internal/command/plan/recompile.go @@ -44,7 +44,9 @@ func recompile(cfg *config.PlanRecompile, out, log io.Writer, planID string) err if _, err := plantag.ApplyTag(cfg.Plan, resp.Payload.ID, cfg.Tag); err != nil { return fmt.Errorf("plan recompiled (id=%s) but tagging failed: %w", resp.Payload.ID, err) } - fmt.Fprintf(log, "Tagged plan %s as %q\n", resp.Payload.ID, cfg.Tag) + if cfg.OutputFormat == config.OutputFormatDefault { + fmt.Fprintf(log, "Tagged plan %s as %q\n", resp.Payload.ID, cfg.Tag) + } } switch cfg.OutputFormat { diff --git a/internal/command/plan/run.go b/internal/command/plan/run.go index bcd97734..829e74e3 100644 --- a/internal/command/plan/run.go +++ b/internal/command/plan/run.go @@ -94,7 +94,9 @@ func runPlan(cfg *config.PlanRun, out, log io.Writer, args []string) error { return fmt.Errorf("creating execution: %w", err) } execID := createResp.Payload.ID - fmt.Fprintf(log, "Created execution %s for plan %s\n", execID, planID) + if cfg.OutputFormat == config.OutputFormatDefault { + fmt.Fprintf(log, "Created execution %s for plan %s\n", execID, planID) + } // Fire-and-forget mode. if !cfg.Wait { @@ -296,7 +298,11 @@ func pollExecution(ctx context.Context, cfg *config.PlanRun, log io.Writer, exec defer cancel() } - spin := spinner.Start(log, "Execution") + spinWriter := log + if cfg.OutputFormat != config.OutputFormatDefault { + spinWriter = io.Discard + } + spin := spinner.Start(spinWriter, "Execution") defer spin.Stop() ticker := time.NewTicker(2 * time.Second) From 876444598ede0ed77258b95011d333704ae65dec Mon Sep 17 00:00:00 2001 From: Scott Cotton Date: Fri, 10 Apr 2026 10:26:53 +0200 Subject: [PATCH 5/7] Update go-sdk to include drill down and extraOutputs Show steps and prompt columns in plan tag list The ListPlanTags API returns inlined plan details but the table printer was not using them. Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- internal/command/plantag/printers.go | 10 +++++++++- 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index aee0ea56..cc36838b 100644 --- a/go.mod +++ b/go.mod @@ -21,14 +21,14 @@ require ( github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249 github.com/oklog/run v1.1.0 github.com/panta/machineid v1.0.2 - github.com/signadot/go-sdk v0.3.8-0.20260409131400-73b58199c972 + github.com/signadot/go-sdk v0.3.8-0.20260410083957-d025f4d8f72c github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.11.0 github.com/theckman/yacspin v0.13.12 github.com/xeonx/timeago v1.0.0-rc5 github.com/zalando/go-keyring v0.2.6 - golang.org/x/term v0.41.0 + golang.org/x/term v0.42.0 google.golang.org/grpc v1.78.0 google.golang.org/protobuf v1.36.11 k8s.io/client-go v0.33.0 @@ -97,7 +97,7 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect @@ -149,9 +149,9 @@ require ( go.opentelemetry.io/otel v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - golang.org/x/crypto v0.49.0 // indirect + golang.org/x/crypto v0.50.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sys v0.42.0 // indirect + golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.9.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect diff --git a/go.sum b/go.sum index e44bab5e..102aa74f 100644 --- a/go.sum +++ b/go.sum @@ -422,8 +422,8 @@ github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/signadot/go-sdk v0.3.8-0.20260409131400-73b58199c972 h1:wnTmmIpMSPw8QDCuAKPRivdUka/I5J7efoApozXpmog= -github.com/signadot/go-sdk v0.3.8-0.20260409131400-73b58199c972/go.mod h1:hb509KxoXFyiH5CZzNMJdX3x0CO1pg8bKGu25Nw47/k= +github.com/signadot/go-sdk v0.3.8-0.20260410083957-d025f4d8f72c h1:aI/nG6B1T6WqdbIplqyo3BDfMa/dawJnwPcpq0ygDvY= +github.com/signadot/go-sdk v0.3.8-0.20260410083957-d025f4d8f72c/go.mod h1:dOoiOHHKM3oOEVD/WxAIq3Cv37032VfXvQO1IU7jJFk= github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e h1:NiYn5S3cMIhsGh3RzBgRg9NzLDG5qEP7uhSJKtwW7oc= github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e/go.mod h1:cAsgAummH9Q9DrLQ7+S3mqrBv/+ZYKVSEXjR/WfoUJM= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -508,8 +508,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -579,8 +579,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= golang.org/x/net v0.0.0-20210331212208-0fccb6fa2b5c/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -652,11 +652,11 @@ golang.org/x/sys v0.0.0-20220406163625-3f8b81556e12/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/command/plantag/printers.go b/internal/command/plantag/printers.go index 015dd242..342f6c08 100644 --- a/internal/command/plantag/printers.go +++ b/internal/command/plantag/printers.go @@ -16,6 +16,8 @@ import ( type tagRow struct { Name string `sdtab:"NAME"` PlanID string `sdtab:"PLAN ID"` + Steps string `sdtab:"STEPS"` + Prompt string `sdtab:"PROMPT"` Created string `sdtab:"CREATED"` Updated string `sdtab:"UPDATED"` } @@ -24,10 +26,14 @@ func printTagTable(out io.Writer, tags []*models.PlanTag) error { t := sdtab.New[tagRow](out) t.AddHeader() for _, tag := range tags { - var planID, created, updated string + var planID, steps, prompt, created, updated string if tag.Spec != nil { planID = tag.Spec.PlanID } + if tag.Plan != nil && tag.Plan.Spec != nil { + steps = fmt.Sprintf("%d", len(tag.Plan.Spec.Steps)) + prompt = print.FirstLine(tag.Plan.Spec.Prompt) + } if tag.Status != nil { if tag.Status.CreatedAt != "" { if ts, err := time.Parse(time.RFC3339, tag.Status.CreatedAt); err == nil { @@ -43,6 +49,8 @@ func printTagTable(out io.Writer, tags []*models.PlanTag) error { t.AddRow(tagRow{ Name: tag.Name, PlanID: planID, + Steps: steps, + Prompt: prompt, Created: created, Updated: updated, }) From 48291f59e8dfec9e2eebd853abf7c60579953d4a Mon Sep 17 00:00:00 2001 From: Scott Cotton Date: Mon, 13 Apr 2026 10:04:01 +0200 Subject: [PATCH 6/7] Add hidden planrunnergroup CLI command (#317) Add apply/get/list/delete subcommands for plan runner groups, modeled after the existing jobrunnergroup command. Hidden from top-level help; accessible via `signadot planrunnergroup` or alias `signadot prg`. Needed for e2e test infrastructure. Co-authored-by: Claude Opus 4.6 (1M context) --- internal/command/command.go | 2 + internal/command/planrunnergroup/apply.go | 70 ++++++++++++++++++++ internal/command/planrunnergroup/command.go | 27 ++++++++ internal/command/planrunnergroup/delete.go | 69 +++++++++++++++++++ internal/command/planrunnergroup/get.go | 50 ++++++++++++++ internal/command/planrunnergroup/list.go | 48 ++++++++++++++ internal/command/planrunnergroup/printers.go | 61 +++++++++++++++++ internal/command/planrunnergroup/subst.go | 37 +++++++++++ internal/config/planrunnergroup.go | 44 ++++++++++++ 9 files changed, 408 insertions(+) create mode 100644 internal/command/planrunnergroup/apply.go create mode 100644 internal/command/planrunnergroup/command.go create mode 100644 internal/command/planrunnergroup/delete.go create mode 100644 internal/command/planrunnergroup/get.go create mode 100644 internal/command/planrunnergroup/list.go create mode 100644 internal/command/planrunnergroup/printers.go create mode 100644 internal/command/planrunnergroup/subst.go create mode 100644 internal/config/planrunnergroup.go diff --git a/internal/command/command.go b/internal/command/command.go index 46557035..25d898e8 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -17,6 +17,7 @@ import ( "github.com/signadot/cli/internal/command/logs" "github.com/signadot/cli/internal/command/mcp" "github.com/signadot/cli/internal/command/plan" + "github.com/signadot/cli/internal/command/planrunnergroup" "github.com/signadot/cli/internal/command/resourceplugin" "github.com/signadot/cli/internal/command/routegroup" "github.com/signadot/cli/internal/command/sandbox" @@ -63,6 +64,7 @@ func New() *cobra.Command { // hidden commands hostedtest.New(cfg), + planrunnergroup.New(cfg), ) return cmd diff --git a/internal/command/planrunnergroup/apply.go b/internal/command/planrunnergroup/apply.go new file mode 100644 index 00000000..12523cb9 --- /dev/null +++ b/internal/command/planrunnergroup/apply.go @@ -0,0 +1,70 @@ +package planrunnergroup + +import ( + "errors" + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + planrunnergroups "github.com/signadot/go-sdk/client/plan_runner_groups" + "github.com/signadot/go-sdk/models" + "github.com/spf13/cobra" +) + +func newApply(prg *config.PlanRunnerGroup) *cobra.Command { + cfg := &config.PlanRunnerGroupApply{PlanRunnerGroup: prg} + + cmd := &cobra.Command{ + Use: "apply -f FILENAME [ --set var1=val1 --set var2=val2 ... ]", + Short: "Create or update a plan runner group", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return apply(cfg, cmd.OutOrStdout(), cmd.ErrOrStderr()) + }, + } + + cfg.AddFlags(cmd) + + return cmd +} + +func apply(cfg *config.PlanRunnerGroupApply, out, log io.Writer) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + if cfg.Filename == "" { + return errors.New("must specify plan runner group request file with '-f' flag") + } + req, err := loadPlanRunnerGroup(cfg.Filename, cfg.TemplateVals, false) + if err != nil { + return err + } + + params := planrunnergroups.NewApplyPlanrunnergroupParams(). + WithOrgName(cfg.Org). + WithPlanRunnerGroupName(req.Name). + WithData(req) + + result, err := cfg.Client.PlanRunnerGroups.ApplyPlanrunnergroup(params, nil) + if err != nil { + return err + } + + fmt.Fprintf(log, "Applied plan runner group %q\n\n", req.Name) + + return writeApplyOutput(cfg, out, result.Payload) +} + +func writeApplyOutput(cfg *config.PlanRunnerGroupApply, out io.Writer, resp *models.PlanRunnerGroup) error { + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return nil + case config.OutputFormatJSON: + return print.RawJSON(out, resp) + case config.OutputFormatYAML: + return print.RawYAML(out, resp) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/planrunnergroup/command.go b/internal/command/planrunnergroup/command.go new file mode 100644 index 00000000..6fe8e418 --- /dev/null +++ b/internal/command/planrunnergroup/command.go @@ -0,0 +1,27 @@ +package planrunnergroup + +import ( + "github.com/signadot/cli/internal/config" + "github.com/spf13/cobra" +) + +func New(api *config.API) *cobra.Command { + cfg := &config.PlanRunnerGroup{API: api} + + cmd := &cobra.Command{ + Use: "planrunnergroup", + Short: "Manage plan runner groups", + Aliases: []string{"prg"}, + Hidden: true, + } + + // Subcommands + cmd.AddCommand( + newGet(cfg), + newList(cfg), + newApply(cfg), + newDelete(cfg), + ) + + return cmd +} diff --git a/internal/command/planrunnergroup/delete.go b/internal/command/planrunnergroup/delete.go new file mode 100644 index 00000000..05f81bc5 --- /dev/null +++ b/internal/command/planrunnergroup/delete.go @@ -0,0 +1,69 @@ +package planrunnergroup + +import ( + "errors" + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + planrunnergroups "github.com/signadot/go-sdk/client/plan_runner_groups" + "github.com/spf13/cobra" +) + +func newDelete(prg *config.PlanRunnerGroup) *cobra.Command { + cfg := &config.PlanRunnerGroupDelete{PlanRunnerGroup: prg} + + cmd := &cobra.Command{ + Use: "delete { NAME | -f FILENAME [ --set var1=val1 --set var2=val2 ... ] }", + Short: "Delete plan runner group", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return prgDelete(cfg, cmd.ErrOrStderr(), args) + }, + } + + cfg.AddFlags(cmd) + + return cmd +} + +func prgDelete(cfg *config.PlanRunnerGroupDelete, log io.Writer, args []string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + var name string + if cfg.Filename == "" { + if len(args) == 0 { + return errors.New("must specify filename (-f) or plan runner group name") + } + if len(cfg.TemplateVals) != 0 { + return errors.New("must specify filename (-f) to use --set") + } + name = args[0] + } else { + if len(args) != 0 { + return errors.New("must not provide args when filename (-f) specified") + } + prg, err := loadPlanRunnerGroup(cfg.Filename, cfg.TemplateVals, true) + if err != nil { + return err + } + name = prg.Name + } + + if name == "" { + return errors.New("plan runner group name is required") + } + + params := planrunnergroups.NewDeletePlanrunnergroupParams(). + WithOrgName(cfg.Org). + WithPlanRunnerGroupName(name) + _, err := cfg.Client.PlanRunnerGroups.DeletePlanrunnergroup(params, nil) + if err != nil { + return err + } + + fmt.Fprintf(log, "Deleted plan runner group %q.\n\n", name) + + return nil +} diff --git a/internal/command/planrunnergroup/get.go b/internal/command/planrunnergroup/get.go new file mode 100644 index 00000000..494881f5 --- /dev/null +++ b/internal/command/planrunnergroup/get.go @@ -0,0 +1,50 @@ +package planrunnergroup + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + planrunnergroups "github.com/signadot/go-sdk/client/plan_runner_groups" + "github.com/spf13/cobra" +) + +func newGet(prg *config.PlanRunnerGroup) *cobra.Command { + cfg := &config.PlanRunnerGroupGet{PlanRunnerGroup: prg} + + cmd := &cobra.Command{ + Use: "get NAME", + Short: "Get plan runner group", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return get(cfg, cmd.OutOrStdout(), args[0]) + }, + } + + return cmd +} + +func get(cfg *config.PlanRunnerGroupGet, out io.Writer, name string) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + params := planrunnergroups.NewGetPlanrunnergroupParams(). + WithOrgName(cfg.Org). + WithPlanRunnerGroupName(name) + resp, err := cfg.Client.PlanRunnerGroups.GetPlanrunnergroup(params, nil) + if err != nil { + return err + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printPlanRunnerGroupDetails(out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/planrunnergroup/list.go b/internal/command/planrunnergroup/list.go new file mode 100644 index 00000000..b105cf4b --- /dev/null +++ b/internal/command/planrunnergroup/list.go @@ -0,0 +1,48 @@ +package planrunnergroup + +import ( + "fmt" + "io" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/print" + planrunnergroups "github.com/signadot/go-sdk/client/plan_runner_groups" + "github.com/spf13/cobra" +) + +func newList(prg *config.PlanRunnerGroup) *cobra.Command { + cfg := &config.PlanRunnerGroupList{PlanRunnerGroup: prg} + + cmd := &cobra.Command{ + Use: "list", + Short: "List plan runner groups", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return list(cfg, cmd.OutOrStdout()) + }, + } + + return cmd +} + +func list(cfg *config.PlanRunnerGroupList, out io.Writer) error { + if err := cfg.InitAPIConfig(); err != nil { + return err + } + resp, err := cfg.Client.PlanRunnerGroups.ListPlanrunnergroup( + planrunnergroups.NewListPlanrunnergroupParams().WithOrgName(cfg.Org), nil) + if err != nil { + return err + } + + switch cfg.OutputFormat { + case config.OutputFormatDefault: + return printPlanRunnerGroupTable(out, resp.Payload) + case config.OutputFormatJSON: + return print.RawJSON(out, resp.Payload) + case config.OutputFormatYAML: + return print.RawYAML(out, resp.Payload) + default: + return fmt.Errorf("unsupported output format: %q", cfg.OutputFormat) + } +} diff --git a/internal/command/planrunnergroup/printers.go b/internal/command/planrunnergroup/printers.go new file mode 100644 index 00000000..04ea1628 --- /dev/null +++ b/internal/command/planrunnergroup/printers.go @@ -0,0 +1,61 @@ +package planrunnergroup + +import ( + "fmt" + "io" + "text/tabwriter" + "time" + + "github.com/signadot/cli/internal/sdtab" + "github.com/signadot/cli/internal/utils" + "github.com/signadot/go-sdk/models" + "github.com/xeonx/timeago" +) + +type planRunnerGroupRow struct { + Name string `sdtab:"NAME"` + Cluster string `sdtab:"CLUSTER"` + Created string `sdtab:"CREATED"` + Status string `sdtab:"STATUS"` +} + +func printPlanRunnerGroupTable(out io.Writer, prgs []*models.PlanRunnerGroup) error { + t := sdtab.New[planRunnerGroupRow](out) + t.AddHeader() + for _, prg := range prgs { + createdAt, err := time.Parse(time.RFC3339, prg.CreatedAt) + if err != nil { + return err + } + + t.AddRow(planRunnerGroupRow{ + Name: prg.Name, + Cluster: prg.Spec.Cluster, + Created: timeago.NoMax(timeago.English).Format(createdAt), + Status: readiness(prg), + }) + } + return t.Flush() +} + +func printPlanRunnerGroupDetails(out io.Writer, prg *models.PlanRunnerGroup) error { + tw := tabwriter.NewWriter(out, 0, 0, 3, ' ', 0) + + fmt.Fprintf(tw, "Name:\t%s\n", prg.Name) + fmt.Fprintf(tw, "Cluster:\t%s\n", prg.Spec.Cluster) + fmt.Fprintf(tw, "Created:\t%s\n", utils.FormatTimestamp(prg.CreatedAt)) + fmt.Fprintf(tw, "Status:\t%s\n", readiness(prg)) + + return tw.Flush() +} + +func readiness(prg *models.PlanRunnerGroup) string { + if prg.DeletedAt != "" { + return "draining" + } + if prg.Status == nil || prg.Status.Pods == nil { + return "-" + } + return fmt.Sprintf("%d/%d pods ready", + prg.Status.Pods.Ready, prg.Status.Pods.Ready+prg.Status.Pods.NotReady) +} diff --git a/internal/command/planrunnergroup/subst.go b/internal/command/planrunnergroup/subst.go new file mode 100644 index 00000000..d6893782 --- /dev/null +++ b/internal/command/planrunnergroup/subst.go @@ -0,0 +1,37 @@ +package planrunnergroup + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/signadot/cli/internal/config" + "github.com/signadot/cli/internal/jsonexact" + "github.com/signadot/cli/internal/utils" + "github.com/signadot/go-sdk/models" +) + +func loadPlanRunnerGroup(file string, tplVals config.TemplateVals, forDelete bool) (*models.PlanRunnerGroup, error) { + template, err := utils.LoadUnstructuredTemplate(file, tplVals, forDelete) + if err != nil { + return nil, err + } + return unstructuredToPlanRunnerGroup(template) +} + +func unstructuredToPlanRunnerGroup(un any) (*models.PlanRunnerGroup, error) { + name, spec, err := utils.UnstructuredToNameAndSpec(un) + if err != nil { + return nil, err + } + d, err := json.Marshal(spec) + if err != nil { + return nil, err + } + prg := &models.PlanRunnerGroup{Name: name} + if err := jsonexact.Unmarshal(d, &prg.Spec); err != nil { + return nil, fmt.Errorf("couldn't parse YAML plan runner group definition - %s", + strings.TrimPrefix(err.Error(), "json: ")) + } + return prg, nil +} diff --git a/internal/config/planrunnergroup.go b/internal/config/planrunnergroup.go new file mode 100644 index 00000000..f2e20f9c --- /dev/null +++ b/internal/config/planrunnergroup.go @@ -0,0 +1,44 @@ +package config + +import ( + "github.com/spf13/cobra" +) + +type PlanRunnerGroup struct { + *API +} + +type PlanRunnerGroupApply struct { + *PlanRunnerGroup + + // Flags + Filename string + TemplateVals TemplateVals +} + +func (c *PlanRunnerGroupApply) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&c.Filename, "filename", "f", "", "YAML or JSON file containing the plan runner group definition") + cmd.MarkFlagRequired("filename") + cmd.Flags().Var(&c.TemplateVals, "set", "--set var=val") +} + +type PlanRunnerGroupDelete struct { + *PlanRunnerGroup + + // Flags + Filename string + TemplateVals TemplateVals +} + +func (c *PlanRunnerGroupDelete) AddFlags(cmd *cobra.Command) { + cmd.Flags().StringVarP(&c.Filename, "filename", "f", "", "optional YAML or JSON file containing the plan runner group definition") + cmd.Flags().Var(&c.TemplateVals, "set", "--set var=val") +} + +type PlanRunnerGroupGet struct { + *PlanRunnerGroup +} + +type PlanRunnerGroupList struct { + *PlanRunnerGroup +} From c73b93aaf75c81f32fca51010f8491e701e9ddc7 Mon Sep 17 00:00:00 2001 From: Scott Cotton Date: Wed, 15 Apr 2026 18:50:16 +0200 Subject: [PATCH 7/7] Emit get-output sidecars by default with server-supplied Content-Type (#318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously --metadata was opt-in and only wrote a sidecar when the output carried explicit metadata, so silent shell outputs (stdout, stderr, exitCode) exported with no sidecar — the user had no way to tell what the file was without opening it. Make --metadata default true and always emit a sidecar per exported output. The sidecar carries the derived allOutput fields (name, scope, step, type, size, ready) plus the Content-Type the apiserver returned (now exposed on GetStepOutputOK / GetPlanExecutionOutputOK after the go-sdk regen) and any explicit Metadata. Drop omitempty on Size so 0-byte outputs show size:0 rather than eliding the field, which would be ambiguous with "size not reported". Bump go-sdk to 170febfc to pick up the Content-Type header binding. Co-authored-by: Claude Opus 4.6 (1M context) --- go.mod | 2 +- go.sum | 2 + internal/command/planexec/get_output.go | 76 ++++++++++++++++++------- internal/command/planexec/outputs.go | 21 ++++++- 4 files changed, 78 insertions(+), 23 deletions(-) diff --git a/go.mod b/go.mod index cc36838b..0d8793d2 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/nsf/jsondiff v0.0.0-20210926074059-1e845ec5d249 github.com/oklog/run v1.1.0 github.com/panta/machineid v1.0.2 - github.com/signadot/go-sdk v0.3.8-0.20260410083957-d025f4d8f72c + github.com/signadot/go-sdk v0.3.8-0.20260414122927-170febfcff95 github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.11.0 diff --git a/go.sum b/go.sum index 102aa74f..22c89b2a 100644 --- a/go.sum +++ b/go.sum @@ -424,6 +424,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/signadot/go-sdk v0.3.8-0.20260410083957-d025f4d8f72c h1:aI/nG6B1T6WqdbIplqyo3BDfMa/dawJnwPcpq0ygDvY= github.com/signadot/go-sdk v0.3.8-0.20260410083957-d025f4d8f72c/go.mod h1:dOoiOHHKM3oOEVD/WxAIq3Cv37032VfXvQO1IU7jJFk= +github.com/signadot/go-sdk v0.3.8-0.20260414122927-170febfcff95 h1:Yc0tkczAm+vAb1qSk97yasya8gbpH7tNQxS9DajpBqw= +github.com/signadot/go-sdk v0.3.8-0.20260414122927-170febfcff95/go.mod h1:dOoiOHHKM3oOEVD/WxAIq3Cv37032VfXvQO1IU7jJFk= github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e h1:NiYn5S3cMIhsGh3RzBgRg9NzLDG5qEP7uhSJKtwW7oc= github.com/signadot/libconnect v0.1.1-0.20260224205539-f894c2d0a57e/go.mod h1:cAsgAummH9Q9DrLQ7+S3mqrBv/+ZYKVSEXjR/WfoUJM= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= diff --git a/internal/command/planexec/get_output.go b/internal/command/planexec/get_output.go index f910bf24..cf3beb2b 100644 --- a/internal/command/planexec/get_output.go +++ b/internal/command/planexec/get_output.go @@ -31,7 +31,7 @@ Single output: Bulk export: signadot plan x get-output --all --dir ./outputs/ - signadot plan x get-output --all --dir ./outputs/ --metadata`, + signadot plan x get-output --all --dir ./outputs/ --metadata=false # skip sidecars`, Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { if cfg.All { @@ -52,7 +52,8 @@ Bulk export: cmd.Flags().BoolVar(&cfg.All, "all", false, "export all outputs") cmd.Flags().StringVar(&cfg.Dir, "dir", "", "directory to export outputs to (requires --all)") - cmd.Flags().BoolVar(&cfg.Metadata, "metadata", false, "write metadata sidecar JSON files (requires --all)") + cmd.Flags().BoolVar(&cfg.Metadata, "metadata", true, + "write .meta.json sidecar files alongside each exported output (requires --all; pass --metadata=false to disable)") return cmd } @@ -119,7 +120,10 @@ func getAllOutputs(cfg *config.PlanExecGetOutput, log io.Writer, execID string) return nil } - // Build a metadata lookup from the raw response for sidecar export. + // Build a metadata lookup keyed by qualified name ( for plan + // outputs, / for step outputs). Outputs without any + // explicit metadata map to nil entries — sidecars are still written + // for them using the derived fields alone. metadataMap := buildMetadataMap(resp.Payload) if err := os.MkdirAll(cfg.Dir, 0o755); err != nil { @@ -153,7 +157,12 @@ func getAllOutputs(cfg *config.PlanExecGetOutput, log io.Writer, execID string) } // Download using the appropriate API based on scope. + // Capture Content-Type from the response so the sidecar + // can record the server-supplied value rather than a + // re-derived one (keeps the server's + // plans.ResolveContentType as the single source of truth). qualName := o.Name + var contentType string if o.Scope == "step" { qualName = o.Step + "/" + o.Name params := planexecs.NewGetStepOutputParams(). @@ -162,14 +171,28 @@ func getAllOutputs(cfg *config.PlanExecGetOutput, log io.Writer, execID string) WithExecutionID(execID). WithStepID(o.Step). WithOutputName(o.Name) - _, _, err = c.PlanExecutions.GetStepOutput(params, nil, f) + ok, partial, getErr := c.PlanExecutions.GetStepOutput(params, nil, f) + err = getErr + switch { + case ok != nil: + contentType = ok.ContentType + case partial != nil: + contentType = partial.ContentType + } } else { params := planexecs.NewGetPlanExecutionOutputParams(). WithTimeout(4*time.Minute). WithOrgName(cfg.Org). WithExecutionID(execID). WithOutputName(o.Name) - _, _, err = c.PlanExecutions.GetPlanExecutionOutput(params, nil, f) + ok, partial, getErr := c.PlanExecutions.GetPlanExecutionOutput(params, nil, f) + err = getErr + switch { + case ok != nil: + contentType = ok.ContentType + case partial != nil: + contentType = partial.ContentType + } } f.Close() if err != nil { @@ -177,19 +200,27 @@ func getAllOutputs(cfg *config.PlanExecGetOutput, log io.Writer, execID string) } fmt.Fprintf(log, "Exported %s\n", outPath) - // Write metadata sidecar if requested. + // Write sidecar. --metadata is on by default; pass + // --metadata=false to disable. The sidecar is always + // emitted (regardless of whether the output carries + // explicit metadata) so the derived fields — scope, + // step, inline vs artifact, size, ready, contentType — + // are visible to the reader without another round-trip. if cfg.Metadata { - if meta := metadataMap[qualName]; meta != nil { - metaPath := outPath + ".meta.json" - metaJSON, err := json.MarshalIndent(meta, "", " ") - if err != nil { - return fmt.Errorf("marshaling metadata for %q: %w", qualName, err) - } - if err := os.WriteFile(metaPath, metaJSON, 0o644); err != nil { - return fmt.Errorf("writing %s: %w", metaPath, err) - } - fmt.Fprintf(log, "Exported %s\n", metaPath) + sidecar := outputSidecar{ + allOutput: o, + ContentType: contentType, + Metadata: metadataMap[qualName], + } + metaPath := outPath + ".meta.json" + metaJSON, err := json.MarshalIndent(sidecar, "", " ") + if err != nil { + return fmt.Errorf("marshaling sidecar for %q: %w", qualName, err) + } + if err := os.WriteFile(metaPath, metaJSON, 0o644); err != nil { + return fmt.Errorf("writing %s: %w", metaPath, err) } + fmt.Fprintf(log, "Exported %s\n", metaPath) } } return nil @@ -198,19 +229,24 @@ func getAllOutputs(cfg *config.PlanExecGetOutput, log io.Writer, execID string) // buildMetadataMap extracts metadata from plan-level and step-level outputs, // keyed by "name" (plan-level) or "step/name" (step-level). -func buildMetadataMap(ex *models.PlanExecution) map[string]any { - m := map[string]any{} +// buildMetadataMap returns each output's explicit metadata keyed by +// qualified name: "" for plan-level outputs, "/" for +// step-level outputs. Outputs that carry no explicit metadata are +// absent from the map (so a lookup returns a nil map, which is the +// correct omitempty signal for the sidecar). +func buildMetadataMap(ex *models.PlanExecution) map[string]map[string]string { + m := map[string]map[string]string{} if ex.Status == nil { return m } for _, o := range ex.Status.Outputs { - if o.Metadata != nil { + if len(o.Metadata) > 0 { m[o.Name] = o.Metadata } } for _, s := range ex.Status.Steps { for _, o := range s.Outputs { - if o.Metadata != nil { + if len(o.Metadata) > 0 { m[s.ID+"/"+o.Name] = o.Metadata } } diff --git a/internal/command/planexec/outputs.go b/internal/command/planexec/outputs.go index 68737b47..1780b67e 100644 --- a/internal/command/planexec/outputs.go +++ b/internal/command/planexec/outputs.go @@ -28,15 +28,32 @@ func newOutputs(exec *config.PlanExecution) *cobra.Command { } // allOutput unifies plan-level and step-level outputs for display. +// Size has no omitempty so a 0-byte output (e.g. a silent stdout) +// shows "size": 0 in sidecars rather than being elided — a missing +// field would be ambiguous with "size not reported". type allOutput struct { Name string `json:"name"` - Step string `json:"step"` + Step string `json:"step,omitempty"` Scope string `json:"scope"` // "plan" or "step" Type string `json:"type"` // "inline" or "artifact" - Size int64 `json:"size,omitempty"` + Size int64 `json:"size"` Ready *bool `json:"ready,omitempty"` } +// outputSidecar is the .meta.json content written alongside each +// exported output under --all --metadata. It combines derived fields +// (name, scope, step, type, size, ready) with the server-supplied +// Content-Type and any explicit metadata the step or plan declared, +// so the reader can tell what an exported file is without opening it. +type outputSidecar struct { + allOutput + // ContentType is the Content-Type header the apiserver returned + // when downloading this output. Omitted when absent (e.g. drill + // paths that don't set it, or older servers). + ContentType string `json:"contentType,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` +} + func listOutputs(cfg *config.PlanExecOutputs, out io.Writer, execID string) error { if err := cfg.InitAPIConfig(); err != nil { return err