diff --git a/.ai/skills/new-command.md b/.ai/skills/new-command.md index 9026184..42c4885 100644 --- a/.ai/skills/new-command.md +++ b/.ai/skills/new-command.md @@ -173,7 +173,7 @@ Shape of a flow: ```go engine := wizard.NewEngine(f.Prompter(), f.Status(), - wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) + wizard.WithOutput(ioStreams.ErrOut)) if err := engine.Run(ctx, flow); err != nil { return err // Ctrl+C returns an error here — propagate it, like configure/vm/mv } diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml index ff949d8..c2a4d3b 100644 --- a/.github/workflows/changelog.yml +++ b/.github/workflows/changelog.yml @@ -26,7 +26,7 @@ jobs: fetch-depth: 0 - name: Install git-cliff - run: pip install git-cliff + run: pip install "git-cliff==2.13.1" - name: Regenerate unreleased changelog run: make changelog.unreleased diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8217da..6df4508 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: run: echo "$(go env GOPATH)/bin" >> $GITHUB_PATH - name: Install goimports - run: go install golang.org/x/tools/cmd/goimports@latest + run: go install golang.org/x/tools/cmd/goimports@v0.48.0 - name: Check formatting run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b26e128..e2b1896 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -38,7 +38,7 @@ jobs: cache: true - name: Install git-cliff - run: pip install git-cliff + run: pip install "git-cliff==2.13.1" - name: Install goreleaser uses: goreleaser/goreleaser-action@v6 diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 1f5b1ef..cb2a807 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -77,7 +77,7 @@ jobs: uses: actions/checkout@v6 - name: Run trivy filesystem scan - uses: aquasecurity/trivy-action@master + uses: aquasecurity/trivy-action@v0.36.0 with: scan-type: "fs" scan-ref: "." diff --git a/.gitignore b/.gitignore index 4c88ec1..916535a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ verda # Local smoke-test output (see test/run.sh) tmp/ +temp/ # Created by https://www.toptal.com/developers/gitignore/api/vim,jetbrains,vscode,git,go,tags,backup,test,emacs # Edit at https://www.toptal.com/developers/gitignore?templates=vim,jetbrains,vscode,git,go,tags,backup,test,emacs diff --git a/.golangci.yaml b/.golangci.yaml index b70bc89..b2c14f7 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -123,4 +123,7 @@ formatters: settings: goimports: local-prefixes: + # Known typo (missing .com): local grouping never matched, and fixing + # the prefix would regroup imports in ~100 files — deliberately + # deferred until a dedicated import-regroup change. Review LOW-2026-08-09. - github/verda-cloud/verda-cli diff --git a/AGENTS.md b/AGENTS.md index a19621c..d4ef650 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,7 +49,7 @@ Skipping these steps leads to pattern violations, broken dual-mode, and pricing - [ ] `make build` passes - [ ] `make lint` passes with zero issues (do not rely on pre-commit to surface these) -- [ ] `make test` passes (runs lint + unit tests) +- [ ] `make test` passes (unit tests with -race; lint is the separate `make lint` item above) - [ ] `--help` renders correctly for changed commands - [ ] Interactive and non-interactive modes both work - [ ] Interactive Selects pass `tui.WithShowHints(true)` so the hint bar renders diff --git a/CLAUDE.md b/CLAUDE.md index 0c8e57f..9d6b02d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,12 +6,12 @@ Go CLI for Verda Cloud. Cobra commands + Bubble Tea TUI + lipgloss styling. ```bash make build # Build binary to ./bin/verda -make test # Run all tests (go test + golangci-lint) -make lint # Lint only +make test # Run all tests (go test -race) +make lint # Lint only (golangci-lint); also run by pre-commit hooks make pre-commit # Full pre-commit suite ``` -Never use raw `go test ./...` — always `make test` which includes linting. +Never use raw `go test ./...` — always `make test` (go test -race). Lint is separate: `make lint`; the pre-commit hooks run both. ## Architecture @@ -43,6 +43,9 @@ Each command directory has its own `CLAUDE.md` (domain knowledge) and `README.md | `cmd/registry/` | CLAUDE.md, README.md | Container registry (vccr.io): configure, configure-docker (alias login), show, ls, tags, push, copy, delete — beta (enabled by default, marked `(beta)` in `verda --help`) | | `cmd/update/` | CLAUDE.md, README.md | CLI self-update | | `cmd/settings/` | CLAUDE.md, README.md | CLI settings management | +| `cmd/objectstorage/` | CLAUDE.md, README.md | S3-style object storage: configure, mb/rb, cp/mv/sync/ls/rm, uploads, presign | +| `cmd/serverless/` | CLAUDE.md, README.md | Serverless containers and batch jobs | +| `cmd/doctor/` | — | Environment diagnostics | | `cmd/availability/` | — | Instance availability by location | | `cmd/cost/` | — | Balance, running costs, estimates | | `cmd/images/` | — | OS image listing | @@ -50,7 +53,7 @@ Each command directory has its own `CLAUDE.md` (domain knowledge) and `README.md | `cmd/locations/` | — | Datacenter locations | | `cmd/status/` | — | Status dashboard | | `cmd/ssh/` | — | SSH into instances | -| `cmd/mcp/` | — | MCP server | +| `cmd/mcp/` | CLAUDE.md, README.md | MCP server (AI-agent tool surface; confirm gates, accepted/completed semantics) | | `cmd/skills/` | — | AI skills management | | `cmd/completion/` | — | Shell completions | @@ -73,7 +76,7 @@ Each command directory has its own `CLAUDE.md` (domain knowledge) and `README.md ### Go House Style — avoid avoidable lint hits -The repo lints with `golangci-lint` via `make lint` (included in `make test`). These are the patterns the linters enforce — write them correctly the first time instead of fixing them in a second pass: +The repo lints with `golangci-lint` via `make lint` (also enforced by the pre-commit hooks, not by `make test`). These are the patterns the linters enforce — write them correctly the first time instead of fixing them in a second pass: - **HTTP bodies** — use `http.NoBody` for GET/DELETE/etc., never `nil`. Close with `defer func() { _ = resp.Body.Close() }()`, not bare `defer resp.Body.Close()` (errcheck). - **American English** — `behavior`, `canceled`, `artifact`, `checkered`, `gray`. `misspell` runs with `locale: US` and rejects British spellings in code and comments. @@ -98,7 +101,7 @@ The repo lints with `golangci-lint` via `make lint` (included in `make test`). T ### Every API-calling command MUST: -1. **Timeout context**: `ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout)` +1. **Timeout context**: `ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout)` for control-plane calls. Data-plane transfers (registry push/copy, object-storage cp/mv/sync) run on `cmd.Context()` — Ctrl+C is the stop signal; a multi-GB transfer legitimately outlives `--timeout`. Interactive prompts also get `cmd.Context()`, and work resumed after a prompt re-bounds its API ctx so prompt think-time can't drain the budget. The shared `http.Client` carries NO `Timeout` — the client cap covers whole-body reads and would clamp transfers. 2. **Spinner**: Show spinner during API calls, stop before handling result 3. **Debug output**: `cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "label:", data)` 4. **Dual mode**: Work with flags (non-interactive) AND prompts (interactive) — no partial wizard @@ -118,10 +121,11 @@ The repo lints with `golangci-lint` via `make lint` (included in `make test`). T ### Pricing — get this wrong and users get billed wrong: -- Instance `price_per_hour` from API is **per-unit** (per-GPU or per-vCPU) -- Total = `price_per_hour * units` — use `cmdutil.InstanceTotalHourlyCost(inst)` -- Volume: `price_per_month_per_gb` — hourly = `ceil(monthly * size / 730 * 10000) / 10000` -- Never display raw API price as "total" without multiplying +- Instance `price_per_hour` from the API (instances AND instance-types endpoints) is the **TOTAL** hourly price of the instance. Never multiply by GPU/vCPU count. Verified live on staging 2026-08-09 (`temp/docs/c1-ondemand-instance.json`; review C1). +- Burn rate = plain sum of instance `price_per_hour` totals (+ volume `base_hourly_cost`). +- A per-unit price shown to the user is total **divided** by units (GPU count or vCPU count) — division only, and only for display. +- Volume hourly: `cmdutil.VolumeHourlyPrice(monthlyPerGB, sizeGB)` = `ceil(monthlyPerGB * sizeGB / HoursInMonth * 10000) / 10000` — the only sanctioned formula (MCP and all CLI surfaces use it). +- Volume monthly: `cmdutil.VolumeMonthlyPrice(monthlyPerGB, sizeGB)`; hourly→monthly estimates use `cmdutil.HoursInMonth` (730 = 365*24/12, matching the web frontend). ### Credentials @@ -157,10 +161,11 @@ Before considering any change complete: ```bash make build # Must compile -make test # Must pass (tests + lint) +make test # Must pass (go test -race) +make lint # Must pass (golangci-lint; also run by pre-commit hooks) ``` -`make test` runs `golangci-lint` — **never** report work as complete with lint failures outstanding. Fix them before the "done" message; don't defer to the pre-commit hook. See the "Go House Style" section above for the patterns that prevent the common hits. +**Never** report work as complete with lint failures outstanding. Fix them before the "done" message; don't defer to the pre-commit hook. See the "Go House Style" section above for the patterns that prevent the common hits. If you modified a command, also verify: - `./bin/verda --help` renders correctly diff --git a/Makefile b/Makefile index d01f6c9..2a1c5a1 100644 --- a/Makefile +++ b/Makefile @@ -25,8 +25,8 @@ lint.fix: ## Run golangci-lint with auto-fix security: ## Run gosec-only scan mirroring CI (ignores .golangci.yaml, so test files are scanned too) @golangci-lint run --no-config -E gosec ./... -test: ## Run all tests - @go test -count=1 ./... +test: ## Run all tests (with race detector) + @go test -race -count=1 ./... test.integration: build ## Run integration tests (requires staging credentials in [test] profile) @cp $(OUTPUT_DIR)/verda /usr/local/bin/verda-test @@ -38,6 +38,7 @@ test-s3-integration: build ## Run S3 data-plane smoke test against a live endpoi fmt: ## Format code with gofmt and goimports @gofmt -w . + @# local prefix has a known typo (missing .com, same as .golangci.yaml); fixing it would regroup ~100 files — deliberate deferral, see .golangci.yaml. @goimports -w -local github/verda-cloud/verda-cli . @go mod tidy diff --git a/README.md b/README.md index 0f71649..690ebfe 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ go install github.com/verda-cloud/verda-cli/cmd/verda@latest ```bash verda --version # verify installation verda update # update to latest -verda update --version v1.0.0 # specific version +verda update --target v1.0.0 # specific version ``` ## Getting Started @@ -157,6 +157,12 @@ Once configured, just talk to your agent: Credentials are shared with the CLI — run `verda auth login` first. +### Safety contract + +Tools that create billed resources (`create_vm`, `create_volume`) require `confirm: true`, and `vm_action` requires it for the destructive actions (`shutdown`, `force_shutdown`, `hibernate`, `delete`) — the same gate as `--yes` in `--agent` mode. Without it the tool fails with `CONFIRMATION_REQUIRED` in the [agent error format](docs/agent-errors.md#mcp-server-tools), so the agent can present the exact action to the user and retry after approval. + +`vm_action` reports `status: "accepted"` once the API accepts the action; only `wait: true` polls the instance to its expected status and reports `"completed"` (`create_vm` waits by default). Argument types are validated strictly — wrong JSON types and unknown enum values fail with `VALIDATION_ERROR` instead of being silently coerced. The full tool/parameter reference lives in [cmd/mcp/README.md](internal/verda-cli/cmd/mcp/README.md). + ### Agent Mode For scripts and agents that use the CLI directly (without MCP): diff --git a/cmd/verda/main.go b/cmd/verda/main.go index 500105b..d9b8060 100644 --- a/cmd/verda/main.go +++ b/cmd/verda/main.go @@ -29,6 +29,11 @@ func main() { if err := root.Execute(); errors.Is(err, cmd.ErrVersionRequested) { // --version flag was handled; exit cleanly. return + } else if cmdutil.IsPromptCancel(err) { + // User cancel (Ctrl+C / Esc from a prompt or wizard) — clean exit, + // no stderr noise. Real failures propagate below. Checked before the + // agent-mode branch so a cancel stays silent there too. + return } else if err != nil { // In agent mode, always emit structured JSON errors. if opts.Agent || cmdutil.IsAgentError(err) { diff --git a/docs/agent-errors.md b/docs/agent-errors.md index 9f520ec..1ba71dd 100644 --- a/docs/agent-errors.md +++ b/docs/agent-errors.md @@ -222,10 +222,11 @@ Catch-all for errors that don't match a more specific code. Errors are classified in this priority order: 1. **Already an AgentError** (from explicit checks in commands) -- returned as-is -2. **SDK `APIError`** -- mapped by HTTP status code (401/403 -> AUTH_ERROR, 404 -> NOT_FOUND, 402 -> INSUFFICIENT_BALANCE, others -> API_ERROR) -3. **SDK `ValidationError`** -- mapped to VALIDATION_ERROR with field and reason -4. **Auth-related message heuristic** -- messages containing "no credentials configured", "unauthorized", "token expired" -> AUTH_ERROR -5. **Fallback** -- generic ERROR with the original message +2. **CLI usage errors** (`cmdutil.UsageError` from flag/argument misuse) -- VALIDATION_ERROR, exit 2 +3. **SDK `APIError`** -- mapped by HTTP status code (401/403 -> AUTH_ERROR, 404 -> NOT_FOUND, 402 -> INSUFFICIENT_BALANCE, others -> API_ERROR) +4. **SDK `ValidationError`** -- mapped to VALIDATION_ERROR with field and reason +5. **Auth-related message heuristic** -- messages containing "no credentials configured", "unauthorized", "token expired" -> AUTH_ERROR +6. **Fallback** -- generic ERROR with the original message ## For Developers @@ -246,3 +247,17 @@ Errors are classified in this priority order: - Error types: `internal/verda-cli/cmd/util/agent_error.go` - Classification: `ClassifyError()` in the same file - Entry point: `cmd/verda/main.go` calls `ClassifyError()` on all errors + +## MCP server tools + +Tools exposed by `verda mcp serve` reuse this contract with one transport difference: MCP has no stderr/exit codes, so tool failures arrive as tool results with `isError: true` whose **text payload is the same JSON envelope**. Argument-contract errors produced inside the MCP server use these codes: + +- `CONFIRMATION_REQUIRED` — billing/destructive tool called without `confirm: true`; `details.action` names the gated action +- `MISSING_REQUIRED_FLAGS` — required tool argument absent; `details.missing` lists them +- `VALIDATION_ERROR` — argument type/out-of-set value rejected; `details.field` + `details.reason` + +```json +{"error": {"code": "CONFIRMATION_REQUIRED", "message": "action \"delete\" creates billing or destructive changes and requires an explicit confirm: true argument", "details": {"action": "delete"}}} +``` + +All other tool failures (API errors, auth, unknown IDs) arrive as plain-text `isError` results. See `internal/verda-cli/cmd/mcp/README.md` for the full tool reference. diff --git a/docs/commands.md b/docs/commands.md index a255162..f738214 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -99,8 +99,9 @@ Credentials are resolved from multiple sources in order of precedence: | 3 | Environment variables | `VERDA_CLIENT_ID`, `VERDA_CLIENT_SECRET` | | 4 | Credentials file | `[default]` in `~/.verda/credentials` | -> **Note:** When `--auth.profile` is passed explicitly, the credentials file -> values for that profile override env vars — but CLI flags still win. +> **Note:** `--auth.profile` / `VERDA_PROFILE` select WHICH credentials file +> profile fills missing values; inline sources (flags, config, env — including +> the `VERDA_AUTH_*` spellings) always win over stored profile values. ### Environment Variables diff --git a/internal/skills/files/verda-reference.md b/internal/skills/files/verda-reference.md index 29a76eb..35ed438 100644 --- a/internal/skills/files/verda-reference.md +++ b/internal/skills/files/verda-reference.md @@ -63,6 +63,8 @@ All commands: `--agent -o json` (except `verda ssh` and `verda auth show`). **Optional flags:** `--location` (default FIN-01), `--ssh-key` (repeatable, takes ID), `--is-spot`, `--os-volume-size` (GiB), `--storage-size` (GiB), `--storage-type` (NVMe/HDD), `--startup-script` (ID), `--contract` (PAY_AS_YOU_GO/SPOT/LONG_TERM), `--from` (template name), `--wait`, `--wait-timeout` (use 2m) +**Agent-mode wait semantics:** in `--agent` mode, `vm create` and `vm action` return immediately after the API accepts the request (`status: "accepted"`). Add `--wait` to poll until the target state; the result then reports `completed`, or an error if the transition fails. + ## VM Lifecycle | Command | Key Flags | @@ -84,7 +86,7 @@ Note: `shutdown` alias is `stop`. `delete` alias is `rm`. | Command | Key Flags | Output Fields | |---------|-----------|---------------| | `verda cost balance -o json` | — | `amount`, `currency` | -| `verda cost estimate -o json` | `--type` (required), `--os-volume`, `--storage`, `--storage-type`, `--spot`, `--location` | `total.hourly`, `instance.hourly`, `os_volume.hourly` | +| `verda cost estimate -o json` | `--type` (required), `--os-volume`, `--storage`, `--storage-type`, `--spot` | `total.hourly`, `instance.hourly`, `os_volume.hourly` | | `verda cost running -o json` | — | `instances[]` (each: `hostname`, `hourly`, `daily`, `monthly`), `total.hourly` | ## Status (Low Priority) @@ -107,10 +109,10 @@ Tell user to run in their terminal: |---------|-----------| | `verda ssh-key list -o json` | — | | `verda ssh-key add -o json` | `--name`, `--public-key` | -| `verda ssh-key delete -o json` | confirm first | +| `verda ssh-key delete --yes -o json` | `--yes` **required** in agent mode | | `verda startup-script list -o json` | — | | `verda startup-script add -o json` | `--name`, `--file` or `--script` | -| `verda startup-script delete -o json` | confirm first | +| `verda startup-script delete --yes -o json` | `--yes` **required** in agent mode | ## Templates (alias: `tmpl`) @@ -135,8 +137,9 @@ Hostname patterns: `{random}` → random words, `{location}` → location code |---------|-----------| | `verda volume list -o json` | `--status` (attached, detached, ordered) | | `verda volume describe -o json` | — | -| `verda volume create -o json` | `--name`, `--size`, `--type` (NVMe/HDD), `--location` | +| `verda volume create -o json` | `--name`, `--size`, `--type` (NVMe/HDD), `--location`, **`--yes`** (required in agent mode — volume creation is billable) | | `verda volume action ` | Actions: detach, rename, resize, clone, delete | +| `verda volume delete --id --yes -o json` | `--yes` **required** in agent mode | | `verda volume trash -o json` | Recoverable within 96 hours | ## Object Storage (S3) diff --git a/internal/skills/manifest.json b/internal/skills/manifest.json index 445b4ae..2753287 100644 --- a/internal/skills/manifest.json +++ b/internal/skills/manifest.json @@ -1,5 +1,5 @@ { - "version": "1.0.0", + "version": "1.2.0", "skills": [ "verda-cloud.md", "verda-reference.md" @@ -36,6 +36,36 @@ "verda-cloud.md": "SKILL.md" } }, + "kimi-code": { + "display_name": "Kimi Code", + "scope": "global", + "target": "~/.kimi-code/skills/", + "method": "copy", + "file_map": { + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md" + } + }, + "opencode": { + "display_name": "OpenCode", + "scope": "global", + "target": "~/.config/opencode/skills/", + "method": "copy", + "file_map": { + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md" + } + }, + "pi": { + "display_name": "Pi", + "scope": "global", + "target": "~/.pi/agent/skills/", + "method": "copy", + "file_map": { + "verda-cloud.md": "verda-cloud/SKILL.md", + "verda-reference.md": "verda-reference/SKILL.md" + } + }, "gemini": { "display_name": "Gemini CLI", "scope": "global", diff --git a/internal/verda-cli/cmd/auth/README.md b/internal/verda-cli/cmd/auth/README.md index 176faba..7fc6240 100644 --- a/internal/verda-cli/cmd/auth/README.md +++ b/internal/verda-cli/cmd/auth/README.md @@ -75,10 +75,14 @@ shows the order of precedence (highest first): | `VERDA_AGENT` | Enable agent mode (`1` or `true`) | | `VERDA_HOME` | Base directory for config (default `~/.verda`) | -### Explicit Profile Override +### Explicit Profile Selection -When `--auth.profile` is passed explicitly, the credentials file values for that -profile override env vars and config file values — but CLI flags still win. +`--auth.profile` / `VERDA_PROFILE` choose WHICH credentials file section +supplies missing values — they do not promote stored values over inline +sources. Per field, the precedence table above always applies: CLI flag > +config file (including `VERDA_AUTH_CLIENT_ID` / `VERDA_AUTH_CLIENT_SECRET` via +viper's env binding) > `VERDA_CLIENT_ID` / `VERDA_CLIENT_SECRET` > credentials +file. The selected profile additionally pins its own `verda_base_url`. For example: @@ -86,12 +90,13 @@ For example: # Env var is set export VERDA_CLIENT_ID=env-id -# Explicit profile overrides the env var -verda compute list --auth.profile=staging -# → uses client ID from [staging] in ~/.verda/credentials, not env-id +# Inline env still wins; [staging] only fills unset fields +verda vm list --auth.profile=staging +# → uses env-id (prior behavior silently used [staging]'s client ID — +# fixed after a wrong-account report) -# But a CLI flag always wins -verda compute list --auth.profile=staging --auth.client-id=flag-id +# A CLI flag wins over everything +verda vm list --auth.profile=staging --auth.client-id=flag-id # → uses flag-id ``` diff --git a/internal/verda-cli/cmd/auth/auth_test.go b/internal/verda-cli/cmd/auth/auth_test.go index cc481dc..3708a72 100644 --- a/internal/verda-cli/cmd/auth/auth_test.go +++ b/internal/verda-cli/cmd/auth/auth_test.go @@ -72,13 +72,5 @@ func TestResolveCredentialsFileUsesDefault(t *testing.T) { func makeLocalTempDir(t *testing.T) string { t.Helper() - - dir, err := os.MkdirTemp(".", "tmp-test-") - if err != nil { - t.Fatalf("os.MkdirTemp() returned error: %v", err) - } - t.Cleanup(func() { - _ = os.RemoveAll(dir) - }) - return dir + return t.TempDir() } diff --git a/internal/verda-cli/cmd/auth/login.go b/internal/verda-cli/cmd/auth/login.go index a6c029b..d344bc9 100644 --- a/internal/verda-cli/cmd/auth/login.go +++ b/internal/verda-cli/cmd/auth/login.go @@ -85,7 +85,7 @@ func NewCmdLogin(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command RunE: func(cmd *cobra.Command, args []string) error { if strings.TrimSpace(opts.ClientID) == "" || strings.TrimSpace(opts.ClientSecret) == "" { flow := buildLoginFlow(opts) - engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) + engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) if err := engine.Run(cmd.Context(), flow); err != nil { return err } diff --git a/internal/verda-cli/cmd/auth/use.go b/internal/verda-cli/cmd/auth/use.go index 0dfb934..9bd0406 100644 --- a/internal/verda-cli/cmd/auth/use.go +++ b/internal/verda-cli/cmd/auth/use.go @@ -51,19 +51,14 @@ func NewCmdUse(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { profile = args[0] } else { // Interactive: list profiles and let user pick. - profiles, err := options.ListProfiles(path) + selected, err := selectProfile(cmd, f, path) if err != nil { return err } - if len(profiles) == 0 { - return fmt.Errorf("no profiles found in %s — run 'verda auth login' first", path) + if selected == "" { + return nil // User pressed Esc/Ctrl+C. } - - idx, err := f.Prompter().Select(cmd.Context(), "Select profile", profiles, tui.WithShowHints(true)) - if err != nil { - return nil //nolint:nilerr // User pressed Esc/Ctrl+C. - } - profile = profiles[idx] + profile = selected } // Validate that the profile exists in the credentials file. @@ -88,6 +83,27 @@ func NewCmdUse(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { return cmd } +// selectProfile lists profiles from the credentials file and prompts the user +// to pick one. Returns "" when the user cancels the selection. +func selectProfile(cmd *cobra.Command, f cmdutil.Factory, path string) (string, error) { + profiles, err := options.ListProfiles(path) + if err != nil { + return "", err + } + if len(profiles) == 0 { + return "", fmt.Errorf("no profiles found in %s — run 'verda auth login' first", path) + } + + idx, err := f.Prompter().Select(cmd.Context(), "Select profile", profiles, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", nil // User pressed Esc/Ctrl+C. + } + return "", err + } + return profiles[idx], nil +} + func writeActiveProfile(path, profile string) error { cfg := map[string]any{} if data, err := os.ReadFile(path); err == nil { //nolint:gosec // controlled config path diff --git a/internal/verda-cli/cmd/cmd.go b/internal/verda-cli/cmd/cmd.go index a7b6df2..58e5be4 100644 --- a/internal/verda-cli/cmd/cmd.go +++ b/internal/verda-cli/cmd/cmd.go @@ -128,7 +128,7 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op initConfig(viper.GetString(clioptions.FlagConfig)) }) - f := cmdutil.NewFactory(opts, ioStreams.ErrOut) + f := cmdutil.NewFactory(opts, ioStreams) resourceCmds := []*cobra.Command{ availability.NewCmdAvailability(f, ioStreams), diff --git a/internal/verda-cli/cmd/cost/estimate.go b/internal/verda-cli/cmd/cost/estimate.go index 6e11a00..841b57f 100644 --- a/internal/verda-cli/cmd/cost/estimate.go +++ b/internal/verda-cli/cmd/cost/estimate.go @@ -17,7 +17,6 @@ package cost import ( "context" "fmt" - "math" "strings" "charm.land/lipgloss/v2" @@ -27,11 +26,8 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) -const hoursInMonth = 730 // 365*24/12 - type estimateOptions struct { InstanceType string - Location string IsSpot bool OSVolumeSize int StorageSize int @@ -73,7 +69,6 @@ func newCmdEstimate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comma flags := cmd.Flags() flags.StringVar(&opts.InstanceType, "type", "", "Instance type (required)") - flags.StringVar(&opts.Location, "location", "", "Location code for pricing") flags.BoolVar(&opts.IsSpot, "spot", false, "Use spot pricing") flags.IntVar(&opts.OSVolumeSize, "os-volume", 0, "OS volume size in GiB") flags.IntVar(&opts.StorageSize, "storage", 0, "Additional storage size in GiB") @@ -148,45 +143,18 @@ func runEstimate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStre Description: instanceDescription(instType), Hourly: instanceHourly, Daily: instanceHourly * 24, - Monthly: instanceHourly * hoursInMonth, + Monthly: instanceHourly * cmdutil.HoursInMonth, }, } // Volume pricing (if needed). if opts.OSVolumeSize > 0 || opts.StorageSize > 0 { - volTypes, err := client.VolumeTypes.GetAllVolumeTypes(ctx) - if err != nil { - return fmt.Errorf("fetching volume pricing: %w", err) - } - vtMap := make(map[string]verda.VolumeType, len(volTypes)) - for _, vt := range volTypes { - vtMap[vt.Type] = vt - } - - if opts.OSVolumeSize > 0 { - item := volumeCostItem("NVMe", opts.OSVolumeSize, vtMap) - estimate.OSVolume = &item - } - if opts.StorageSize > 0 { - item := volumeCostItem(opts.StorageType, opts.StorageSize, vtMap) - estimate.Storage = &item + if err := addVolumeItems(ctx, cmd, client, opts, &estimate); err != nil { + return err } } - // Compute totals. - estimate.Total.Hourly = estimate.Instance.Hourly - estimate.Total.Daily = estimate.Instance.Daily - estimate.Total.Monthly = estimate.Instance.Monthly - if estimate.OSVolume != nil { - estimate.Total.Hourly += estimate.OSVolume.Hourly - estimate.Total.Daily += estimate.OSVolume.Daily - estimate.Total.Monthly += estimate.OSVolume.Monthly - } - if estimate.Storage != nil { - estimate.Total.Hourly += estimate.Storage.Hourly - estimate.Total.Daily += estimate.Storage.Daily - estimate.Total.Monthly += estimate.Storage.Monthly - } + estimate.computeTotals() cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Cost estimate:", estimate) @@ -198,6 +166,35 @@ func runEstimate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStre return nil } +// addVolumeItems fills in the OS volume and storage line items. +func addVolumeItems(ctx context.Context, cmd *cobra.Command, client *verda.Client, opts *estimateOptions, estimate *Estimate) error { + volTypes, err := client.VolumeTypes.GetAllVolumeTypes(ctx) + if err != nil { + return fmt.Errorf("fetching volume pricing: %w", err) + } + vtMap := make(map[string]verda.VolumeType, len(volTypes)) + for _, vt := range volTypes { + vtMap[vt.Type] = vt + } + + if opts.OSVolumeSize > 0 { + // OS volumes are always NVMe; a missing catalog entry is a bug, not user input. + item, err := volumeCostItem(verda.VolumeTypeNVMe, opts.OSVolumeSize, vtMap) + if err != nil { + return err + } + estimate.OSVolume = &item + } + if opts.StorageSize > 0 { + item, err := volumeCostItem(opts.StorageType, opts.StorageSize, vtMap) + if err != nil { + return cmdutil.UsageErrorf(cmd, "invalid --storage-type: %v", err) + } + estimate.Storage = &item + } + return nil +} + func findInstanceType(types []verda.InstanceTypeInfo, name string) *verda.InstanceTypeInfo { for i := range types { if strings.EqualFold(types[i].InstanceType, name) { @@ -207,18 +204,38 @@ func findInstanceType(types []verda.InstanceTypeInfo, name string) *verda.Instan return nil } -func volumeCostItem(volType string, sizeGB int, vtMap map[string]verda.VolumeType) LineItem { - var monthlyPerGB float64 - if vt, ok := vtMap[volType]; ok { - monthlyPerGB = vt.Price.PricePerMonthPerGB +// volumeCostItem prices a volume; an unknown type is an error (was: silent $0). +func volumeCostItem(volType string, sizeGB int, vtMap map[string]verda.VolumeType) (LineItem, error) { + vt, ok := vtMap[volType] + if !ok { + return LineItem{}, fmt.Errorf("unknown volume type %q (valid types: %s)", + volType, strings.Join(cmdutil.ValidVolumeTypeNames(vtMap), ", ")) } - hourly := math.Ceil(monthlyPerGB*float64(sizeGB)/hoursInMonth*10000) / 10000 - monthly := monthlyPerGB * float64(sizeGB) + monthlyPerGB := vt.Price.PricePerMonthPerGB + hourly := cmdutil.VolumeHourlyPrice(monthlyPerGB, sizeGB) return LineItem{ Description: fmt.Sprintf("%dGB %s", sizeGB, volType), Hourly: hourly, Daily: hourly * 24, - Monthly: monthly, + Monthly: cmdutil.VolumeMonthlyPrice(monthlyPerGB, sizeGB), + }, nil +} + +// computeTotals recomputes e.Total from the line items. The money path is +// pinned by TestEstimateTotals. +func (e *Estimate) computeTotals() { + e.Total = TotalItem{ + Hourly: e.Instance.Hourly, + Daily: e.Instance.Daily, + Monthly: e.Instance.Monthly, + } + for _, item := range []*LineItem{e.OSVolume, e.Storage} { + if item == nil { + continue + } + e.Total.Hourly += item.Hourly + e.Total.Daily += item.Daily + e.Total.Monthly += item.Monthly } } diff --git a/internal/verda-cli/cmd/cost/estimate_test.go b/internal/verda-cli/cmd/cost/estimate_test.go index 7ef36c6..2c672f4 100644 --- a/internal/verda-cli/cmd/cost/estimate_test.go +++ b/internal/verda-cli/cmd/cost/estimate_test.go @@ -16,9 +16,12 @@ package cost import ( "math" + "strings" "testing" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) func TestVolumeCostItem(t *testing.T) { @@ -29,17 +32,24 @@ func TestVolumeCostItem(t *testing.T) { "HDD": {Type: "HDD", Price: verda.VolumeTypePrice{PricePerMonthPerGB: 0.03}}, } - item := volumeCostItem("NVMe", 100, vtMap) + item, err := volumeCostItem("NVMe", 100, vtMap) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Monthly = 0.10 * 100 = $10.00 if math.Abs(item.Monthly-10.0) > 0.01 { t.Fatalf("expected monthly $10.00, got $%.2f", item.Monthly) } - // Hourly = ceil(0.10 * 100 / 730 * 10000) / 10000 + // Spec formula: hourly = ceil(monthly*size/730*10000)/10000. expectedHourly := math.Ceil(0.10*100/730*10000) / 10000 if math.Abs(item.Hourly-expectedHourly) > 0.0001 { t.Fatalf("expected hourly $%.4f, got $%.4f", expectedHourly, item.Hourly) } + // Cross-check the production helper agrees. + if item.Hourly != cmdutil.VolumeHourlyPrice(0.10, 100) { + t.Fatalf("hourly $%.4f disagrees with cmdutil.VolumeHourlyPrice $%.4f", item.Hourly, cmdutil.VolumeHourlyPrice(0.10, 100)) + } // Daily = hourly * 24 if math.Abs(item.Daily-item.Hourly*24) > 0.01 { t.Fatalf("expected daily $%.4f, got $%.4f", item.Hourly*24, item.Daily) @@ -53,7 +63,10 @@ func TestVolumeCostItemHDD(t *testing.T) { "HDD": {Type: "HDD", Price: verda.VolumeTypePrice{PricePerMonthPerGB: 0.03}}, } - item := volumeCostItem("HDD", 500, vtMap) + item, err := volumeCostItem("HDD", 500, vtMap) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } // Monthly = 0.03 * 500 = $15.00 if math.Abs(item.Monthly-15.0) > 0.01 { @@ -64,11 +77,18 @@ func TestVolumeCostItemHDD(t *testing.T) { func TestVolumeCostItemUnknownType(t *testing.T) { t.Parallel() - vtMap := map[string]verda.VolumeType{} - item := volumeCostItem("Unknown", 100, vtMap) + vtMap := map[string]verda.VolumeType{ + "NVMe": {Type: "NVMe", Price: verda.VolumeTypePrice{PricePerMonthPerGB: 0.10}}, + } + _, err := volumeCostItem("nvme", 100, vtMap) - if item.Monthly != 0 || item.Hourly != 0 { - t.Fatalf("expected zero pricing for unknown volume type, got hourly=$%.4f monthly=$%.2f", item.Hourly, item.Monthly) + // Unknown types (here: wrong case) must error and list the valid types — + // previously they silently priced at $0 into the estimate total. + if err == nil { + t.Fatal("expected error for unknown volume type, got nil") + } + if !strings.Contains(err.Error(), `"nvme"`) || !strings.Contains(err.Error(), "NVMe") { + t.Fatalf("error should name the invalid type and list valid types, got: %v", err) } } @@ -157,16 +177,13 @@ func TestEstimateTotals(t *testing.T) { Storage: &LineItem{Hourly: 0.0685, Daily: 1.644, Monthly: 50.00}, } - total := e.Instance.Hourly - if e.OSVolume != nil { - total += e.OSVolume.Hourly - } - if e.Storage != nil { - total += e.Storage.Hourly - } + e.computeTotals() expected := 0.44 + 0.0137 + 0.0685 - if math.Abs(total-expected) > 0.001 { - t.Fatalf("expected total hourly $%.4f, got $%.4f", expected, total) + if math.Abs(e.Total.Hourly-expected) > 0.001 { + t.Fatalf("expected total hourly $%.4f, got $%.4f", expected, e.Total.Hourly) + } + if e.Total.Monthly != 321.20+10.00+50.00 { + t.Fatalf("expected total monthly $381.20, got $%.2f", e.Total.Monthly) } } diff --git a/internal/verda-cli/cmd/cost/running.go b/internal/verda-cli/cmd/cost/running.go index c339270..0afa403 100644 --- a/internal/verda-cli/cmd/cost/running.go +++ b/internal/verda-cli/cmd/cost/running.go @@ -17,7 +17,6 @@ package cost import ( "context" "fmt" - "math" "strings" "charm.land/lipgloss/v2" @@ -68,6 +67,17 @@ type RunningCostSummary struct { Total TotalItem `json:"total" yaml:"total"` } +// computeTotals recomputes s.Total from the per-instance rows. The money path +// is pinned by TestRunningCostSummaryTotals. +func (s *RunningCostSummary) computeTotals() { + s.Total = TotalItem{} + for i := range s.Instances { + s.Total.Hourly += s.Instances[i].Hourly + s.Total.Daily += s.Instances[i].Daily + s.Total.Monthly += s.Instances[i].Monthly + } +} + func runRunning(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams) error { client, err := f.VerdaClient() if err != nil { @@ -128,7 +138,7 @@ func runRunning(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStrea volCount++ volGB += vol.Size if vt, ok := vtMap[vol.Type]; ok { - volHourly += math.Ceil(vt.Price.PricePerMonthPerGB*float64(vol.Size)/hoursInMonth*10000) / 10000 + volHourly += cmdutil.VolumeHourlyPrice(vt.Price.PricePerMonthPerGB, vol.Size) } } @@ -141,17 +151,15 @@ func runRunning(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStrea Status: inst.Status, Hourly: totalHourly, Daily: totalHourly * 24, - Monthly: totalHourly * hoursInMonth, + Monthly: totalHourly * cmdutil.HoursInMonth, VolumeCount: volCount, VolumeGB: volGB, VolumeHourly: volHourly, } summary.Instances = append(summary.Instances, rc) - summary.Total.Hourly += rc.Hourly - summary.Total.Daily += rc.Daily - summary.Total.Monthly += rc.Monthly } + summary.computeTotals() cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Running costs:", summary) diff --git a/internal/verda-cli/cmd/cost/running_test.go b/internal/verda-cli/cmd/cost/running_test.go index 06eb0fe..2a0d047 100644 --- a/internal/verda-cli/cmd/cost/running_test.go +++ b/internal/verda-cli/cmd/cost/running_test.go @@ -89,17 +89,16 @@ func TestRunningCostSummaryTotals(t *testing.T) { }, } - var totalH, totalD, totalM float64 - for _, inst := range s.Instances { - totalH += inst.Hourly - totalD += inst.Daily - totalM += inst.Monthly - } + // Assert the production aggregation, not a re-implemented inline sum. + s.computeTotals() - if totalH != 0.58 { - t.Fatalf("expected total hourly 0.58, got %f", totalH) + if s.Total.Hourly != 0.58 { + t.Fatalf("expected total hourly 0.58, got %f", s.Total.Hourly) + } + if s.Total.Daily != 13.92 { + t.Fatalf("expected total daily 13.92, got %f", s.Total.Daily) } - if totalD != 13.92 { - t.Fatalf("expected total daily 13.92, got %f", totalD) + if s.Total.Monthly != 423.4 { + t.Fatalf("expected total monthly 423.4, got %f", s.Total.Monthly) } } diff --git a/internal/verda-cli/cmd/doctor/doctor.go b/internal/verda-cli/cmd/doctor/doctor.go index 5ffe779..2cd812d 100644 --- a/internal/verda-cli/cmd/doctor/doctor.go +++ b/internal/verda-cli/cmd/doctor/doctor.go @@ -83,7 +83,7 @@ func runDoctor(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream authResult := checkAuthentication(f, credResult, apiResult) // CLI update check hits GitHub; spinner covers ~2s of silence. - versionResult, _ := cmdutil.WithSpinner(ctx, f.Status(), "Checking for CLI updates...", func() (checkResult, error) { + versionResult, _ := cmdutil.WithSpinner(ctx, f.Status(), "Checking for CLI updates...", func(ctx context.Context) (checkResult, error) { return checkCLIVersion(ctx), nil }) diff --git a/internal/verda-cli/cmd/mcp/CLAUDE.md b/internal/verda-cli/cmd/mcp/CLAUDE.md new file mode 100644 index 0000000..bff55ed --- /dev/null +++ b/internal/verda-cli/cmd/mcp/CLAUDE.md @@ -0,0 +1,43 @@ +# MCP Server Knowledge + +## Quick Reference +- Command: `verda mcp serve` (stdio) — registered in `mcp.go` +- Client: lazy `clientFunc` resolved on first tool call, cached via `sync.Once` (both value and error are latched) +- Handlers never return Go errors for expected failures: they return `mcp.NewToolResultError(...)` (isError=true) so the agent sees the message +- Argument contract errors use `toolErrorResult` → JSON envelope `{"error": {code, message, details}}` mirroring `docs/agent-errors.md` + +## Domain-Specific Rules + +### Concurrency +- mcp-go dispatches tool calls on a worker pool — every handler may run concurrently +- ALL lazy/shared state goes through `Server.clientOnce` (`server.go`). Do not add check-then-set fields to `Server` +- Guard: `TestLazyClientInitConcurrent` in `lazy_init_test.go` fails under `-race` without it + +### Confirm gates (hard requirement, mirrors `--yes`) +- Billing: `create_vm`, `create_volume` — `confirm: true` always required (schema marks it Required, but mcp-go does not enforce schemas — the handler gate is the enforcement) +- Destructive `vm_action`: `shutdown`, `force_shutdown`, `hibernate`, `delete` — gated via the `vmActions` table's `destructive` flag +- The gate runs BEFORE `verdaClient()`/API calls; a refused call must have zero side effects +- Agent-facing codes: `CONFIRMATION_REQUIRED`, `VALIDATION_ERROR`, `MISSING_REQUIRED_FLAGS` — same names as the CLI `--agent` contract + +### Action honesty +- Never report `"completed"` without observing it: default is `"accepted"`; `wait: true` polls via `cmdutil.PollInstanceStatus` (5 min timeout, nil writer — no TUI spinner over MCP) +- `vmActions.expectStatus` and the destructive set mirror `cmd/vm/action.go` — keep both in sync +- `cmdutil.PollInstanceStatus` reports GO errors on terminal failure statuses (`error`, `notfound`); `PollVolumeStatus` stops immediately on failed volume statuses (`VolumeFailedStatuses` in `cmd/util/status_messages.go`) instead of burning the timeout + +### Strict args +- mcp-go v0.47 performs NO schema validation or coercion for arguments — `GetArguments()` returns the raw map; handlers must reject wrong types themselves +- Use the strict helpers in `server.go`; never write `v, _ := m["x"].(string)` (silent zero values were review NEW-6) +- JSON numbers arrive as `float64`; `int` is accepted for direct-unit-test callers; strings are rejected +- `estimate_cost` prices storage via the API's `/volume-types` catalog; a `storage_type` absent from the catalog is a `VALIDATION_ERROR` listing `cmdutil.ValidVolumeTypeNames` — never $0 (mirrors `cost/estimate.go`) + +## Gotchas +- `Server.getClient` closure in `mcp.go` mutates shared `Options` (`opts.Complete()`); the Once also serializes that +- `NewServer(nil)` is used by tests for registration-only checks — handlers would nil-deref on use; always pass a real client when testing handlers (`tools_test.go` shows the `mockapi` pattern) +- `create_vm`'s `wait` default is `true` (local `pollInstance`, 3s interval); `vm_action`'s `wait` default is `false` +- `add_ssh_key` is additive (not destructive/billing) — intentionally NOT gated +- No MCP tools exist for registry/startup-script mutations; if added, they need the same gate treatment + +## Relationships +- Imports `cmdutil` for `PollInstanceStatus`, `WaitOptions`, `UniqueVolumeIDs`, `VolumeHourlyPrice`, `HoursInMonth`, `ValidVolumeTypeNames` +- Tests reuse `tests/contract/mockapi` (in-process mock API, covers oauth + instances + volumes + volume-types + ssh-keys + availability + balance) +- mcp-go: `github.com/mark3labs/mcp-go@v0.47` — `AddTool` + `ToolHandlerFunc`; handler signature `(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error)` diff --git a/internal/verda-cli/cmd/mcp/README.md b/internal/verda-cli/cmd/mcp/README.md new file mode 100644 index 0000000..8103622 --- /dev/null +++ b/internal/verda-cli/cmd/mcp/README.md @@ -0,0 +1,79 @@ +# verda mcp -- MCP server for AI agents (beta) + +Exposes Verda Cloud operations as [MCP](https://modelcontextprotocol.io/) tools over stdio, for agents that cannot (or prefer not to) shell out to the CLI. + +```json +{ + "mcpServers": { + "verda": { + "command": "verda", + "args": ["mcp", "serve"] + } + } +} +``` + +Credentials are shared with the CLI — run `verda auth login` first in the same profile environment the agent process inherits. The client is created lazily on the first tool call; a credential error is *latched* for the server lifetime (fix credentials, then restart the server). + +## Tools + +| Tool | Mutating? | Notable arguments | +|------|-----------|-------------------| +| `list_locations` | no | — | +| `list_instance_types` | no | `gpu_only`, `cpu_only`, `spot` | +| `check_availability` | no | `location`, `instance_type`, `spot` | +| `vm_availability` | no | `location`, `instance_type`, `gpu_only`, `cpu_only`, `spot` | +| `list_images` | no | `instance_type`, `category` (unused today) | +| `list_vms` | no | `status` | +| `describe_vm` | no | `id` (required) | +| `get_balance` | no | — | +| `estimate_cost` | no | `instance_type` (required), `os_volume_gb`, `storage_gb`, `storage_type`, `spot` | +| `get_running_costs` | no | — | +| `list_ssh_keys` | no | `search` | +| `get_ssh_command` | no | `id_or_hostname` (required), `user`, `key_path` | +| `list_volumes` | no | — | +| `list_volumes_in_trash` | no | — | +| `add_ssh_key` | yes (additive) | `name`, `public_key` (both required) | +| `create_volume` | **yes, billing** — `confirm:true` required | `name`, `size_gb`, `confirm` (required); `type`, `location` | +| `create_vm` | **yes, billing** — `confirm:true` required | `instance_type`, `image`, `hostname`, `confirm` (required); `location`, `description`, `os_volume_size_gb`, `ssh_key_ids`, `startup_script_id`, `spot`, `storage_size_gb`, `storage_type`, `wait` (default true) | +| `vm_action` | **action-dependent** | `id`, `action` (required); `confirm`, `wait` | + +## Confirmation contract + +Tools that create billed resources (`create_vm`, `create_volume`) or perform destructive actions (`vm_action` with `shutdown`, `force_shutdown`, `hibernate`, `delete`) require the boolean argument `confirm: true`. This mirrors `--yes` in the CLI's `--agent` mode. + +Without it, the tool fails with `isError: true` and a JSON payload following the CLI agent-error envelope (see [docs/agent-errors.md](../../../docs/agent-errors.md)): + +```json +{"error": {"code": "CONFIRMATION_REQUIRED", "message": "action \"delete\" creates billing or destructive changes and requires an explicit confirm: true argument", "details": {"action": "delete"}}} +``` + +**Agent action:** show the user the exact target and (for creates) the `estimate_cost` result; only retry with `confirm: true` after explicit approval. + +## Action semantics: `accepted` vs `completed` + +`vm_action` reports `status: "accepted"` by default — the API has accepted the action, nothing more. `shutdown` of a running VM is *not* done at that point; do not tell the user billing stopped. + +With `wait: true`, the tool polls the instance (up to 5 minutes) until it reaches the action's expected status (`start`→running, `shutdown`/`force_shutdown`/`hibernate`→offline) and then reports `status: "completed"` plus `instance_status`. A failed transition (instance enters `error`) is a tool error, not a success. `delete` is not polled and always returns `accepted`. + +`create_vm` keeps `wait: true` as its default and blocks until the instance is `running`; on poll failure it returns the instance plus `poll_error`/`poll_timed_out` fields. + +## Argument typing + +mcp-go does not validate arguments against the declared schema. All handlers type-check arguments explicitly; mismatches fail with the envelope and code `VALIDATION_ERROR` (details: `field`, `reason`) instead of being silently coerced: + +- numbers must be numbers — `"500"` for `os_volume_size_gb` is rejected (was: coerced to 0, silently applying the 50 GB default) +- arrays must be arrays — `ssh_key_ids` as a bare string is rejected (was: dropped, attaching *all* account SSH keys) +- enums are checked against their allowed sets — `storage_type` accepts only `NVMe`/`HDD`; `vm_action` `action` accepts only the five documented verbs; unknown `estimate_cost` `storage_type` errors listing the types from the API catalog instead of pricing storage at $0 +- missing required arguments produce `MISSING_REQUIRED_FLAGS` + +## Files + +- `mcp.go` — `verda mcp` / `verda mcp serve` cobra wiring; lazy client creation +- `server.go` — server struct, `sync.Once` client init, tool-error envelope, strict argument helpers (`requiredString`, `optionalString`, `optionalBool`, `requiredInt`/`optionalInt`, `optionalEnum`, `optionalStringSlice`, `toolErrorResult`) +- `tools_discovery.go` — locations, instance types, availability, images +- `tools_cost.go` — balance, estimate (CLI pricing formula via `cmdutil`), running costs +- `tools_vm.go` — VM list/describe/create/action; `vmActions` table mirrors `cmd/vm/action.go` +- `tools_ssh.go` — SSH key list/add, ssh command construction +- `tools_volume.go` — volume list/create/trash +- `server_test.go` — strict helper + envelope tests; `lazy_init_test.go` — concurrent-init race repro (`-race`); `tools_test.go` — handler tests against `tests/contract/mockapi` diff --git a/internal/verda-cli/cmd/mcp/lazy_init_test.go b/internal/verda-cli/cmd/mcp/lazy_init_test.go new file mode 100644 index 0000000..139f608 --- /dev/null +++ b/internal/verda-cli/cmd/mcp/lazy_init_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" +) + +// TestLazyClientInitConcurrent reproduces the lazy-init race: mcp-go dispatches +// tool calls on a worker pool, so the first parallel batch (e.g. list_vms + +// get_balance) hits the check-then-set on Server.client concurrently. Under +// -race this is a data race; functionally the factory must run exactly once. +func TestLazyClientInitConcurrent(t *testing.T) { + t.Parallel() + + client, err := verda.NewClient( + verda.WithBaseURL("http://127.0.0.1:1"), + verda.WithClientID("test-id"), + verda.WithClientSecret("test-secret"), + ) + if err != nil { + t.Fatalf("creating client: %v", err) + } + + var calls atomic.Int32 + s := NewLazyServer(func() (*verda.Client, error) { + calls.Add(1) + // Widen the check-then-set window so parallel first calls overlap. + time.Sleep(10 * time.Millisecond) + return client, nil + }) + + const workers = 16 + var wg sync.WaitGroup + errs := make(chan error, workers) + for i := 0; i < workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + c, err := s.verdaClient() + if err != nil { + errs <- err + return + } + if c != client { + errs <- errors.New("verdaClient returned the wrong client instance") + } + }() + } + wg.Wait() + close(errs) + + for err := range errs { + t.Error(err) + } + if n := calls.Load(); n != 1 { + t.Fatalf("lazy client factory called %d times, want exactly 1", n) + } +} diff --git a/internal/verda-cli/cmd/mcp/server.go b/internal/verda-cli/cmd/mcp/server.go index f723c38..3cc3f91 100644 --- a/internal/verda-cli/cmd/mcp/server.go +++ b/internal/verda-cli/cmd/mcp/server.go @@ -17,8 +17,11 @@ package mcp import ( "context" "encoding/json" + "errors" "fmt" "os" + "strings" + "sync" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -31,9 +34,11 @@ type clientFunc func() (*verda.Client, error) // Server wraps the MCP protocol server and Verda SDK client. type Server struct { - client *verda.Client - getClient clientFunc - mcpServer *server.MCPServer + client *verda.Client + clientErr error + clientOnce sync.Once + getClient clientFunc + mcpServer *server.MCPServer } // NewServer creates a new MCP server backed by the given Verda client. @@ -65,17 +70,18 @@ func newServer(getClient clientFunc) *Server { return s } -// verdaClient returns the Verda SDK client, creating it on first call. +// verdaClient returns the Verda SDK client, creating it exactly once. +// mcp-go dispatches tool calls on a worker pool, so the lazy init must be +// safe for concurrent first calls; a factory error is latched too (fix +// credentials, then restart the server). func (s *Server) verdaClient() (*verda.Client, error) { - if s.client != nil { - return s.client, nil + s.clientOnce.Do(func() { + s.client, s.clientErr = s.getClient() + }) + if s.clientErr != nil { + return nil, s.clientErr } - c, err := s.getClient() - if err != nil { - return nil, err - } - s.client = c - return c, nil + return s.client, nil } // ServeStdio starts the MCP server on stdin/stdout. @@ -93,6 +99,63 @@ func jsonResult(data any) (*mcp.CallToolResult, error) { return mcp.NewToolResultText(string(b)), nil } +// argError is a typed argument error carrying the CLI agent-contract code and +// details (docs/agent-errors.md), rendered into MCP tool-error payloads. +type argError struct { + code string + message string + details map[string]any +} + +func (e *argError) Error() string { return e.message } + +func missingArgError(name string) *argError { + return &argError{ + code: "MISSING_REQUIRED_FLAGS", + message: fmt.Sprintf("missing required argument %q", name), + details: map[string]any{"missing": []string{name}}, + } +} + +func invalidArgError(name, reason string) *argError { + return &argError{ + code: "VALIDATION_ERROR", + message: fmt.Sprintf("invalid value for %s: %s", name, reason), + details: map[string]any{"field": name, "reason": reason}, + } +} + +// confirmationRequiredError mirrors the CLI's agent-mode CONFIRMATION_REQUIRED +// contract: destructive and billing tools refuse to run without confirm=true. +func confirmationRequiredError(action string) *argError { + return &argError{ + code: "CONFIRMATION_REQUIRED", + message: fmt.Sprintf("action %q creates billing or destructive changes and requires an explicit confirm: true argument", action), + details: map[string]any{"action": action}, + } +} + +// toolErrorResult renders err as an MCP tool-error result. argErrors serialize +// to the agent-contract JSON envelope ({"error": {code, message, details}}) so +// agents can branch on code the same way as with `verda --agent` stderr. +func toolErrorResult(err error) *mcp.CallToolResult { + var ae *argError + if !errors.As(err, &ae) { + return mcp.NewToolResultError(err.Error()) + } + b, mErr := json.Marshal(map[string]any{ + "error": map[string]any{ + "code": ae.code, + "message": ae.message, + "details": ae.details, + }, + }) + if mErr != nil { + return mcp.NewToolResultError(ae.message) + } + return mcp.NewToolResultError(string(b)) +} + // args extracts the arguments map from a CallToolRequest. // //nolint:gocritic // hugeParam: handler signature is defined by mcp-go library. @@ -104,64 +167,138 @@ func args(req mcp.CallToolRequest) map[string]any { func requiredString(a map[string]any, name string) (string, error) { v, ok := a[name] if !ok || v == nil { - return "", fmt.Errorf("missing required argument %q", name) + return "", missingArgError(name) } s, ok := v.(string) - if !ok || s == "" { - return "", fmt.Errorf("argument %q must be a non-empty string", name) + if !ok { + return "", invalidArgError(name, "must be a string, got "+jsonTypeName(v)) + } + if s == "" { + return "", invalidArgError(name, "must be a non-empty string") } return s, nil } -// optionalString extracts an optional string argument, returning "" if absent. -func optionalString(a map[string]any, name string) string { +// optionalString extracts an optional string argument. A present value of the +// wrong type is rejected: mcp-go does no schema validation, so silent coercion +// here (e.g. treating a number as "") hides caller bugs. +func optionalString(a map[string]any, name string) (string, error) { v, ok := a[name] if !ok || v == nil { - return "" + return "", nil } - s, _ := v.(string) - return s + s, ok := v.(string) + if !ok { + return "", invalidArgError(name, "must be a string, got "+jsonTypeName(v)) + } + return s, nil } // optionalBool extracts an optional boolean argument, returning false if absent. -func optionalBool(a map[string]any, name string) bool { +func optionalBool(a map[string]any, name string) (bool, error) { v, ok := a[name] if !ok || v == nil { - return false + return false, nil + } + b, ok := v.(bool) + if !ok { + return false, invalidArgError(name, "must be a boolean, got "+jsonTypeName(v)) } - b, _ := v.(bool) - return b + return b, nil +} + +// requiredInt extracts a required positive-integer argument. +func requiredInt(a map[string]any, name string) (int, error) { + v, ok := a[name] + if !ok || v == nil { + return 0, missingArgError(name) + } + return strictInt(a, name, v) } // optionalInt extracts an optional integer argument, returning 0 if absent. -func optionalInt(a map[string]any, name string) int { +// JSON numbers arrive as float64; strings must be rejected, not coerced +// ("500" silently becoming 0 was NEW-6 in the architecture review). +func optionalInt(a map[string]any, name string) (int, error) { v, ok := a[name] if !ok || v == nil { - return 0 + return 0, nil } - // JSON numbers are float64 - f, ok := v.(float64) - if !ok { - return 0 + return strictInt(a, name, v) +} + +func strictInt(a map[string]any, name string, v any) (int, error) { + var f float64 + switch n := v.(type) { + case float64: + f = n + case int: + f = float64(n) + default: + return 0, invalidArgError(name, "must be a number, got "+jsonTypeName(v)) + } + i := int(f) + if float64(i) != f { + return 0, invalidArgError(name, "must be a whole number") + } + if i < 0 { + return 0, invalidArgError(name, "must not be negative") } - return int(f) + return i, nil } -// optionalStringSlice extracts an optional string array argument. -func optionalStringSlice(a map[string]any, name string) []string { +// optionalEnum extracts an optional string argument restricted to the allowed +// values; an out-of-set value is rejected with the allowed list. +func optionalEnum(a map[string]any, name string, allowed ...string) (string, error) { + s, err := optionalString(a, name) + if err != nil || s == "" { + return "", err + } + for _, allow := range allowed { + if s == allow { + return s, nil + } + } + return "", invalidArgError(name, fmt.Sprintf("invalid value %q (valid: %s)", s, strings.Join(allowed, ", "))) +} + +// optionalStringSlice extracts an optional string array argument. A non-array +// value is rejected: silently dropping it would fall through to the "attach +// all account keys" default in create_vm. +func optionalStringSlice(a map[string]any, name string) ([]string, error) { v, ok := a[name] if !ok || v == nil { - return nil + return nil, nil } arr, ok := v.([]any) if !ok { - return nil + return nil, invalidArgError(name, "must be an array of strings, got "+jsonTypeName(v)) } result := make([]string, 0, len(arr)) - for _, item := range arr { - if s, ok := item.(string); ok { - result = append(result, s) + for i, item := range arr { + s, ok := item.(string) + if !ok { + return nil, invalidArgError(name, fmt.Sprintf("element %d must be a string, got %s", i, jsonTypeName(item))) } + result = append(result, s) + } + return result, nil +} + +// jsonTypeName names JSON-ish value kinds for type-mismatch messages. +func jsonTypeName(v any) string { + switch v.(type) { + case string: + return "a string" + case bool: + return "a boolean" + case float64, int: + return "a number" + case []any: + return "an array" + case map[string]any: + return "an object" + default: + return fmt.Sprintf("%T", v) } - return result } diff --git a/internal/verda-cli/cmd/mcp/server_test.go b/internal/verda-cli/cmd/mcp/server_test.go index fcf3d92..65418c0 100644 --- a/internal/verda-cli/cmd/mcp/server_test.go +++ b/internal/verda-cli/cmd/mcp/server_test.go @@ -16,11 +16,51 @@ package mcp import ( "encoding/json" + "errors" "testing" "github.com/mark3labs/mcp-go/mcp" ) +// resultText extracts the text payload of a single-content tool result. +func resultText(t *testing.T, res *mcp.CallToolResult) string { + t.Helper() + if res == nil { + t.Fatal("nil result") + } + if len(res.Content) == 0 { + t.Fatal("result has no content") + } + tc, ok := res.Content[0].(mcp.TextContent) + if !ok { + t.Fatalf("content is %T, want mcp.TextContent", res.Content[0]) + } + return tc.Text +} + +// assertToolErrorCode asserts the result is an error carrying the given +// agent-contract code in its JSON envelope. +func assertToolErrorCode(t *testing.T, res *mcp.CallToolResult, code string) { + t.Helper() + if !res.IsError { + t.Fatalf("expected error result, got success: %s", resultText(t, res)) + } + var env struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` + } `json:"error"` + } + text := resultText(t, res) + if err := json.Unmarshal([]byte(text), &env); err != nil { + t.Fatalf("error payload is not the contract envelope: %v\ntext: %s", err, text) + } + if env.Error.Code != code { + t.Fatalf("code = %q, want %q\ntext: %s", env.Error.Code, code, text) + } +} + func TestRequiredString(t *testing.T) { a := map[string]any{"name": "test"} @@ -36,32 +76,154 @@ func TestRequiredString(t *testing.T) { if err == nil { t.Fatal("expected error for missing arg") } + var ae *argError + if errors.As(err, &ae) && ae.code != "MISSING_REQUIRED_FLAGS" { + t.Errorf("code = %q, want MISSING_REQUIRED_FLAGS", ae.code) + } + + a["num"] = float64(7) + _, err = requiredString(a, "num") + if err == nil { + t.Fatal("expected error for non-string arg") + } + if errors.As(err, &ae) && ae.code != "VALIDATION_ERROR" { + t.Errorf("code = %q, want VALIDATION_ERROR", ae.code) + } +} + +func TestOptionalStringStrict(t *testing.T) { + a := map[string]any{"str": "hello", "num": float64(1)} + + if got, err := optionalString(a, "str"); err != nil || got != "hello" { + t.Errorf("optionalString = %q, %v; want hello, nil", got, err) + } + if got, err := optionalString(a, "missing"); err != nil || got != "" { + t.Errorf("optionalString(missing) = %q, %v; want empty, nil", got, err) + } + if _, err := optionalString(a, "num"); err == nil { + t.Error("optionalString(number) = nil error, want VALIDATION_ERROR") + } } -func TestOptionalHelpers(t *testing.T) { +func TestOptionalBoolStrict(t *testing.T) { + a := map[string]any{"flag": true, "str": "true"} + + if got, err := optionalBool(a, "flag"); err != nil || !got { + t.Errorf("optionalBool = %v, %v; want true, nil", got, err) + } + if got, err := optionalBool(a, "missing"); err != nil || got { + t.Errorf("optionalBool(missing) = %v, %v; want false, nil", got, err) + } + if _, err := optionalBool(a, "str"); err == nil { + t.Error(`optionalBool("true" string) = nil error, want VALIDATION_ERROR`) + } +} + +func TestNumbersStrict(t *testing.T) { a := map[string]any{ - "str": "hello", - "flag": true, - "num": float64(42), + "json_num": float64(42), + "go_int": 7, + "str_num": "500", + "frac": float64(1.5), + "neg": float64(-10), + "bool": true, } - if got := optionalString(a, "str"); got != "hello" { - t.Errorf("optionalString = %q, want %q", got, "hello") + if got, err := optionalInt(a, "json_num"); err != nil || got != 42 { + t.Errorf("optionalInt = %d, %v; want 42, nil", got, err) } - if got := optionalString(a, "missing"); got != "" { - t.Errorf("optionalString(missing) = %q, want empty", got) + if got, err := optionalInt(a, "go_int"); err != nil || got != 7 { + t.Errorf("optionalInt(int) = %d, %v; want 7, nil", got, err) } - if got := optionalBool(a, "flag"); !got { - t.Error("optionalBool = false, want true") + if got, err := optionalInt(a, "missing"); err != nil || got != 0 { + t.Errorf("optionalInt(missing) = %d, %v; want 0, nil", got, err) } - if got := optionalBool(a, "missing"); got { - t.Error("optionalBool(missing) = true, want false") + + // The review repro: a string "500" must not silently become 0/50GB default. + for _, name := range []string{"str_num", "frac", "neg", "bool"} { + if _, err := optionalInt(a, name); err == nil { + t.Errorf("optionalInt(%s) = nil error, want rejection", name) + } + } + + if _, err := requiredInt(a, "missing_int"); err == nil { + t.Error("requiredInt(missing) = nil error, want MISSING_REQUIRED_FLAGS") + } + if got, err := requiredInt(a, "json_num"); err != nil || got != 42 { + t.Errorf("requiredInt = %d, %v; want 42, nil", got, err) + } +} + +func TestOptionalStringSliceStrict(t *testing.T) { + a := map[string]any{ + "arr": []any{"a", "b"}, + "string": "abc", + "mixed": []any{"a", float64(2)}, + "empty_ok": []any{}, + } + + if got, err := optionalStringSlice(a, "arr"); err != nil || len(got) != 2 { + t.Errorf("optionalStringSlice = %v, %v", got, err) + } + if got, err := optionalStringSlice(a, "missing"); err != nil || got != nil { + t.Errorf("optionalStringSlice(missing) = %v, %v", got, err) + } + // The review repro: a bare string must not fall through to nil (create_vm + // would then attach ALL account SSH keys). + if got, err := optionalStringSlice(a, "string"); err == nil || got != nil { + t.Errorf("optionalStringSlice(string) = %v, %v; want nil, error", got, err) + } + if _, err := optionalStringSlice(a, "mixed"); err == nil { + t.Error("optionalStringSlice(mixed types) = nil error, want VALIDATION_ERROR") + } + if got, err := optionalStringSlice(a, "empty_ok"); err != nil || len(got) != 0 { + t.Errorf("optionalStringSlice(empty) = %v, %v; want [], nil", got, err) } - if got := optionalInt(a, "num"); got != 42 { - t.Errorf("optionalInt = %d, want 42", got) +} + +func TestOptionalEnum(t *testing.T) { + a := map[string]any{"type": "NVMe", "bad": "SCSI", "num": float64(1)} + + if got, err := optionalEnum(a, "type", "NVMe", "HDD"); err != nil || got != "NVMe" { + t.Errorf("optionalEnum = %q, %v; want NVMe, nil", got, err) + } + if got, err := optionalEnum(a, "missing", "NVMe", "HDD"); err != nil || got != "" { + t.Errorf("optionalEnum(missing) = %q, %v; want empty, nil", got, err) + } + if _, err := optionalEnum(a, "bad", "NVMe", "HDD"); err == nil { + t.Error("optionalEnum(SCSI) = nil error, want VALIDATION_ERROR with valid list") + } + if _, err := optionalEnum(a, "num", "NVMe"); err == nil { + t.Error("optionalEnum(number) = nil error, want VALIDATION_ERROR") + } +} + +func TestToolErrorResultEnvelope(t *testing.T) { + res := toolErrorResult(confirmationRequiredError("create_vm")) + if !res.IsError { + t.Fatal("expected IsError") + } + var env struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(resultText(t, res)), &env); err != nil { + t.Fatalf("not the contract envelope: %v", err) } - if got := optionalInt(a, "missing"); got != 0 { - t.Errorf("optionalInt(missing) = %d, want 0", got) + if env.Error.Code != "CONFIRMATION_REQUIRED" { + t.Errorf("code = %q, want CONFIRMATION_REQUIRED", env.Error.Code) + } + if env.Error.Details["action"] != "create_vm" { + t.Errorf("details.action = %v, want create_vm", env.Error.Details["action"]) + } + + // Non-argError falls back to plain text. + res = toolErrorResult(errors.New("boom")) + if !res.IsError || resultText(t, res) != "boom" { + t.Errorf("plain error = %q, IsError=%v; want boom, true", resultText(t, res), res.IsError) } } diff --git a/internal/verda-cli/cmd/mcp/tools_cost.go b/internal/verda-cli/cmd/mcp/tools_cost.go index 705d20d..12ace04 100644 --- a/internal/verda-cli/cmd/mcp/tools_cost.go +++ b/internal/verda-cli/cmd/mcp/tools_cost.go @@ -18,9 +18,12 @@ import ( "context" "fmt" "math" + "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) func (s *Server) registerCostTools() { @@ -39,14 +42,13 @@ func (s *Server) registerCostTools() { mcp.WithNumber("storage_gb", mcp.Description("Additional storage size in GiB")), mcp.WithString("storage_type", mcp.Description("Storage type: NVMe or HDD (default NVMe)")), mcp.WithBoolean("spot", mcp.Description("Use spot pricing")), - mcp.WithString("location", mcp.Description("Location code for pricing")), ), s.handleEstimateCost, ) s.mcpServer.AddTool( mcp.NewTool("get_running_costs", - mcp.WithDescription("Show costs of currently running instances"), + mcp.WithDescription("Show costs of currently running instances, including attached volumes"), ), s.handleGetRunningCosts, ) @@ -68,22 +70,35 @@ func (s *Server) handleGetBalance(ctx context.Context, _ mcp.CallToolRequest) (* //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleEstimateCost(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := s.verdaClient() + a := args(req) + + instanceType, err := requiredString(a, "instance_type") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } - - instanceType, err := requiredString(args(req), "instance_type") + spot, err := optionalBool(a, "spot") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil + } + osVolumeGB, err := optionalInt(a, "os_volume_gb") + if err != nil { + return toolErrorResult(err), nil + } + storageGB, err := optionalInt(a, "storage_gb") + if err != nil { + return toolErrorResult(err), nil + } + storageType, err := optionalString(a, "storage_type") + if err != nil { + return toolErrorResult(err), nil } - - spot := optionalBool(args(req), "spot") - osVolumeGB := optionalInt(args(req), "os_volume_gb") - storageGB := optionalInt(args(req), "storage_gb") - storageType := optionalString(args(req), "storage_type") if storageType == "" { - storageType = "NVMe" + storageType = verda.VolumeTypeNVMe + } + + client, err := s.verdaClient() + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } // Get instance type pricing by fetching all types and filtering. @@ -107,22 +122,12 @@ func (s *Server) handleEstimateCost(ctx context.Context, req mcp.CallToolRequest instanceHourly = info.SpotPrice.Float64() } - // Get volume pricing. + // Get volume pricing (shared cmdutil helper: same formula as the CLI). var osVolumeHourly, storageHourly float64 if osVolumeGB > 0 || storageGB > 0 { - volTypes, err := client.VolumeTypes.GetAllVolumeTypes(ctx) + osVolumeHourly, storageHourly, err = volumeHourlyRates(ctx, client, osVolumeGB, storageGB, storageType) if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - for _, vt := range volTypes { - monthlyPerGB := vt.Price.PricePerMonthPerGB - hourlyPerGB := math.Ceil(monthlyPerGB/30/24*10000) / 10000 - if vt.Type == "NVMe" && osVolumeGB > 0 { - osVolumeHourly = hourlyPerGB * float64(osVolumeGB) - } - if vt.Type == storageType && storageGB > 0 { - storageHourly = hourlyPerGB * float64(storageGB) - } + return toolErrorResult(err), nil } } @@ -133,7 +138,7 @@ func (s *Server) handleEstimateCost(ctx context.Context, req mcp.CallToolRequest "estimate": map[string]any{ "hourly": round4(totalHourly), "daily": round4(totalHourly * 24), - "monthly": round4(totalHourly * 24 * 30), + "monthly": round4(totalHourly * cmdutil.HoursInMonth), "breakdown": map[string]any{ "instance": round4(instanceHourly), "os_volume": round4(osVolumeHourly), @@ -144,6 +149,38 @@ func (s *Server) handleEstimateCost(ctx context.Context, req mcp.CallToolRequest return jsonResult(result) } +// volumeHourlyRates prices the OS volume (always NVMe) and extra storage from +// the API volume-type catalog. A type missing from the catalog fails loudly — +// pricing it $0 would lie about cost (mirrors the CLI's volumeCostItem). +func volumeHourlyRates(ctx context.Context, client *verda.Client, osVolumeGB, storageGB int, storageType string) (osHourly, storageHourly float64, err error) { + volTypes, err := client.VolumeTypes.GetAllVolumeTypes(ctx) + if err != nil { + return 0, 0, err + } + vtMap := make(map[string]verda.VolumeType, len(volTypes)) + for _, vt := range volTypes { + vtMap[vt.Type] = vt + } + + if osVolumeGB > 0 { + vt, ok := vtMap[verda.VolumeTypeNVMe] + if !ok { + return 0, 0, fmt.Errorf("volume type catalog has no %q entry; cannot price the OS volume (valid types: %s)", + verda.VolumeTypeNVMe, strings.Join(cmdutil.ValidVolumeTypeNames(vtMap), ", ")) + } + osHourly = cmdutil.VolumeHourlyPrice(vt.Price.PricePerMonthPerGB, osVolumeGB) + } + if storageGB > 0 { + vt, ok := vtMap[storageType] + if !ok { + return 0, 0, invalidArgError("storage_type", + fmt.Sprintf("unknown volume type %q (valid types: %s)", storageType, strings.Join(cmdutil.ValidVolumeTypeNames(vtMap), ", "))) + } + storageHourly = cmdutil.VolumeHourlyPrice(vt.Price.PricePerMonthPerGB, storageGB) + } + return osHourly, storageHourly, nil +} + //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleGetRunningCosts(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { client, err := s.verdaClient() @@ -156,23 +193,55 @@ func (s *Server) handleGetRunningCosts(ctx context.Context, _ mcp.CallToolReques return mcp.NewToolResultError(err.Error()), nil } + // Volume pricing for attached-volume costs (mirrors `verda cost running`). + volTypes, err := client.VolumeTypes.GetAllVolumeTypes(ctx) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + vtMap := make(map[string]verda.VolumeType, len(volTypes)) + for _, vt := range volTypes { + vtMap[vt.Type] = vt + } + type instanceCost struct { ID string `json:"id"` Hostname string `json:"hostname"` InstanceType string `json:"instance_type"` HourlyCost float64 `json:"hourly_cost"` + VolumeCount int `json:"volume_count"` + VolumeGB int `json:"volume_gb"` + VolumeHourly float64 `json:"volume_hourly"` // attached-volume share of hourly_cost } var totalHourly float64 costs := make([]instanceCost, 0, len(instances)) for i := range instances { - hourly := instances[i].PricePerHour.Float64() + instanceHourly := instances[i].PricePerHour.Float64() + + var volCount, volGB int + var volHourly float64 + for _, volID := range cmdutil.UniqueVolumeIDs(&instances[i]) { + vol, err := client.Volumes.GetVolume(ctx, volID) + if err != nil { + continue + } + volCount++ + volGB += vol.Size + if vt, ok := vtMap[vol.Type]; ok { + volHourly += cmdutil.VolumeHourlyPrice(vt.Price.PricePerMonthPerGB, vol.Size) + } + } + + hourly := instanceHourly + volHourly totalHourly += hourly costs = append(costs, instanceCost{ ID: instances[i].ID, Hostname: instances[i].Hostname, InstanceType: instances[i].InstanceType, HourlyCost: hourly, + VolumeCount: volCount, + VolumeGB: volGB, + VolumeHourly: round4(volHourly), }) } diff --git a/internal/verda-cli/cmd/mcp/tools_discovery.go b/internal/verda-cli/cmd/mcp/tools_discovery.go index 0f25694..0593be4 100644 --- a/internal/verda-cli/cmd/mcp/tools_discovery.go +++ b/internal/verda-cli/cmd/mcp/tools_discovery.go @@ -76,6 +76,16 @@ func (s *Server) handleListLocations(ctx context.Context, _ mcp.CallToolRequest) //nolint:gocritic // hugeParam: handler signature defined by mcp-go. //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleListInstanceTypes(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + a := args(req) + gpuOnly, err := optionalBool(a, "gpu_only") + if err != nil { + return toolErrorResult(err), nil + } + cpuOnly, err := optionalBool(a, "cpu_only") + if err != nil { + return toolErrorResult(err), nil + } + client, err := s.verdaClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -86,9 +96,6 @@ func (s *Server) handleListInstanceTypes(ctx context.Context, req mcp.CallToolRe return mcp.NewToolResultError(err.Error()), nil } - gpuOnly := optionalBool(args(req), "gpu_only") - cpuOnly := optionalBool(args(req), "cpu_only") - if gpuOnly || cpuOnly { filtered := types[:0] for i := range types { @@ -108,15 +115,25 @@ func (s *Server) handleListInstanceTypes(ctx context.Context, req mcp.CallToolRe //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleCheckAvailability(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + a := args(req) + location, err := optionalString(a, "location") + if err != nil { + return toolErrorResult(err), nil + } + instanceType, err := optionalString(a, "instance_type") + if err != nil { + return toolErrorResult(err), nil + } + spot, err := optionalBool(a, "spot") + if err != nil { + return toolErrorResult(err), nil + } + client, err := s.verdaClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } - location := optionalString(args(req), "location") - instanceType := optionalString(args(req), "instance_type") - spot := optionalBool(args(req), "spot") - // If checking a specific instance type, use the targeted API. if instanceType != "" { available, err := client.InstanceAvailability.GetInstanceTypeAvailability(ctx, instanceType, spot, location) @@ -142,13 +159,16 @@ func (s *Server) handleCheckAvailability(ctx context.Context, req mcp.CallToolRe //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleListImages(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + instanceType, err := optionalString(args(req), "instance_type") + if err != nil { + return toolErrorResult(err), nil + } + client, err := s.verdaClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } - instanceType := optionalString(args(req), "instance_type") - var images any if instanceType != "" { diff --git a/internal/verda-cli/cmd/mcp/tools_ssh.go b/internal/verda-cli/cmd/mcp/tools_ssh.go index dc32e35..5668beb 100644 --- a/internal/verda-cli/cmd/mcp/tools_ssh.go +++ b/internal/verda-cli/cmd/mcp/tools_ssh.go @@ -54,6 +54,11 @@ func (s *Server) registerSSHTools() { //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleListSSHKeys(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + search, err := optionalString(args(req), "search") + if err != nil { + return toolErrorResult(err), nil + } + client, err := s.verdaClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -64,7 +69,7 @@ func (s *Server) handleListSSHKeys(ctx context.Context, req mcp.CallToolRequest) return mcp.NewToolResultError(err.Error()), nil } - if search := optionalString(args(req), "search"); search != "" { + if search != "" { lower := strings.ToLower(search) filtered := keys[:0] for i := range keys { @@ -87,11 +92,11 @@ func (s *Server) handleAddSSHKey(ctx context.Context, req mcp.CallToolRequest) ( name, err := requiredString(args(req), "name") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } publicKey, err := requiredString(args(req), "public_key") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } key, err := client.SSHKeys.AddSSHKey(ctx, &verda.CreateSSHKeyRequest{ @@ -106,19 +111,23 @@ func (s *Server) handleAddSSHKey(ctx context.Context, req mcp.CallToolRequest) ( //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleGetSSHCommand(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - if _, err := s.verdaClient(); err != nil { - return mcp.NewToolResultError(err.Error()), nil + a := args(req) + idOrHostname, err := requiredString(a, "id_or_hostname") + if err != nil { + return toolErrorResult(err), nil } - - idOrHostname, err := requiredString(args(req), "id_or_hostname") + user, err := optionalString(a, "user") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } - user := optionalString(args(req), "user") + keyPath, err := optionalString(a, "key_path") + if err != nil { + return toolErrorResult(err), nil + } + if user == "" { user = "root" } - keyPath := optionalString(args(req), "key_path") // Try to resolve the instance to get the IP. inst, err := s.resolveInstance(ctx, idOrHostname) diff --git a/internal/verda-cli/cmd/mcp/tools_test.go b/internal/verda-cli/cmd/mcp/tools_test.go new file mode 100644 index 0000000..f4ca3c5 --- /dev/null +++ b/internal/verda-cli/cmd/mcp/tools_test.go @@ -0,0 +1,461 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + "github.com/verda-cloud/verda-cli/tests/contract/mockapi" +) + +// newMockBackedServer returns an MCP Server whose Verda client talks to the +// contract-suite mock API. State is per-test via mockapi.Server. +func newMockBackedServer(t *testing.T) (*Server, *mockapi.Server) { + t.Helper() + mock := mockapi.New() + t.Cleanup(mock.Close) + + client, err := verda.NewClient( + verda.WithBaseURL(mock.URL()), + verda.WithClientID("test-id"), + verda.WithClientSecret("test-secret"), + ) + if err != nil { + t.Fatalf("creating client: %v", err) + } + return NewServer(client), mock +} + +func callReq(name string, arguments map[string]any) mcp.CallToolRequest { + var r mcp.CallToolRequest + r.Params.Name = name + r.Params.Arguments = arguments + return r +} + +// parseSuccess parses a non-error JSON tool result into a map. +func parseSuccess(t *testing.T, res *mcp.CallToolResult) map[string]any { + t.Helper() + if res.IsError { + t.Fatalf("expected success, got error: %s", resultText(t, res)) + } + var out map[string]any + if err := json.Unmarshal([]byte(resultText(t, res)), &out); err != nil { + t.Fatalf("result is not a JSON object: %v", err) + } + return out +} + +// --- Confirm gates (review NEW-4): destructive/billing tools require +// confirm: true and report the CLI's CONFIRMATION_REQUIRED contract code. + +func TestCreateVolumeConfirmGate(t *testing.T) { + t.Parallel() + s, mock := newMockBackedServer(t) + + volArgs := func(confirm any) map[string]any { + a := map[string]any{"name": "test-vol", "size_gb": float64(100)} + if confirm != nil { + a["confirm"] = confirm + } + return a + } + + res, err := s.handleCreateVolume(context.Background(), callReq("create_volume", volArgs(nil))) + if err != nil { + t.Fatalf("handler error: %v", err) + } + assertToolErrorCode(t, res, "CONFIRMATION_REQUIRED") + if mock.VolumeCount() != 0 { + t.Fatalf("volume created without confirm; mock has %d volumes", mock.VolumeCount()) + } + + res, err = s.handleCreateVolume(context.Background(), callReq("create_volume", volArgs(false))) + if err != nil { + t.Fatalf("handler error: %v", err) + } + assertToolErrorCode(t, res, "CONFIRMATION_REQUIRED") + + res, err = s.handleCreateVolume(context.Background(), callReq("create_volume", volArgs(true))) + if err != nil { + t.Fatalf("handler error: %v", err) + } + out := parseSuccess(t, res) + if out["id"] == "" || out["size_gb"] != float64(100) { + t.Errorf("unexpected create result: %v", out) + } + if mock.VolumeCount() != 1 { + t.Fatalf("with confirm the volume should exist; mock has %d", mock.VolumeCount()) + } +} + +func TestCreateVMConfirmGate(t *testing.T) { + t.Parallel() + s, mock := newMockBackedServer(t) + key := mock.SeedSSHKey("deploy") + + vmArgs := func(confirm any) map[string]any { + a := map[string]any{ + "instance_type": mockapi.TypeCPU, + "image": "ubuntu-24.04", + "hostname": "mcp-test", + "location": verda.LocationFIN01, + "ssh_key_ids": []any{key.ID}, + "wait": false, + } + if confirm != nil { + a["confirm"] = confirm + } + return a + } + + res, err := s.handleCreateVM(context.Background(), callReq("create_vm", vmArgs(nil))) + if err != nil { + t.Fatalf("handler error: %v", err) + } + assertToolErrorCode(t, res, "CONFIRMATION_REQUIRED") + // No instance may exist or be billed without confirmation: the only GET + // /instances/{id} traffic allowed is zero. + if got := mock.InstanceGetCount(); got != 0 { + t.Fatalf("unexpected instance polling without confirm: %d GETs", got) + } + + res, err = s.handleCreateVM(context.Background(), callReq("create_vm", vmArgs(true))) + if err != nil { + t.Fatalf("handler error: %v", err) + } + out := parseSuccess(t, res) + inst, ok := out["instance"].(map[string]any) + if !ok || inst["id"] == "" { + t.Fatalf("expected created instance, got %v", out) + } + if ids, _ := inst["ssh_key_ids"].([]any); len(ids) != 1 || ids[0] != key.ID { + t.Errorf("ssh_key_ids = %v, want seeded key only", inst["ssh_key_ids"]) + } +} + +func TestVMActionConfirmGate(t *testing.T) { + t.Parallel() + + destructive := []string{"shutdown", "force_shutdown", "hibernate", "delete"} + for _, action := range destructive { + t.Run(action, func(t *testing.T) { + t.Parallel() + s, mock := newMockBackedServer(t) + inst := mock.SeedInstance("gate-test", mockapi.TypeCPU, mockapi.CPUOnDemandTotal) + + res, err := s.handleVMAction(context.Background(), callReq("vm_action", map[string]any{ + "id": inst.ID, + "action": action, + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + assertToolErrorCode(t, res, "CONFIRMATION_REQUIRED") + + res, err = s.handleVMAction(context.Background(), callReq("vm_action", map[string]any{ + "id": inst.ID, + "action": action, + "confirm": true, + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + out := parseSuccess(t, res) + if out["status"] != "accepted" { + t.Errorf("status = %v, want accepted", out["status"]) + } + }) + } + + // start is not destructive and must work without confirm. + s, mock := newMockBackedServer(t) + inst := mock.SeedInstance("start-test", mockapi.TypeCPU, mockapi.CPUOnDemandTotal) + res, err := s.handleVMAction(context.Background(), callReq("vm_action", map[string]any{ + "id": inst.ID, + "action": "start", + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if out := parseSuccess(t, res); out["status"] != "accepted" { + t.Errorf("status = %v, want accepted", out["status"]) + } +} + +// --- Honest action semantics (review NEW-5): default reports 'accepted'; +// wait: true polls to the expected status and reports 'completed', surfacing +// failed transitions as tool errors. + +func TestVMActionWaitCompletes(t *testing.T) { + t.Parallel() + s, mock := newMockBackedServer(t) + inst := mock.SeedInstance("wait-test", mockapi.TypeCPU, mockapi.CPUOnDemandTotal) + + res, err := s.handleVMAction(context.Background(), callReq("vm_action", map[string]any{ + "id": inst.ID, + "action": "shutdown", + "confirm": true, + "wait": true, + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + out := parseSuccess(t, res) + if out["status"] != "completed" { + t.Errorf("status = %v, want completed", out) + } + if out["instance_status"] != verda.StatusOffline { + t.Errorf("instance_status = %v, want offline", out["instance_status"]) + } + if got := mock.InstanceGetCount(); got == 0 { + t.Error("wait=true must poll the instance status (0 GET /instances/{id})") + } +} + +func TestVMActionWaitFailedTransitionIsError(t *testing.T) { + t.Parallel() + + // The action call succeeds but the instance lands in "error" — the tool + // must surface a tool error, not claim completion. + mux := http.NewServeMux() + mux.HandleFunc("POST /oauth2/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"access_token": "t", "token_type": "Bearer"}) + }) + mux.HandleFunc("PUT /instances", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusAccepted) + _ = json.NewEncoder(w).Encode([]verda.InstanceActionResult{ + {Action: "shutdown", InstanceID: "inst-1", Status: "completed"}, + }) + }) + mux.HandleFunc("GET /instances/inst-1", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(verda.Instance{ID: "inst-1", Status: verda.StatusError}) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + client, err := verda.NewClient( + verda.WithBaseURL(srv.URL), + verda.WithClientID("test-id"), + verda.WithClientSecret("test-secret"), + ) + if err != nil { + t.Fatalf("creating client: %v", err) + } + s := NewServer(client) + + res, err := s.handleVMAction(context.Background(), callReq("vm_action", map[string]any{ + "id": "inst-1", + "action": "shutdown", + "confirm": true, + "wait": true, + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + if !res.IsError { + t.Fatalf("wait on failed transition must be a tool error, got: %s", resultText(t, res)) + } + if text := resultText(t, res); !strings.Contains(text, "error") { + t.Errorf("error should mention the failed status, got: %s", text) + } +} + +// --- Strict argument validation (review NEW-6) --- +// Table-driven: mismatched JSON types must be rejected with VALIDATION_ERROR +// (mcp-go performs no schema validation), enums against their allowed sets, +// missing required arguments with MISSING_REQUIRED_FLAGS. + +func TestStrictArgValidation(t *testing.T) { + t.Parallel() + s, mock := newMockBackedServer(t) + inst := mock.SeedInstance("args-test", mockapi.TypeCPU, mockapi.CPUOnDemandTotal) + key := mock.SeedSSHKey("deploy") + + tests := []struct { + name string + call func(context.Context, mcp.CallToolRequest) (*mcp.CallToolResult, error) + req map[string]any + code string + }{ + { + name: "create_vm string os_volume_size_gb not coerced to 50GB default", + call: s.handleCreateVM, + req: map[string]any{"instance_type": mockapi.TypeCPU, "image": "img", "hostname": "h", "os_volume_size_gb": "500"}, + code: "VALIDATION_ERROR", + }, + { + name: "create_vm fractional os_volume_size_gb", + call: s.handleCreateVM, + req: map[string]any{"instance_type": mockapi.TypeCPU, "image": "img", "hostname": "h", "os_volume_size_gb": 50.5}, + code: "VALIDATION_ERROR", + }, + { + name: "create_vm ssh_key_ids as string must not fan out to all keys", + call: s.handleCreateVM, + req: map[string]any{"instance_type": mockapi.TypeCPU, "image": "img", "hostname": "h", "ssh_key_ids": key.ID}, + code: "VALIDATION_ERROR", + }, + { + name: "create_vm ssh_key_ids with non-string element", + call: s.handleCreateVM, + req: map[string]any{"instance_type": mockapi.TypeCPU, "image": "img", "hostname": "h", "ssh_key_ids": []any{key.ID, 42.0}}, + code: "VALIDATION_ERROR", + }, + { + name: "create_vm unknown storage_type", + call: s.handleCreateVM, + req: map[string]any{"instance_type": mockapi.TypeCPU, "image": "img", "hostname": "h", "storage_type": "SCSI"}, + code: "VALIDATION_ERROR", + }, + { + name: "create_vm wait as string", + call: s.handleCreateVM, + req: map[string]any{"instance_type": mockapi.TypeCPU, "image": "img", "hostname": "h", "wait": "yes"}, + code: "VALIDATION_ERROR", + }, + { + name: "create_vm missing hostname", + call: s.handleCreateVM, + req: map[string]any{"instance_type": mockapi.TypeCPU, "image": "img"}, + code: "MISSING_REQUIRED_FLAGS", + }, + { + name: "vm_action unknown action enum", + call: s.handleVMAction, + req: map[string]any{"id": inst.ID, "action": "reboot"}, + code: "VALIDATION_ERROR", + }, + { + name: "vm_action confirm as string", + call: s.handleVMAction, + req: map[string]any{"id": inst.ID, "action": "shutdown", "confirm": "true"}, + code: "VALIDATION_ERROR", + }, + { + name: "describe_vm id as number", + call: s.handleDescribeVM, + req: map[string]any{"id": 123.0}, + code: "VALIDATION_ERROR", + }, + { + name: "create_volume size_gb as string (was: coerced to 0 then fail on positivity)", + call: s.handleCreateVolume, + req: map[string]any{"name": "v", "size_gb": "500"}, + code: "VALIDATION_ERROR", + }, + { + name: "create_volume zero size", + call: s.handleCreateVolume, + req: map[string]any{"name": "v", "size_gb": 0.0, "confirm": true}, + code: "VALIDATION_ERROR", + }, + { + name: "create_volume unknown type enum", + call: s.handleCreateVolume, + req: map[string]any{"name": "v", "size_gb": 100.0, "type": "SCSI", "confirm": true}, + code: "VALIDATION_ERROR", + }, + { + name: "estimate_cost os_volume_gb as string", + call: s.handleEstimateCost, + req: map[string]any{"instance_type": mockapi.TypeCPU, "os_volume_gb": "100"}, + code: "VALIDATION_ERROR", + }, + { + name: "list_vms status as number", + call: s.handleListVMs, + req: map[string]any{"status": 1.0}, + code: "VALIDATION_ERROR", + }, + { + name: "vm_availability gpu_only as string", + call: s.handleVMAvailability, + req: map[string]any{"gpu_only": "yes"}, + code: "VALIDATION_ERROR", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + res, err := tt.call(context.Background(), callReq("test", tt.req)) + if err != nil { + t.Fatalf("handler error: %v", err) + } + assertToolErrorCode(t, res, tt.code) + }) + } +} + +// --- estimate_cost (item 5): unknown storage_type must error, not price $0. + +func TestEstimateCostUnknownStorageType(t *testing.T) { + t.Parallel() + s, _ := newMockBackedServer(t) + + res, err := s.handleEstimateCost(context.Background(), callReq("estimate_cost", map[string]any{ + "instance_type": mockapi.TypeCPU, + "storage_gb": 100.0, + "storage_type": "HDD_Shared_Whatever", + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + assertToolErrorCode(t, res, "VALIDATION_ERROR") + if text := resultText(t, res); !strings.Contains(text, "HDD") || !strings.Contains(text, "NVMe") { + t.Errorf("error should list valid volume types, got: %s", text) + } +} + +func TestEstimateCostSuccess(t *testing.T) { + t.Parallel() + s, _ := newMockBackedServer(t) + + res, err := s.handleEstimateCost(context.Background(), callReq("estimate_cost", map[string]any{ + "instance_type": mockapi.TypeCPU, + "os_volume_gb": 100.0, + "storage_gb": 200.0, + "storage_type": "HDD", + })) + if err != nil { + t.Fatalf("handler error: %v", err) + } + out := parseSuccess(t, res) + est, ok := out["estimate"].(map[string]any) + if !ok { + t.Fatalf("missing estimate: %v", out) + } + breakdown, _ := est["breakdown"].(map[string]any) + if got := breakdown["instance"]; got != mockapi.CPUOnDemandTotal { + t.Errorf("instance hourly = %v, want %v (catalog TOTAL)", got, mockapi.CPUOnDemandTotal) + } + if osVol, _ := breakdown["os_volume"].(float64); osVol <= 0 { + t.Errorf("os_volume hourly = %v, want > 0 for 100GB NVMe", osVol) + } + if st, _ := breakdown["storage"].(float64); st <= 0 { + t.Errorf("storage hourly = %v, want > 0 for 200GB HDD", st) + } +} diff --git a/internal/verda-cli/cmd/mcp/tools_vm.go b/internal/verda-cli/cmd/mcp/tools_vm.go index e004b7b..304bfa5 100644 --- a/internal/verda-cli/cmd/mcp/tools_vm.go +++ b/internal/verda-cli/cmd/mcp/tools_vm.go @@ -24,8 +24,12 @@ import ( "github.com/mark3labs/mcp-go/mcp" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) +const confirmParamHint = "REQUIRED for any action that creates billed or irreversible changes: set true to confirm, after showing the user the exact target and cost. Without it the tool fails with CONFIRMATION_REQUIRED (mirrors --yes in the CLI agent contract)." + func (s *Server) registerVMTools() { s.mcpServer.AddTool( mcp.NewTool("list_vms", @@ -45,10 +49,11 @@ func (s *Server) registerVMTools() { s.mcpServer.AddTool( mcp.NewTool("create_vm", - mcp.WithDescription("Create a new Verda Cloud VM instance. Required: instance_type, image, hostname. Optional: ssh_key_ids (if omitted, all account keys are attached), os_volume_size_gb (default 50), location (auto-picked if omitted). Use vm_availability to check stock and list_images for image options. Always show cost estimate and get user confirmation first."), + mcp.WithDescription("Create a new Verda Cloud VM instance (starts billing). REQUIRES confirm: true — estimate costs first (estimate_cost), show the user the type/location/price, and only then call with confirm: true; without it the tool fails with CONFIRMATION_REQUIRED. Required: instance_type, image, hostname, confirm. Optional: ssh_key_ids (if omitted, all account keys are attached), os_volume_size_gb (default 50), location (auto-picked if omitted). Use vm_availability to check stock and list_images for image options."), mcp.WithString("instance_type", mcp.Required(), mcp.Description("Instance type, e.g. 1V100.6V or CPU.4V.16G")), mcp.WithString("image", mcp.Required(), mcp.Description("OS image slug, e.g. ubuntu-24.04-cuda-12.8-open-docker")), mcp.WithString("hostname", mcp.Required(), mcp.Description("Hostname for the new VM")), + mcp.WithBoolean("confirm", mcp.Required(), mcp.Description(confirmParamHint)), mcp.WithString("location", mcp.Description("Location code. If omitted, automatically picks a location that has stock for the requested instance type.")), mcp.WithString("description", mcp.Description("Human-readable description")), mcp.WithNumber("os_volume_size_gb", mcp.Description("OS volume size in GiB (default 50)")), @@ -57,7 +62,7 @@ func (s *Server) registerVMTools() { mcp.WithBoolean("spot", mcp.Description("Request a spot instance")), mcp.WithNumber("storage_size_gb", mcp.Description("Additional storage size in GiB")), mcp.WithString("storage_type", mcp.Description("Storage type: NVMe or HDD (default NVMe)")), - mcp.WithBoolean("wait", mcp.Description("Wait for the VM to be ready (default true)")), + mcp.WithBoolean("wait", mcp.Description("Wait for the VM to be in 'running' status (default true)")), ), s.handleCreateVM, ) @@ -76,10 +81,11 @@ func (s *Server) registerVMTools() { s.mcpServer.AddTool( mcp.NewTool("vm_action", - mcp.WithDescription("Perform an action on a VM: start, shutdown, force_shutdown, hibernate, or delete. IMPORTANT: Always confirm with the user before destructive actions."), + mcp.WithDescription("Perform an action on a VM: start, shutdown, force_shutdown, hibernate, or delete. Destructive actions (shutdown, force_shutdown, hibernate, delete) REQUIRE confirm: true — confirm with the user first; without it the tool fails with CONFIRMATION_REQUIRED. Returns status 'accepted' once the API has accepted the action; pass wait: true to poll until the instance reaches its expected status and report status 'completed' (a failed transition, e.g. the instance entering 'error', is a tool error). delete is not polled and always returns 'accepted'."), mcp.WithString("id", mcp.Required(), mcp.Description("Instance ID")), mcp.WithString("action", mcp.Required(), mcp.Description("Action: start, shutdown, force_shutdown, hibernate, delete")), - mcp.WithBoolean("wait", mcp.Description("Wait for the action to complete (default true)")), + mcp.WithBoolean("confirm", mcp.Description("Set true to confirm destructive actions (shutdown, force_shutdown, hibernate, delete). Not needed for start.")), + mcp.WithBoolean("wait", mcp.Description("Poll until the instance reaches the action's expected status (default false: return 'accepted' immediately)")), ), s.handleVMAction, ) @@ -96,19 +102,35 @@ type availableInstance struct { SpotPrice float64 `json:"spot_price,omitempty"` } -//nolint:gocritic // hugeParam: handler signature defined by mcp-go. +//nolint:gocritic,gocyclo // hugeParam + complexity from strict per-argument type checks. func (s *Server) handleVMAvailability(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + a := args(req) + location, err := optionalString(a, "location") + if err != nil { + return toolErrorResult(err), nil + } + instanceType, err := optionalString(a, "instance_type") + if err != nil { + return toolErrorResult(err), nil + } + gpuOnly, err := optionalBool(a, "gpu_only") + if err != nil { + return toolErrorResult(err), nil + } + cpuOnly, err := optionalBool(a, "cpu_only") + if err != nil { + return toolErrorResult(err), nil + } + spot, err := optionalBool(a, "spot") + if err != nil { + return toolErrorResult(err), nil + } + client, err := s.verdaClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } - location := optionalString(args(req), "location") - instanceType := optionalString(args(req), "instance_type") - gpuOnly := optionalBool(args(req), "gpu_only") - cpuOnly := optionalBool(args(req), "cpu_only") - spot := optionalBool(args(req), "spot") - // Fetch instance types with pricing. types, err := client.InstanceTypes.Get(ctx, "usd") if err != nil { @@ -183,12 +205,16 @@ func (s *Server) handleVMAvailability(ctx context.Context, req mcp.CallToolReque //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleListVMs(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + status, err := optionalString(args(req), "status") + if err != nil { + return toolErrorResult(err), nil + } + client, err := s.verdaClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } - status := optionalString(args(req), "status") instances, err := client.Instances.Get(ctx, status) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -198,15 +224,16 @@ func (s *Server) handleListVMs(ctx context.Context, req mcp.CallToolRequest) (*m //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleDescribeVM(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := s.verdaClient() + id, err := requiredString(args(req), "id") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } - id, err := requiredString(args(req), "id") + client, err := s.verdaClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } + inst, err := client.Instances.GetByID(ctx, id) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -216,40 +243,88 @@ func (s *Server) handleDescribeVM(ctx context.Context, req mcp.CallToolRequest) //nolint:gocritic,gocyclo // hugeParam + complexity from auto-resolving location/SSH keys. func (s *Server) handleCreateVM(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := s.verdaClient() + a := args(req) + + instanceType, err := requiredString(a, "instance_type") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } - - instanceType, err := requiredString(args(req), "instance_type") + image, err := requiredString(a, "image") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } - image, err := requiredString(args(req), "image") + hostname, err := requiredString(a, "hostname") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil + } + location, err := optionalString(a, "location") + if err != nil { + return toolErrorResult(err), nil + } + description, err := optionalString(a, "description") + if err != nil { + return toolErrorResult(err), nil + } + scriptID, err := optionalString(a, "startup_script_id") + if err != nil { + return toolErrorResult(err), nil } - hostname, err := requiredString(args(req), "hostname") + osVolumeSize, err := optionalInt(a, "os_volume_size_gb") + if err != nil { + return toolErrorResult(err), nil + } + storageSize, err := optionalInt(a, "storage_size_gb") + if err != nil { + return toolErrorResult(err), nil + } + storageType, err := optionalEnum(a, "storage_type", verda.VolumeTypeNVMe, verda.VolumeTypeHDD) + if err != nil { + return toolErrorResult(err), nil + } + spot, err := optionalBool(a, "spot") + if err != nil { + return toolErrorResult(err), nil + } + wait, err := optionalBool(a, "wait") + if err != nil { + return toolErrorResult(err), nil + } + if _, present := a["wait"]; !present { + wait = true + } + confirm, err := optionalBool(a, "confirm") + if err != nil { + return toolErrorResult(err), nil + } + sshKeyInputs, err := optionalStringSlice(a, "ssh_key_ids") + if err != nil { + return toolErrorResult(err), nil + } + + // Billing action: explicit confirmation required, mirroring --yes in the + // CLI agent contract. Gated before any API call. + if !confirm { + return toolErrorResult(confirmationRequiredError("create_vm")), nil + } + + client, err := s.verdaClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } - location := optionalString(args(req), "location") if location == "" { // Auto-pick a location that has stock for this instance type. - loc, err := s.findAvailableLocation(ctx, client, instanceType, optionalBool(args(req), "spot")) + loc, err := s.findAvailableLocation(ctx, client, instanceType, spot) if err != nil { return mcp.NewToolResultError(err.Error()), nil } location = loc } - description := optionalString(args(req), "description") if description == "" { description = hostname } // Resolve SSH key names to IDs, or use the most recent key as default. - sshKeyInputs := optionalStringSlice(args(req), "ssh_key_ids") sshKeyIDs, err := s.resolveSSHKeyIDs(ctx, client, sshKeyInputs) if err != nil { return mcp.NewToolResultError(err.Error()), nil @@ -275,15 +350,14 @@ func (s *Server) handleCreateVM(ctx context.Context, req mcp.CallToolRequest) (* Description: description, LocationCode: location, SSHKeyIDs: sshKeyIDs, - IsSpot: optionalBool(args(req), "spot"), + IsSpot: spot, } - if scriptID := optionalString(args(req), "startup_script_id"); scriptID != "" { + if scriptID != "" { createReq.StartupScriptID = &scriptID } - osVolumeSize := optionalInt(args(req), "os_volume_size_gb") - if osVolumeSize <= 0 { + if osVolumeSize == 0 { osVolumeSize = 50 } createReq.OSVolume = &verda.OSVolumeCreateRequest{ @@ -291,8 +365,7 @@ func (s *Server) handleCreateVM(ctx context.Context, req mcp.CallToolRequest) (* Size: osVolumeSize, } - if storageSize := optionalInt(args(req), "storage_size_gb"); storageSize > 0 { - storageType := optionalString(args(req), "storage_type") + if storageSize > 0 { if storageType == "" { storageType = verda.VolumeTypeNVMe } @@ -315,14 +388,6 @@ func (s *Server) handleCreateVM(ctx context.Context, req mcp.CallToolRequest) (* return mcp.NewToolResultError(err.Error()), nil } - // Wait for VM to be ready if requested (default true). - wait := true - if v, ok := args(req)["wait"]; ok { - if b, ok := v.(bool); ok { - wait = b - } - } - if wait { inst, err = s.pollInstance(ctx, inst.ID, verda.StatusRunning, 5*time.Minute) if err != nil { @@ -342,45 +407,102 @@ func (s *Server) handleCreateVM(ctx context.Context, req mcp.CallToolRequest) (* return jsonResult(result) } +// vmAction describes a supported vm_action operation. +type vmAction struct { + expectStatus string // polled target when wait=true; empty = not polled + destructive bool // requires confirm=true + exec func(ctx context.Context, client *verda.Client, id string) error +} + +// vmActions mirrors the CLI's vm action table (cmd/vm/action.go): ExpectStatus +// and the destructive set (shutdown/force_shutdown/hibernate carry warnings +// there; delete is special-cased) must stay in sync. +var vmActions = map[string]vmAction{ + verda.ActionStart: { + expectStatus: verda.StatusRunning, + exec: func(ctx context.Context, c *verda.Client, id string) error { return c.Instances.Start(ctx, id) }, + }, + verda.ActionShutdown: { + expectStatus: verda.StatusOffline, + destructive: true, + exec: func(ctx context.Context, c *verda.Client, id string) error { return c.Instances.Shutdown(ctx, id) }, + }, + verda.ActionForceShutdown: { + expectStatus: verda.StatusOffline, + destructive: true, + exec: func(ctx context.Context, c *verda.Client, id string) error { return c.Instances.ForceShutdown(ctx, id) }, + }, + verda.ActionHibernate: { + expectStatus: verda.StatusOffline, + destructive: true, + exec: func(ctx context.Context, c *verda.Client, id string) error { return c.Instances.Hibernate(ctx, id) }, + }, + verda.ActionDelete: { + destructive: true, + exec: func(ctx context.Context, c *verda.Client, id string) error { + return c.Instances.Delete(ctx, []string{id}, nil, false) + }, + }, +} + +// vmActionNames returns the sorted action names for error messages. +func vmActionNames() string { + names := make([]string, 0, len(vmActions)) + for name := range vmActions { + names = append(names, name) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleVMAction(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := s.verdaClient() + a := args(req) + + id, err := requiredString(a, "id") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } - - id, err := requiredString(args(req), "id") + actionName, err := requiredString(a, "action") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } - action, err := requiredString(args(req), "action") + action, ok := vmActions[actionName] + if !ok { + return toolErrorResult(invalidArgError("action", fmt.Sprintf("invalid value %q (valid: %s)", actionName, vmActionNames()))), nil + } + wait, err := optionalBool(a, "wait") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil + } + confirm, err := optionalBool(a, "confirm") + if err != nil { + return toolErrorResult(err), nil } - switch action { - case "start": - err = client.Instances.Start(ctx, id) - case "shutdown": - err = client.Instances.Shutdown(ctx, id) - case "force_shutdown": - err = client.Instances.ForceShutdown(ctx, id) - case "hibernate": - err = client.Instances.Hibernate(ctx, id) - case "delete": - err = client.Instances.Delete(ctx, []string{id}, nil, false) - default: - return mcp.NewToolResultError(fmt.Sprintf("unknown action %q: use start, shutdown, force_shutdown, hibernate, or delete", action)), nil + if action.destructive && !confirm { + return toolErrorResult(confirmationRequiredError(actionName)), nil } + client, err := s.verdaClient() if err != nil { return mcp.NewToolResultError(err.Error()), nil } - result := map[string]string{ - "id": id, - "action": action, - "status": "completed", + if err := action.exec(ctx, client, id); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + // Truthful default: the API accepted the action; nothing has completed yet. + result := map[string]any{"id": id, "action": actionName, "status": "accepted"} + if wait && action.expectStatus != "" { + inst, err := cmdutil.PollInstanceStatus(ctx, nil, client, id, + cmdutil.WaitOptions{Wait: true, Timeout: 5 * time.Minute}, action.expectStatus) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("action %q was accepted but the wait failed: %v", actionName, err)), nil + } + result["status"] = "completed" + result["instance_status"] = inst.Status } return jsonResult(result) } diff --git a/internal/verda-cli/cmd/mcp/tools_volume.go b/internal/verda-cli/cmd/mcp/tools_volume.go index 29844d7..6cd0b1d 100644 --- a/internal/verda-cli/cmd/mcp/tools_volume.go +++ b/internal/verda-cli/cmd/mcp/tools_volume.go @@ -31,9 +31,10 @@ func (s *Server) registerVolumeTools() { s.mcpServer.AddTool( mcp.NewTool("create_volume", - mcp.WithDescription("Create a new block storage volume"), + mcp.WithDescription("Create a new block storage volume (starts billing). REQUIRES confirm: true — show the user the name/size/location first; without it the tool fails with CONFIRMATION_REQUIRED (mirrors --yes in the CLI agent contract)."), mcp.WithString("name", mcp.Required(), mcp.Description("Volume name")), mcp.WithNumber("size_gb", mcp.Required(), mcp.Description("Volume size in GiB")), + mcp.WithBoolean("confirm", mcp.Required(), mcp.Description(confirmParamHint)), mcp.WithString("type", mcp.Description("Volume type: NVMe or HDD (default NVMe)")), mcp.WithString("location", mcp.Description("Location code (default FIN-01)")), ), @@ -64,27 +65,46 @@ func (s *Server) handleListVolumes(ctx context.Context, _ mcp.CallToolRequest) ( //nolint:gocritic // hugeParam: handler signature defined by mcp-go. func (s *Server) handleCreateVolume(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { - client, err := s.verdaClient() + a := args(req) + + name, err := requiredString(a, "name") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } - - name, err := requiredString(args(req), "name") + sizeGB, err := requiredInt(a, "size_gb") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return toolErrorResult(err), nil } - - sizeGB := optionalInt(args(req), "size_gb") if sizeGB <= 0 { - return mcp.NewToolResultError("size_gb must be a positive integer"), nil + return toolErrorResult(invalidArgError("size_gb", "must be a positive integer")), nil + } + volType, err := optionalEnum(a, "type", verda.VolumeTypeNVMe, verda.VolumeTypeHDD) + if err != nil { + return toolErrorResult(err), nil + } + location, err := optionalString(a, "location") + if err != nil { + return toolErrorResult(err), nil + } + confirm, err := optionalBool(a, "confirm") + if err != nil { + return toolErrorResult(err), nil + } + + // Billing action: explicit confirmation required, mirroring --yes in the + // CLI agent contract. Gated before any API call. + if !confirm { + return toolErrorResult(confirmationRequiredError("create_volume")), nil + } + + client, err := s.verdaClient() + if err != nil { + return mcp.NewToolResultError(err.Error()), nil } - volType := optionalString(args(req), "type") if volType == "" { volType = verda.VolumeTypeNVMe } - - location := optionalString(args(req), "location") if location == "" { location = verda.LocationFIN01 } diff --git a/internal/verda-cli/cmd/objectstorage/CLAUDE.md b/internal/verda-cli/cmd/objectstorage/CLAUDE.md index cb06102..ac49a5e 100644 --- a/internal/verda-cli/cmd/objectstorage/CLAUDE.md +++ b/internal/verda-cli/cmd/objectstorage/CLAUDE.md @@ -40,6 +40,10 @@ Package-level `clientBuilder` in `helper.go` is swapped in tests via the `withFa - `rb`, `rm`: require `prompter.Confirm()` unless `--yes`. In agent mode without `--yes`, return `cmdutil.NewConfirmationRequiredError`. - `mv`, `cp`, `sync`: NO prompt (matches `aws s3`; the user committed by typing the verb). - `sync --delete`: also no prompt (AWS convention -- `--delete` is opt-in already). +- Prompts run on `cmd.Context()` (never the `--timeout`-bounded listing ctx); the delete/abort phase after the prompt re-bounds a fresh ctx so think-time can't drain it (same two-ctx split as sshkey/startupscript delete). + +### Context discipline (cp/mv/sync) +Bulk transfers are data-plane: they run on `cmd.Context()` (Ctrl+C), never the per-request `--timeout` — see the comment in `cp.go runCp`. Only enumeration (ListObjectsV2 via `enumerateS3`/`listAllKeys`) re-bounds a `WithTimeout` ctx around the listing. ### Batching and pagination - `rb --force` and `rm --recursive` use `DeleteObjects` in batches of `maxDeleteBatch = 1000` (defined in `rb.go`). diff --git a/internal/verda-cli/cmd/objectstorage/abortuploads.go b/internal/verda-cli/cmd/objectstorage/abortuploads.go index af197c4..ef2c7bb 100644 --- a/internal/verda-cli/cmd/objectstorage/abortuploads.go +++ b/internal/verda-cli/cmd/objectstorage/abortuploads.go @@ -138,7 +138,9 @@ func runAbortUploads(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO } if !opts.Yes && !f.AgentMode() { - confirmed, confirmErr := confirmAbort(ctx, f, ioStreams, uri.Bucket, targets) + // cmd.Context(): prompt think-time is not --timeout-bounded and must + // not drain the abort budget below. + confirmed, confirmErr := confirmAbort(cmd.Context(), f, ioStreams, uri.Bucket, targets) if confirmErr != nil { if cmdutil.IsPromptCancel(confirmErr) { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") @@ -152,7 +154,10 @@ func runAbortUploads(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO } } - return executeAbort(ctx, f, ioStreams, client, uri.Bucket, targets) + // Fresh bound for the aborts (two-ctx split: listing above, mutation here). + execCtx, execCancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer execCancel() + return executeAbort(execCtx, f, ioStreams, client, uri.Bucket, targets) } // filterAbortTargets narrows the listed uploads to those matching --key (exact) diff --git a/internal/verda-cli/cmd/objectstorage/browse.go b/internal/verda-cli/cmd/objectstorage/browse.go index eb0f371..6d9f4d9 100644 --- a/internal/verda-cli/cmd/objectstorage/browse.go +++ b/internal/verda-cli/cmd/objectstorage/browse.go @@ -87,7 +87,7 @@ func runLsBrowser(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt // browseBuckets shows the bucket list. Returns (chosen bucket, exit, err); // exit is true when the user chose Exit / Ctrl+C / Esc at the root. func browseBuckets(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API) (bucket string, exit bool, err error) { - out, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading buckets...", func() (*s3.ListBucketsOutput, error) { + out, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading buckets...", func(ctx context.Context) (*s3.ListBucketsOutput, error) { return client.ListBuckets(ctx, &s3.ListBucketsInput{}) }) if err != nil { @@ -121,7 +121,7 @@ func browseBuckets(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOS // Returns again=false to leave the browser entirely; again=true to keep // looping (cur may have been mutated to drill in/out). func browseLevel(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, cur *URI) (bool, error) { - payload, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading...", func() (objectsPayload, error) { + payload, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading...", func(ctx context.Context) (objectsPayload, error) { return collectObjects(ctx, f, ioStreams, client, *cur, "/") }) if err != nil { @@ -397,7 +397,7 @@ func announceRename(ioStreams cmdutil.IOStreams, key, local string) { // browseInfo prints object metadata via HeadObject. func browseInfo(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, obj URI) error { - head, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading details...", func() (*s3.HeadObjectOutput, error) { + head, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading details...", func(ctx context.Context) (*s3.HeadObjectOutput, error) { return client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(obj.Bucket), Key: aws.String(obj.Key)}) }) if err != nil { diff --git a/internal/verda-cli/cmd/objectstorage/configure.go b/internal/verda-cli/cmd/objectstorage/configure.go index ffa3edc..e2b8987 100644 --- a/internal/verda-cli/cmd/objectstorage/configure.go +++ b/internal/verda-cli/cmd/objectstorage/configure.go @@ -86,7 +86,7 @@ func NewCmdConfigure(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comm if strings.TrimSpace(opts.AccessKey) == "" || strings.TrimSpace(opts.SecretKey) == "" { printConfigureIntro(ioStreams) flow := buildConfigureFlow(opts) - engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) + engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) if err := engine.Run(cmd.Context(), flow); err != nil { return err } diff --git a/internal/verda-cli/cmd/objectstorage/move_wizard.go b/internal/verda-cli/cmd/objectstorage/move_wizard.go index 4e0a8e6..e5045bc 100644 --- a/internal/verda-cli/cmd/objectstorage/move_wizard.go +++ b/internal/verda-cli/cmd/objectstorage/move_wizard.go @@ -44,7 +44,7 @@ type moveWizardState struct { } // runMoveWizard guides an interactive S3->S3 move/rename using the shared wizard -// engine (same progress bar + hint bar + exit-confirmation as `s3 configure`): +// engine (same progress bar + hint bar as `s3 configure`): // source bucket → source object → destination bucket (pick or create) → // destination key. A source fixed by an argument pre-sets and skips those steps. // After the engine collects the selections it previews + confirms, creates the @@ -66,7 +66,7 @@ func runMoveWizard(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOSt st := &moveWizardState{srcBucket: srcBucket, srcKey: srcKey} engine := wizard.NewEngine(f.Prompter(), f.Status(), - wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) + wizard.WithOutput(ioStreams.ErrOut)) if err := engine.Run(ctx, buildMoveFlow(f, client, st)); err != nil { return err } @@ -223,7 +223,7 @@ func moveStepDestKey(st *moveWizardState) wizard.Step { // bucketChoices lists buckets as wizard choices, optionally appending a trailing // "create new bucket" option (for destination selection). func bucketChoices(ctx context.Context, f cmdutil.Factory, client API, withCreate bool) ([]wizard.Choice, error) { - out, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading buckets...", func() (*s3.ListBucketsOutput, error) { + out, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading buckets...", func(ctx context.Context) (*s3.ListBucketsOutput, error) { return client.ListBuckets(ctx, &s3.ListBucketsInput{}) }) if err != nil { @@ -243,7 +243,7 @@ func bucketChoices(ctx context.Context, f cmdutil.Factory, client API, withCreat // objectChoices lists object keys in bucket (capped) as wizard choices. An empty // bucket is an error — there is nothing to move out of it. func objectChoices(ctx context.Context, f cmdutil.Factory, client API, bucket string) ([]wizard.Choice, error) { - res, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading objects...", func() (cappedKeys, error) { + res, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading objects...", func(ctx context.Context) (cappedKeys, error) { k, truncated, e := listKeysCapped(ctx, client, bucket, objectPickerCap) return cappedKeys{keys: k, truncated: truncated}, e }) @@ -306,7 +306,7 @@ func finalizeMove(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, io } if st.newDstBucket != "" { - if _, err := cmdutil.WithSpinner(ctx, f.Status(), "Creating bucket...", func() (*s3.CreateBucketOutput, error) { + if _, err := cmdutil.WithSpinner(ctx, f.Status(), "Creating bucket...", func(ctx context.Context) (*s3.CreateBucketOutput, error) { return client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(dstBucket)}) }); err != nil { return translateError(err) diff --git a/internal/verda-cli/cmd/objectstorage/mv.go b/internal/verda-cli/cmd/objectstorage/mv.go index a83af45..977c67d 100644 --- a/internal/verda-cli/cmd/objectstorage/mv.go +++ b/internal/verda-cli/cmd/objectstorage/mv.go @@ -109,8 +109,10 @@ func runMv(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o return cmdutil.UsageErrorf(cmd, "mv requires at least one s3:// URI") } - ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) - defer cancel() + // Mirrors cp.go runCp (see its comment): a move is transfer + completing + // delete per object — data-plane on cmd.Context() (Ctrl+C), never the + // per-request --timeout. Enumeration re-bounds inside the tree walks. + ctx := cmd.Context() switch dir { case dirUpload: @@ -274,7 +276,9 @@ func runDownloadMv(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, i } func downloadMoveTree(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, tr Transporter, src URI, dstDir string, opts *cpOptions, payload *cpPayload) error { - keys, err := listAllKeys(ctx, f, ioStreams, client, src) + listCtx, listCancel := context.WithTimeout(ctx, f.Options().Timeout) + keys, err := listAllKeys(listCtx, f, ioStreams, client, src) + listCancel() if err != nil { return err } @@ -361,7 +365,9 @@ func runCopyMv(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioStr } func s3MoveTree(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, src, dst URI, opts *cpOptions, payload *cpPayload) error { - keys, err := listAllKeys(ctx, f, ioStreams, client, src) + listCtx, listCancel := context.WithTimeout(ctx, f.Options().Timeout) + keys, err := listAllKeys(listCtx, f, ioStreams, client, src) + listCancel() if err != nil { return err } diff --git a/internal/verda-cli/cmd/objectstorage/picker.go b/internal/verda-cli/cmd/objectstorage/picker.go index 562181b..7794752 100644 --- a/internal/verda-cli/cmd/objectstorage/picker.go +++ b/internal/verda-cli/cmd/objectstorage/picker.go @@ -34,8 +34,11 @@ const objectPickerCap = 1000 // selectBucket lists buckets and prompts the user to pick one. Returns the // chosen bucket name, or ("", nil) on a clean cancel (Ctrl+C/Esc) or when no // buckets exist — callers treat an empty name as "nothing to do, exit cleanly". -func selectBucket(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API) (string, error) { - out, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading buckets...", func() (*s3.ListBucketsOutput, error) { +// +// ctx bounds the ListBuckets call; promptCtx (the command root ctx) carries +// the picker's think-time so --timeout never cancels a prompt mid-flow. +func selectBucket(ctx, promptCtx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API) (string, error) { + out, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading buckets...", func(ctx context.Context) (*s3.ListBucketsOutput, error) { return client.ListBuckets(ctx, &s3.ListBucketsInput{}) }) if err != nil { @@ -50,7 +53,7 @@ func selectBucket(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt for i := range out.Buckets { labels[i] = aws.ToString(out.Buckets[i].Name) } - idx, err := f.Prompter().Select(ctx, "Select bucket", labels, tui.WithShowHints(true)) + idx, err := f.Prompter().Select(promptCtx, "Select bucket", labels, tui.WithShowHints(true)) if err != nil { if cmdutil.IsPromptCancel(err) { return "", nil @@ -88,7 +91,7 @@ func resolveBucketArg(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I if err != nil { return "", err } - bucket, err := selectBucket(ctx, f, ioStreams, client) + bucket, err := selectBucket(ctx, cmd.Context(), f, ioStreams, client) if err != nil { return "", err } diff --git a/internal/verda-cli/cmd/objectstorage/picker_test.go b/internal/verda-cli/cmd/objectstorage/picker_test.go index 2b0ee37..2d1ba3d 100644 --- a/internal/verda-cli/cmd/objectstorage/picker_test.go +++ b/internal/verda-cli/cmd/objectstorage/picker_test.go @@ -34,7 +34,7 @@ func TestSelectBucket_PicksChosen(t *testing.T) { {Name: aws.String("beta")}, }} f := cmdutil.NewTestFactory(tuitest.New().AddSelect(1)) // choose 2nd - got, err := selectBucket(context.Background(), f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, fake) + got, err := selectBucket(context.Background(), context.Background(), f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, fake) if err != nil { t.Fatalf("selectBucket: %v", err) } @@ -48,7 +48,7 @@ func TestSelectBucket_EmptyReturnsBlank(t *testing.T) { fake := &fakeS3API{} f := cmdutil.NewTestFactory(tuitest.New()) errOut := &bytes.Buffer{} - got, err := selectBucket(context.Background(), f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: errOut}, fake) + got, err := selectBucket(context.Background(), context.Background(), f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: errOut}, fake) if err != nil { t.Fatalf("selectBucket: %v", err) } diff --git a/internal/verda-cli/cmd/objectstorage/rb.go b/internal/verda-cli/cmd/objectstorage/rb.go index e136ef2..108a243 100644 --- a/internal/verda-cli/cmd/objectstorage/rb.go +++ b/internal/verda-cli/cmd/objectstorage/rb.go @@ -104,9 +104,10 @@ func runRb(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o return err } - // Interactive confirmation (TTY path). + // Interactive confirmation (TTY path). cmd.Context(): think-time is not + // --timeout-bounded and must not drain the delete budget below. if !opts.Yes && !f.AgentMode() { - proceed, cerr := confirmRbDeletion(ctx, f, ioStreams, uri.Bucket, opts.Force) + proceed, cerr := confirmRbDeletion(cmd.Context(), f, ioStreams, uri.Bucket, opts.Force) if cerr != nil { return cerr } @@ -116,9 +117,13 @@ func runRb(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o } } + // Fresh bound: the prompt above may have outlived the listing ctx. + execCtx, execCancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer execCancel() + objectsDeleted := 0 if opts.Force { - n, err := emptyBucket(ctx, f, ioStreams, client, uri.Bucket) + n, err := emptyBucket(execCtx, f, ioStreams, client, uri.Bucket) if err != nil { return err } @@ -127,11 +132,11 @@ func runRb(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o var sp interface{ Stop(string) } if status := f.Status(); status != nil { - sp, _ = status.Spinner(ctx, fmt.Sprintf("Removing bucket %s...", uri.Bucket)) + sp, _ = status.Spinner(execCtx, fmt.Sprintf("Removing bucket %s...", uri.Bucket)) } in := &s3.DeleteBucketInput{Bucket: &uri.Bucket} - out, err := client.DeleteBucket(ctx, in) + out, err := client.DeleteBucket(execCtx, in) if sp != nil { sp.Stop("") } diff --git a/internal/verda-cli/cmd/objectstorage/resume_uploads.go b/internal/verda-cli/cmd/objectstorage/resume_uploads.go index fe7ad69..e462be1 100644 --- a/internal/verda-cli/cmd/objectstorage/resume_uploads.go +++ b/internal/verda-cli/cmd/objectstorage/resume_uploads.go @@ -141,9 +141,11 @@ func promptResumeSource(ctx context.Context, f cmdutil.Factory, ioStreams cmduti // resumeServerUpload resumes an in-progress multipart upload (bucket/key/ // uploadID) against the local file at absPath. It infers the original part size -// from the server's parts (so byte ranges align), verifies the file is large -// enough, seeds a checkpoint that ADOPTS the existing UploadId, then runs the -// normal resumable path (progress + same-host lock). +// from the server's parts (so byte ranges align), verifies the file matches the +// server-side part map (covered bytes plus, when the map is fully covered, the +// tail-part size — the strongest check available without content hashes), seeds +// a checkpoint that ADOPTS the existing UploadId, then runs the normal +// resumable path (progress + same-host lock). func resumeServerUpload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, bucket, key, uploadID, absPath string) error { info, err := os.Stat(absPath) if err != nil { @@ -164,13 +166,28 @@ func resumeServerUpload(ctx context.Context, f cmdutil.Factory, ioStreams cmduti partSize := inferPartSize(parts) if partSize > 0 { var maxN int32 + partSizes := make(map[int32]int64, len(parts)) for i := range parts { - maxN = max(maxN, aws.ToInt32(parts[i].PartNumber)) + n := aws.ToInt32(parts[i].PartNumber) + maxN = max(maxN, n) + partSizes[n] = aws.ToInt64(parts[i].Size) } if int64(maxN-1)*partSize >= info.Size() { return fmt.Errorf("local file %q (%s) is smaller than the in-progress upload — it does not match this object", absPath, humanBytes(info.Size())) } + // When the server parts fully cover the file's implied part map, + // completion assembles the object from those parts alone. A tail part + // sized for a different file would silently finish the object with the + // wrong bytes (review H9) — sizes are the only signal here, ListParts + // exposes multipart ETags, not content hashes. + if total := numParts(info.Size(), partSize); maxN == total { + want := info.Size() - int64(maxN-1)*partSize + if got := partSizes[maxN]; got != want { + return fmt.Errorf("local file %q does not match the in-progress upload: its final part would be %s, the server's is %s", + absPath, humanBytes(want), humanBytes(got)) + } + } } storedPartSize := partSize diff --git a/internal/verda-cli/cmd/objectstorage/resume_uploads_test.go b/internal/verda-cli/cmd/objectstorage/resume_uploads_test.go index a0ced6a..0be9d93 100644 --- a/internal/verda-cli/cmd/objectstorage/resume_uploads_test.go +++ b/internal/verda-cli/cmd/objectstorage/resume_uploads_test.go @@ -20,6 +20,8 @@ import ( "bytes" "context" "sort" + "strings" + "sync" "testing" "time" @@ -70,8 +72,11 @@ func TestFindCheckpointByUploadID(t *testing.T) { // resumeFakeAPI serves a fixed set of pre-existing parts and records uploads / // completion. CreateMultipartUpload must NOT be called (resume adopts the id). +// uploadMissingParts drives UploadPart from a worker pool, so the fake must +// be safe for concurrent use. type resumeFakeAPI struct { API + mu sync.Mutex existing []s3types.Part createCalls int uploaded []int32 @@ -79,7 +84,9 @@ type resumeFakeAPI struct { } func (r *resumeFakeAPI) CreateMultipartUpload(ctx context.Context, in *s3.CreateMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error) { + r.mu.Lock() r.createCalls++ + r.mu.Unlock() return &s3.CreateMultipartUploadOutput{UploadId: aws.String("should-not-happen")}, nil } @@ -89,13 +96,17 @@ func (r *resumeFakeAPI) ListParts(ctx context.Context, in *s3.ListPartsInput, op func (r *resumeFakeAPI) UploadPart(ctx context.Context, in *s3.UploadPartInput, opts ...func(*s3.Options)) (*s3.UploadPartOutput, error) { n := aws.ToInt32(in.PartNumber) + r.mu.Lock() r.uploaded = append(r.uploaded, n) + r.mu.Unlock() return &s3.UploadPartOutput{ETag: aws.String("\"new-etag\"")}, nil } func (r *resumeFakeAPI) CompleteMultipartUpload(ctx context.Context, in *s3.CompleteMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error) { if in.MultipartUpload != nil { + r.mu.Lock() r.completed = in.MultipartUpload.Parts + r.mu.Unlock() } return &s3.CompleteMultipartUploadOutput{}, nil } @@ -129,3 +140,58 @@ func TestResumeServerUpload(t *testing.T) { t.Errorf("completed with %d parts, want 4", len(fake.completed)) } } + +// TestResumeServerUploadRejectsMismatchedTail is the H9 regression test: the +// file's implied part map is fully covered by server parts, but the tail part +// was sized for a different file — completing would assemble the object from +// the wrong bytes. One part of minPartSize from another file's upload vs a +// (minPartSize-1) source file. +func TestResumeServerUploadRejectsMismatchedTail(t *testing.T) { + withTempVerdaHome(t) + abs, _, _ := writeTempFile(t, minPartSize-1) // one short tail part + + fake := &resumeFakeAPI{existing: []s3types.Part{ + {PartNumber: aws.Int32(1), Size: aws.Int64(minPartSize), ETag: aws.String("\"e1\"")}, + }} + f := cmdutil.NewTestFactory(nil) + io := cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}} + + err := resumeServerUpload(context.Background(), f, io, fake, "b", "cli-test/model.bin", "u1", abs) + if err == nil { + t.Fatal("expected tail-size mismatch to refuse the adoption") + } + if !strings.Contains(err.Error(), "does not match the in-progress upload") { + t.Fatalf("unexpected error: %v", err) + } + if fake.createCalls != 0 || len(fake.uploaded) != 0 || len(fake.completed) != 0 { + t.Errorf("adoption must refuse before any API mutation; got create=%d uploaded=%v completed=%v", + fake.createCalls, fake.uploaded, fake.completed) + } +} + +// TestResumeServerUploadExactTailCompletes: a fully covered part map whose +// tail size matches proceeds and completes without re-uploading anything. +func TestResumeServerUploadExactTailCompletes(t *testing.T) { + withTempVerdaHome(t) + abs, _, _ := writeTempFile(t, minPartSize+100) // part 1 + 100-byte tail + + fake := &resumeFakeAPI{existing: []s3types.Part{ + {PartNumber: aws.Int32(1), Size: aws.Int64(minPartSize), ETag: aws.String("\"e1\"")}, + {PartNumber: aws.Int32(2), Size: aws.Int64(100), ETag: aws.String("\"e2\"")}, + }} + f := cmdutil.NewTestFactory(nil) + io := cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}} + + if err := resumeServerUpload(context.Background(), f, io, fake, "b", "cli-test/model.bin", "u1", abs); err != nil { + t.Fatalf("resumeServerUpload: %v", err) + } + if fake.createCalls != 0 { + t.Errorf("CreateMultipartUpload called %d times, want 0 (must adopt the existing UploadId)", fake.createCalls) + } + if len(fake.uploaded) != 0 { + t.Errorf("uploaded parts = %v, want none (all parts already on server)", fake.uploaded) + } + if len(fake.completed) != 2 { + t.Errorf("completed with %d parts, want 2", len(fake.completed)) + } +} diff --git a/internal/verda-cli/cmd/objectstorage/rm.go b/internal/verda-cli/cmd/objectstorage/rm.go index 3a82215..da97ab7 100644 --- a/internal/verda-cli/cmd/objectstorage/rm.go +++ b/internal/verda-cli/cmd/objectstorage/rm.go @@ -162,9 +162,11 @@ func runRm(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o return renderDryrun(f, ioStreams, uri, targets) } - // Interactive confirmation (TTY path). + // Interactive confirmation (TTY path). cmd.Context(), not the bounded + // ctx: user think-time is not --timeout-bounded, and it must not drain + // the delete budget either. if !opts.Yes && !f.AgentMode() { - confirmed, confirmErr := confirmRm(ctx, f, ioStreams, uri, targets, opts.Recursive) + confirmed, confirmErr := confirmRm(cmd.Context(), f, ioStreams, uri, targets, opts.Recursive) if confirmErr != nil { if cmdutil.IsPromptCancel(confirmErr) { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") @@ -178,7 +180,11 @@ func runRm(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o } } - return executeRm(ctx, f, ioStreams, client, uri, targets, opts.Recursive) + // Fresh bound for the deletes (sshkey/startupscript delete use the same + // two-ctx split: one for listing, another for the mutation). + execCtx, execCancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer execCancel() + return executeRm(execCtx, f, ioStreams, client, uri, targets, opts.Recursive) } // validateRmArgs parses and validates the positional URI plus the flag diff --git a/internal/verda-cli/cmd/objectstorage/rm_browse.go b/internal/verda-cli/cmd/objectstorage/rm_browse.go index fcf77a6..0e4a3b8 100644 --- a/internal/verda-cli/cmd/objectstorage/rm_browse.go +++ b/internal/verda-cli/cmd/objectstorage/rm_browse.go @@ -57,7 +57,7 @@ func runRmBrowser(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt // Returns again=false to leave the browser entirely; again=true to keep looping // (cur may have been mutated to drill in/out). func rmBrowseLevel(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, cur *URI) (bool, error) { - payload, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading...", func() (objectsPayload, error) { + payload, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading...", func(ctx context.Context) (objectsPayload, error) { return collectObjects(ctx, f, ioStreams, client, *cur, "/") }) if err != nil { diff --git a/internal/verda-cli/cmd/objectstorage/sync.go b/internal/verda-cli/cmd/objectstorage/sync.go index 0a49875..594dbd0 100644 --- a/internal/verda-cli/cmd/objectstorage/sync.go +++ b/internal/verda-cli/cmd/objectstorage/sync.go @@ -123,8 +123,10 @@ func runSync(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, return cmdutil.UsageErrorf(cmd, "sync requires at least one s3:// URI") } - ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) - defer cancel() + // Mirrors cp.go runCp (see its comment): bulk transfers are data-plane and + // run on cmd.Context() (Ctrl+C), never the per-request --timeout. Only the + // enumeration calls re-bound (list control plane) inside each direction. + ctx := cmd.Context() switch dir { case dirUpload: @@ -177,7 +179,9 @@ func runSyncUpload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOS if err != nil { return err } - dstEntries, err := enumerateS3(ctx, f, ioStreams, apiClient, dst.Bucket, dst.Key, opts.Include, opts.Exclude) + listCtx, listCancel := context.WithTimeout(ctx, f.Options().Timeout) + dstEntries, err := enumerateS3(listCtx, f, ioStreams, apiClient, dst.Bucket, dst.Key, opts.Include, opts.Exclude) + listCancel() if err != nil { return err } @@ -238,7 +242,9 @@ func runSyncDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.I return err } - srcEntries, err := enumerateS3(ctx, f, ioStreams, apiClient, src.Bucket, src.Key, opts.Include, opts.Exclude) + listCtx, listCancel := context.WithTimeout(ctx, f.Options().Timeout) + srcEntries, err := enumerateS3(listCtx, f, ioStreams, apiClient, src.Bucket, src.Key, opts.Include, opts.Exclude) + listCancel() if err != nil { return err } @@ -296,11 +302,13 @@ func runSyncCopy(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStr return err } - srcEntries, err := enumerateS3(ctx, f, ioStreams, apiClient, src.Bucket, src.Key, opts.Include, opts.Exclude) - if err != nil { - return err + listCtx, listCancel := context.WithTimeout(ctx, f.Options().Timeout) + srcEntries, err := enumerateS3(listCtx, f, ioStreams, apiClient, src.Bucket, src.Key, opts.Include, opts.Exclude) + var dstEntries []syncEntry + if err == nil { + dstEntries, err = enumerateS3(listCtx, f, ioStreams, apiClient, dst.Bucket, dst.Key, opts.Include, opts.Exclude) } - dstEntries, err := enumerateS3(ctx, f, ioStreams, apiClient, dst.Bucket, dst.Key, opts.Include, opts.Exclude) + listCancel() if err != nil { return err } diff --git a/internal/verda-cli/cmd/objectstorage/upload_wizard.go b/internal/verda-cli/cmd/objectstorage/upload_wizard.go index 00c5210..9ffa96f 100644 --- a/internal/verda-cli/cmd/objectstorage/upload_wizard.go +++ b/internal/verda-cli/cmd/objectstorage/upload_wizard.go @@ -217,7 +217,7 @@ func resolveUploadSource(ctx context.Context, f cmdutil.Factory, ioStreams cmdut // top-level Select is canceled (so the caller can tell Esc from Ctrl+C); a // canceled create-name sub-prompt loops back to the Select rather than exiting. func selectBucketOrCreate(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API) (string, error) { - out, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading buckets...", func() (*s3.ListBucketsOutput, error) { + out, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading buckets...", func(ctx context.Context) (*s3.ListBucketsOutput, error) { return client.ListBuckets(ctx, &s3.ListBucketsInput{}) }) if err != nil { @@ -263,7 +263,7 @@ func createBucketInteractive(ctx context.Context, f cmdutil.Factory, ioStreams c if name == "" { return "", nil } - _, err = cmdutil.WithSpinner(ctx, f.Status(), "Creating bucket...", func() (*s3.CreateBucketOutput, error) { + _, err = cmdutil.WithSpinner(ctx, f.Status(), "Creating bucket...", func(ctx context.Context) (*s3.CreateBucketOutput, error) { return client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(name)}) }) if err != nil { @@ -278,7 +278,7 @@ func createBucketInteractive(ctx context.Context, f cmdutil.Factory, ioStreams c // prompter error if the Select is canceled; a canceled new-folder sub-prompt // loops back to the Select. func selectUploadLocation(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, bucket, suggested string) (string, error) { - payload, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading folders...", func() (objectsPayload, error) { + payload, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading folders...", func(ctx context.Context) (objectsPayload, error) { return collectObjects(ctx, f, ioStreams, client, URI{Bucket: bucket}, "/") }) if err != nil { diff --git a/internal/verda-cli/cmd/registry/CLAUDE.md b/internal/verda-cli/cmd/registry/CLAUDE.md index 50fe124..7045653 100644 --- a/internal/verda-cli/cmd/registry/CLAUDE.md +++ b/internal/verda-cli/cmd/registry/CLAUDE.md @@ -113,7 +113,7 @@ ggcr auto-emits `mount=&from=` hints when it sees a layer `copy` runs two independent auth chains: VCR credentials on the destination (always the robot account from `~/.verda/credentials`), and a pluggable source-side chain selected by `--src-auth`: -- `docker-config` (default) -- `authn.DefaultKeychain` via `sourceKeychainBuilder`. Reads `~/.docker/config.json`, honors `credsStore` / `credHelpers`, anonymous fallback. **If `docker pull ` works, `vccr copy ` reads the same creds.** +- `docker-config` (default) -- `authn.DefaultKeychain` via `sourceKeychainBuilder`. Reads `~/.docker/config.json`, honors `credsStore` / `credHelpers`, anonymous fallback. **If `docker pull ` works, `vccr copy ` reads the same creds.** The keychain is resolved against the SOURCE registry's host (`keychainAuth.host` from the parsed src ref), so non-Hub private sources (ghcr/ECR/GCR/ACR) get their entry and the Hub credential is never presented to a foreign host. - `anonymous` -- sends no Authorization header. Used to bypass a stale docker-config entry, or to prove a source is actually public. - `basic` -- takes `--src-username` + secret via `--src-password-stdin`. The CLI never persists these to disk. `--debug` may still emit `Authorization` headers in its HTTP trace, so avoid `--debug` on shared terminals when using `basic`. @@ -156,7 +156,8 @@ Before each `Write`, we `Head` the destination ref: - Wizard input modes (`configureStepInputMode` in `wizard.go`): the `input-mode` Select gates the paste step vs. the manual `username` + `secret` steps via each step's `ShouldSkip` reading `collected["input-mode"]`. The manual path has **no endpoint step** — the host is derived in `resolveRegistryInputs` (the `Username != "" && Secret != ""` branch) via `resolveEndpointForFlags` (saved host → `vccr.io`), with the project id parsed from the credential name by `projectIDFromUsername`. The manual branch is distinguished from the `--password-stdin` flag branch by a non-empty `opts.Secret` (the flag path leaves `Secret` empty and reads stdin). Skipped manual steps use no-op `Resetter`s so paste-mode doesn't clobber the paste-parsed `opts.Username`. - Overwrite guard (`confirmOverwriteIfConfigured` in `configure.go`): re-configuring a profile that already has registry creds is an **irreversible** replace (the secret is write-once). On a TTY it prompts to confirm (decline → clean abort, nothing written); in agent/non-TTY it proceeds (rotation intent) with a non-agent stderr note. Detection is `options.LoadRegistryCredentialsForProfile(...).HasCredentials()`; a load error is treated as "nothing to overwrite" so a first-time write is never blocked. The wizard's profile picker independently annotates each profile `registry configured` / `no registry credentials yet`. - Bubbletea output always goes to `ioStreams.ErrOut`. Stdout stays clean for structured / scripted consumption. -- **Interactive prompts must use `cmd.Context()`, not the per-request timeout ctx** (`context.WithTimeout(cmd.Context(), f.Options().Timeout)`). Think-time across a picker/wizard easily exceeds the 30s default `--timeout`; a bounded ctx cancels the prompt mid-flow and the cancel is swallowed as a clean exit (the TUI just vanishes). `ls`/`delete`/`tags`/`copy`/`push` interactive flows pass `cmd.Context()`; the bounded ctx is used only for the up-front listing API call. (Per-call timeouts inside the interactive loop are a future refinement.) +- **Interactive prompts must use `cmd.Context()`, not the per-request timeout ctx** (`context.WithTimeout(cmd.Context(), f.Options().Timeout)`). Think-time across a picker/wizard easily exceeds the 30s default `--timeout`; a bounded ctx cancels the prompt mid-flow and the cancel is swallowed as a clean exit (the TUI just vanishes). `ls`/`delete`/`tags`/`copy`/`push` interactive flows pass `cmd.Context()`; the bounded ctx is used only for the up-front listing API call. In `delete`, the post-confirm delete call re-bounds from the prompt ctx (fresh budget; think-time excluded) rather than inheriting the listing ctx. +- **Transfers are data-plane and never see the per-request timeout**: `push`/`copy` Read+Write (incl. ggcr's internal HEAD/POST machinery and the `--all-tags` pool) run on `cmd.Context()` (`WithCancel`, so the TUI's Esc still aborts); only control-plane calls (`Tags`/`Head`/manifest-only dry-run reads) use the `--timeout`-bounded ctx. The shared `http.Client` carries no `Timeout` for the same reason. - `--src-auth basic` validation order matters: `readBasicSourcePassword` checks `--src-username` is set BEFORE reading stdin, so a missing username doesn't drain (and lose) a piped one-shot secret. `buildSourceAuth` keeps its own username + empty-password checks as the wizard-path guard. - `splitLocalRef` in `push.go` intentionally does **not** use `Normalize()` — Normalize prefixes with `creds.ProjectID`, which is correct for VCR destinations but would corrupt a local `my-app:v1` source ref. The host heuristic mirrors `isShortRef` (first segment is a host iff it contains `.` / `:` or is `localhost`). diff --git a/internal/verda-cli/cmd/registry/configure.go b/internal/verda-cli/cmd/registry/configure.go index 11fff29..12d2781 100644 --- a/internal/verda-cli/cmd/registry/configure.go +++ b/internal/verda-cli/cmd/registry/configure.go @@ -135,7 +135,7 @@ func runConfigure(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStr printConfigureIntro(ioStreams) flow := buildConfigureFlow(opts) engine := wizard.NewEngine(f.Prompter(), f.Status(), - wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) + wizard.WithOutput(ioStreams.ErrOut)) if err := engine.Run(cmd.Context(), flow); err != nil { return err } diff --git a/internal/verda-cli/cmd/registry/copy.go b/internal/verda-cli/cmd/registry/copy.go index ee6b38b..9cb71df 100644 --- a/internal/verda-cli/cmd/registry/copy.go +++ b/internal/verda-cli/cmd/registry/copy.go @@ -220,7 +220,7 @@ func runCopy(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, if err != nil { return err } - srcAuth, err := buildSourceAuth(opts, basicPassword) + srcAuth, err := buildSourceAuth(opts, srcRef, basicPassword) if err != nil { return err } @@ -247,11 +247,18 @@ func runCopyResolved(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO srcReg := sourceRegistryBuilder(srcAuth, retryCfg) dstReg := buildClient(creds, retryCfg) - ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) - defer cancel() + // Control plane (Tags/Head/manifest reads) stays bounded by --timeout. + // The Read+Write transfer is data-plane: a multi-GB image legitimately + // outlives the request timeout, so it runs on cmd.Context() with Ctrl+C + // as the stop signal (review H2; mirrors objectstorage cp, and the + // wizard path's promise in copy_wizard.go). Prompts hang off cmd ctx. + apiCtx, apiCancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer apiCancel() + transferCtx, transferCancel := context.WithCancel(cmd.Context()) + defer transferCancel() if opts.AllTags { - return runCopyAllTagsFlow(ctx, cancel, cmd, f, ioStreams, srcReg, dstReg, srcRef, args, creds, opts) + return runCopyAllTagsFlow(apiCtx, transferCtx, transferCancel, cmd, f, ioStreams, srcReg, dstReg, srcRef, args, creds, opts) } dstRef, err := resolveCopyDestination(args, srcRef, creds) @@ -263,12 +270,12 @@ func runCopyResolved(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO dstString := dstRef.String() if opts.DryRun { - return runCopyDryRunSingle(ctx, srcReg, srcString, dstString, f, ioStreams) + return runCopyDryRunSingle(apiCtx, srcReg, srcString, dstString, f, ioStreams) } // Overwrite guard: inspect dst before writing. Dry-run skips this by // design (it never writes; the user is asking "what would happen"). - decision, derr := resolveOverwriteDecision(ctx, dstReg, dstString, opts, f, ioStreams) + decision, derr := resolveOverwriteDecision(apiCtx, cmd.Context(), dstReg, dstString, opts, f, ioStreams) if derr != nil { return derr } @@ -289,7 +296,7 @@ func runCopyResolved(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO return nil } - result := performCopy(ctx, cancel, srcReg, dstReg, srcString, dstString, creds, opts, f, ioStreams) + result := performCopy(transferCtx, transferCancel, srcReg, dstReg, srcString, dstString, creds, opts, f, ioStreams) if isStructuredFormat(f.OutputFormat()) { payload := buildCopyPayload(srcString, dstString, result) @@ -560,8 +567,11 @@ func readBasicSourcePassword(opts *copyOptions, stdin io.Reader) (string, error) // on --src-auth. The basic secret is supplied by the caller (stdin on the flag // path, a prompt in the wizard). docker-config routes through the swappable // sourceKeychainBuilder so tests can assert which keychain was selected without -// driving a real credential store. -func buildSourceAuth(opts *copyOptions, basicPassword string) (authn.Authenticator, error) { +// driving a real credential store; srcRef pins the keychain lookup to the +// source registry's host. +// +//nolint:gocritic // hugeParam: Ref is an immutable value type; contract uses value receivers uniformly (see refname.go). +func buildSourceAuth(opts *copyOptions, srcRef Ref, basicPassword string) (authn.Authenticator, error) { switch opts.SrcAuth { case srcAuthAnonymous: return authn.Anonymous, nil @@ -587,15 +597,12 @@ func buildSourceAuth(opts *copyOptions, basicPassword string) (authn.Authenticat }), nil case srcAuthDockerConfig, "": - // The keychain needs a Resource (host) to resolve against. We - // don't have the srcRef here — keychain callers pass the - // reference at call time. remote.WithAuth accepts a plain - // Authenticator though, not a Keychain, so we return a - // thin keychainAuth adapter that resolves lazily from the - // configured sourceKeychainBuilder. In practice the keychain - // is consulted once per Read and applied to every request for - // that session. - return &keychainAuth{keychain: sourceKeychainBuilder}, nil + // Resolve per source-registry host so private ghcr/ECR/GCR/ACR + // sources get their docker-config creds AND the Docker Hub entry + // is never presented to a different host (review H3). srcRef.Host + // is already normalized by Parse (ggcr rewrites docker.io to + // index.docker.io, which is what the keychain keys on). + return &keychainAuth{keychain: sourceKeychainBuilder, host: srcRef.Host}, nil default: return nil, &cmdutil.AgentError{ @@ -606,37 +613,30 @@ func buildSourceAuth(opts *copyOptions, basicPassword string) (authn.Authenticat } } -// keychainAuth adapts authn.Keychain to the authn.Authenticator contract -// by deferring resolution until Authorization() is called. The keychain -// itself decides which credential (docker-config, helper, anonymous) to -// return per-host. If resolution fails we fall back to anonymous so -// public images remain pullable even when the keychain is misconfigured. +// keychainAuth adapts authn.Keychain to the authn.Authenticator contract. +// Resolution is pinned to the source registry's host: the keychain decides +// which credential (docker-config entry, helper, anonymous) applies to THAT +// host. If resolution misses we fall back to anonymous so public images +// remain pullable even when the keychain is misconfigured. type keychainAuth struct { keychain authn.Keychain - // resource is the host the authenticator is currently being used - // against. Populated lazily on first Authorization() call via a - // side-channel setResource hook invoked by the ggcr transport - // machinery through the remote.Option path. In practice ggcr - // passes a Resource to Keychain.Resolve directly — we embed that - // lookup inline in Authorization so we don't depend on newer - // ContextKeychain APIs. + // host is the source registry (authn.Resource.RegistryStr()) the + // authenticator resolves against. Without it, a plain Authenticator + // adapter would resolve the keychain's default resource — Docker Hub — + // and send the Hub credential to whatever registry it talks to + // (review H3: scope-confusion secret leak). + host string } -// Authorization resolves the keychain at call time. For single-host -// copies the adapter is effectively memoized because remote.Image -// captures the resolved Authenticator after the first request; for -// v1 we re-resolve on every call, which is cheap for DefaultKeychain -// (file read is cached internally by ggcr). +// Authorization resolves the keychain for the pinned source host. For +// single-host copies that's memoization-safe: every call hits the same +// registry. DefaultKeychain caches the config-file read internally, so +// re-resolving per call is cheap. func (k *keychainAuth) Authorization() (*authn.AuthConfig, error) { - // ggcr's keychain-aware call sites go through remote.WithAuthFromKeychain - // rather than an Authenticator adapter like this one — but remote.WithAuth - // is simpler and avoids a second option slot, so we adapt here. The - // resolve-without-resource fallback below returns the Docker Hub entry - // (keychain's default) which is the common public-image case. if k.keychain == nil { return (&authn.AuthConfig{}), nil } - auth, err := k.keychain.Resolve(keychainResource{host: authn.DefaultAuthKey}) + auth, err := k.keychain.Resolve(keychainResource{host: k.host}) if err != nil || auth == nil { return (&authn.AuthConfig{}), nil //nolint:nilerr // fall back to anonymous for public images } @@ -793,8 +793,12 @@ func renderCopyDryRun(ioStreams cmdutil.IOStreams, outputFormat string, rows []c // the environment can't prompt and --overwrite/--yes wasn't provided); the // error return carries agent-mode CONFIRMATION_REQUIRED or a surfaced Head // error. +// +// ctx bounds the Head call; promptCtx carries the user's think-time +// (cmd.Context()) so a slow answer can't be killed by --timeout. func resolveOverwriteDecision( ctx context.Context, + promptCtx context.Context, dstReg Registry, dstString string, opts *copyOptions, @@ -829,7 +833,7 @@ func resolveOverwriteDecision( if !isTerminalFn(ioStreams.ErrOut) { return overwriteSkip, nil } - confirmed, cerr := f.Prompter().Confirm(ctx, + confirmed, cerr := f.Prompter().Confirm(promptCtx, fmt.Sprintf("Destination %s exists. Overwrite?", dstString), tui.WithConfirmDefault(false), ) @@ -1072,10 +1076,14 @@ func assembleAllTagsResults( // shape, fetches the tag list, and delegates the fan-out to // runCopyAllTagsPool. // +// apiCtx bounds the control-plane calls (Tags/Head); transferCtx drives the +// pool's transfers and is what the TUI's cancel func aborts. +// //nolint:gocritic // hugeParam: Ref is an immutable value type; contract uses value receivers uniformly (see refname.go). func runCopyAllTagsFlow( - ctx context.Context, - cancel context.CancelFunc, + apiCtx context.Context, + transferCtx context.Context, + transferCancel context.CancelFunc, cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, @@ -1105,7 +1113,7 @@ func runCopyAllTagsFlow( // host (source refs must be fully qualified). Without the prefix, // ggcr would either fall back to docker.io or emit a hostless URL. srcRepoPath := srcRef.Host + "/" + srcRef.FullRepository() - tags, err := srcReg.Tags(ctx, srcRepoPath) + tags, err := srcReg.Tags(apiCtx, srcRepoPath) if err != nil { return translateError(err) } @@ -1114,19 +1122,19 @@ func runCopyAllTagsFlow( } if opts.DryRun { - return runCopyDryRunAllTags(ctx, f, ioStreams, srcReg, srcRef, dstBase, tags) + return runCopyDryRunAllTags(apiCtx, f, ioStreams, srcReg, srcRef, dstBase, tags) } // Pre-flight overwrite check: we inspect every dst ref first so we can // short-circuit before any Write lands. In agent mode the very first // existing dst raises CONFIRMATION_REQUIRED; in interactive mode the // user decides per-tag, and declined tags are recorded as skipped. - skip, oerr := resolveAllTagsOverwrite(ctx, dstReg, dstBase, tags, opts, f, ioStreams) + skip, oerr := resolveAllTagsOverwrite(apiCtx, cmd.Context(), dstReg, dstBase, tags, opts, f, ioStreams) if oerr != nil { return oerr } - results := assembleAllTagsResults(ctx, cancel, srcReg, dstReg, srcRef, dstBase, tags, skip, creds, opts, f, ioStreams) + results := assembleAllTagsResults(transferCtx, transferCancel, srcReg, dstReg, srcRef, dstBase, tags, skip, creds, opts, f, ioStreams) summary := summarizeCopyResults(results) if handled, err := writeAllTagsStructured(ioStreams, f.OutputFormat(), results, summary); handled { @@ -1480,9 +1488,13 @@ func newPartialFailureError(s allTagsSummary) error { // surface the issue ASAP so the caller can decide once rather than racing // partial writes. Non-TTY non-agent callers get the safe default (skip). // +// ctx bounds the per-tag Head calls; promptCtx carries user think-time +// (cmd.Context(), see resolveOverwriteDecision). +// //nolint:gocritic // hugeParam: Ref is an immutable value type; contract uses value receivers uniformly (see refname.go). func resolveAllTagsOverwrite( ctx context.Context, + promptCtx context.Context, dstReg Registry, dstBase Ref, tags []string, @@ -1518,7 +1530,7 @@ func resolveAllTagsOverwrite( skip[tag] = struct{}{} continue } - confirmed, cerr := f.Prompter().Confirm(ctx, + confirmed, cerr := f.Prompter().Confirm(promptCtx, fmt.Sprintf("Destination %s exists. Overwrite?", dstRef), tui.WithConfirmDefault(false), ) diff --git a/internal/verda-cli/cmd/registry/copy_test.go b/internal/verda-cli/cmd/registry/copy_test.go index f23c657..4d70eaa 100644 --- a/internal/verda-cli/cmd/registry/copy_test.go +++ b/internal/verda-cli/cmd/registry/copy_test.go @@ -17,6 +17,7 @@ package registry import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "io" @@ -25,6 +26,7 @@ import ( "net/url" "sort" "strings" + "sync" "sync/atomic" "testing" "time" @@ -1664,3 +1666,208 @@ func (r *tagFailingRegistry) Head(_ context.Context, _ string) (*v1.Descriptor, StatusCode: http.StatusNotFound, } } + +// ---------- per-host source keychain regression (review H3) ---------- + +// recordedKeychain resolves per-host from a fixed map and records the +// RegistryStr() of every Resolve call, so tests can assert the command asked +// for the SOURCE host's credentials -- and never Docker Hub's. +type recordedKeychain struct { + mu sync.Mutex + perHost map[string]authn.Authenticator + resolves []string +} + +func (k *recordedKeychain) Resolve(r authn.Resource) (authn.Authenticator, error) { + k.mu.Lock() + defer k.mu.Unlock() + k.resolves = append(k.resolves, r.RegistryStr()) + if a, ok := k.perHost[r.RegistryStr()]; ok { + return a, nil + } + return authn.Anonymous, nil +} + +// gatedRegistry fronts the in-memory ggcr registry with a Basic-auth gate. +// /v2/* requests are recorded; when basicHdr is non-empty they must carry +// exactly that Authorization value or get a 401 Basic challenge (a private +// registry). Empty basicHdr accepts anything (a public registry) -- the +// recordings then show which credentials the client attached unprovoked +// (review H3: pre-fix, ggcr's basicTransport attached the resolved Docker +// Hub credential to every request against any source host). +type gatedRegistry struct { + host string + basicHdr string + + mu sync.Mutex + seenAuth []string +} + +func newGatedRegistry(t *testing.T, basicUser, basicPass string) *gatedRegistry { + t.Helper() + g := &gatedRegistry{} + if basicUser != "" { + g.basicHdr = "Basic " + base64.StdEncoding.EncodeToString([]byte(basicUser+":"+basicPass)) + } + inner := ggcrregistry.New() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasPrefix(r.URL.Path, "/v2") { + inner.ServeHTTP(w, r) + return + } + hdr := r.Header.Get("Authorization") + g.mu.Lock() + g.seenAuth = append(g.seenAuth, hdr) + g.mu.Unlock() + if g.basicHdr != "" && hdr != g.basicHdr { + w.Header().Set("WWW-Authenticate", `Basic realm="gated"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + inner.ServeHTTP(w, r) + })) + t.Cleanup(srv.Close) + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse gated registry URL: %v", err) + } + g.host = u.Host + return g +} + +// primeGated pushes a random image into the gated source using the given +// source-side authenticator (going through the challenge/token flow, so the +// fixture exercises the same auth machinery the copy will). +func primeGated(t *testing.T, src *gatedRegistry, ref string, auth authn.Authenticator) v1.Image { + t.Helper() + r := newGGCRRegistryForSource(auth, RetryConfig{}) + img, err := random.Image(1024, 1) + if err != nil { + t.Fatalf("random.Image: %v", err) + } + if err := r.Write(context.Background(), src.host+"/"+ref, img, WriteOptions{}); err != nil { + t.Fatalf("prime gated source: %v", err) + } + return img +} + +// TestCopy_DockerConfigResolvesCredsForSourceHost: the docker-config keychain +// holds credentials for a private ghcr-style host under THAT host's key. The +// copy must succeed and the keychain must be resolved with the source host -- +// pre-fix resolution keyed on authn.DefaultAuthKey (Docker Hub), missed, and +// the private token endpoint rejected the anonymous fetch (review H3). +func TestCopy_DockerConfigResolvesCredsForSourceHost(t *testing.T) { + const user, pass = "ghcr-user", "ghcr-secret" + src := newGatedRegistry(t, user, pass) + dstHost := testServer(t) + writeCopyCredsFile(t, dstHost, "proj") + + kc := &recordedKeychain{perHost: map[string]authn.Authenticator{ + src.host: authn.FromConfig(authn.AuthConfig{Username: user, Password: pass}), + }} + withSourceKeychain(t, kc) + + srcImg := primeGated(t, src, "ns/app:v1", authn.FromConfig(authn.AuthConfig{Username: user, Password: pass})) + + f := cmdutil.NewTestFactory(nil) + streams, out, _ := copyStreams("") + + srcArg := src.host + "/ns/app:v1" + dstArg := dstHost + "/proj/app:v1" + if err := runCopyForTest(t, f, streams, srcArg, dstArg); err != nil { + t.Fatalf("copy from private source with host-keyed creds: %v\nout: %s", err, out.String()) + } + + dstReg := newGGCRRegistry(testCreds(dstHost)) + desc, err := dstReg.Head(context.Background(), dstArg) + if err != nil { + t.Fatalf("Head(dst): %v", err) + } + want, _ := srcImg.Digest() + if desc.Digest != want { + t.Fatalf("digest mismatch: got %s, want %s", desc.Digest, want) + } + + if len(kc.resolves) == 0 { + t.Fatal("keychain never resolved -- --src-auth docker-config should consult it") + } + for _, host := range kc.resolves { + if host != src.host { + t.Errorf("keychain resolved for %q, want only source host %q (Hub key would leak creds)", host, src.host) + } + } + + src.mu.Lock() + defer src.mu.Unlock() + wantBasic := "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass)) + var sawCreds bool + for _, hdr := range src.seenAuth { + if hdr == wantBasic { + sawCreds = true + } + } + if !sawCreds { + t.Errorf("private source never received the host-keyed credentials; Authorization seen: %q", src.seenAuth) + } +} + +// TestCopy_DockerConfigNeverSendsHubCredsToForeignHost: with only a Docker +// Hub login in the keychain, a copy from any other registry must run +// anonymous -- the Hub credential must never be presented to a non-Hub host +// (review H3: scope-confusion leak). The token endpoint accepts anonymous +// pulls; the assertion is on what the keychain was asked and what crossed +// the wire, so the test discriminates even though the copy succeeds either +// way. +func TestCopy_DockerConfigNeverSendsHubCredsToForeignHost(t *testing.T) { + const hubUser, hubPass = "hub-user", "hub-secret" + hubHdr := "Basic " + base64.StdEncoding.EncodeToString([]byte(hubUser+":"+hubPass)) + hubAuth := authn.FromConfig(authn.AuthConfig{Username: hubUser, Password: hubPass}) + + src := newGatedRegistry(t, "", "") // anonymous token fetches allowed + dstHost := testServer(t) + writeCopyCredsFile(t, dstHost, "proj") + + // Real DefaultKeychain with a Hub login answers both the bare host and + // the legacy v1 key; seed the same host->cred association under both so + // resolve behavior matches what ggcr would do with a real docker config. + kc := &recordedKeychain{perHost: map[string]authn.Authenticator{ + "index.docker.io": hubAuth, + authn.DefaultAuthKey: hubAuth, + }} + withSourceKeychain(t, kc) + + srcImg := primeGated(t, src, "ns/app:v2", authn.Anonymous) + + f := cmdutil.NewTestFactory(nil) + streams, out, _ := copyStreams("") + + srcArg := src.host + "/ns/app:v2" + dstArg := dstHost + "/proj/app:v2" + if err := runCopyForTest(t, f, streams, srcArg, dstArg); err != nil { + t.Fatalf("anonymous copy from public source: %v\nout: %s", err, out.String()) + } + + dstReg := newGGCRRegistry(testCreds(dstHost)) + desc, err := dstReg.Head(context.Background(), dstArg) + if err != nil { + t.Fatalf("Head(dst): %v", err) + } + want, _ := srcImg.Digest() + if desc.Digest != want { + t.Fatalf("digest mismatch: got %s, want %s", desc.Digest, want) + } + + for _, host := range kc.resolves { + if host == "index.docker.io" || host == authn.DefaultAuthKey { + t.Fatalf("keychain resolved the Docker Hub entry for a copy from %q -- Hub creds would be sent to a foreign host", host) + } + } + + src.mu.Lock() + defer src.mu.Unlock() + for _, hdr := range src.seenAuth { + if hdr == hubHdr { + t.Fatalf("Docker Hub credential presented to %q (scope confusion); Authorization seen: %q", src.host, src.seenAuth) + } + } +} diff --git a/internal/verda-cli/cmd/registry/copy_wizard.go b/internal/verda-cli/cmd/registry/copy_wizard.go index 34a88bb..26858b7 100644 --- a/internal/verda-cli/cmd/registry/copy_wizard.go +++ b/internal/verda-cli/cmd/registry/copy_wizard.go @@ -354,7 +354,7 @@ func confirmAndRunCopy(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. return false, nil } - srcAuth, aerr := buildSourceAuth(opts, srcPassword) + srcAuth, aerr := buildSourceAuth(opts, srcRef, srcPassword) if aerr != nil { return false, aerr } diff --git a/internal/verda-cli/cmd/registry/delete.go b/internal/verda-cli/cmd/registry/delete.go index a3798e0..e38d3bb 100644 --- a/internal/verda-cli/cmd/registry/delete.go +++ b/internal/verda-cli/cmd/registry/delete.go @@ -195,7 +195,7 @@ func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream return runDeleteInteractive(cmd.Context(), f, ioStreams, lister, creds) } - return runDeleteTarget(ctx, f, ioStreams, lister, creds, target, opts.Yes) + return runDeleteTarget(ctx, cmd.Context(), f, ioStreams, lister, creds, target, opts.Yes) } // classifyTarget inspects the raw positional argument and decides whether @@ -226,7 +226,12 @@ func classifyTarget(raw string) (isArtifact, isDigest bool) { // target, dispatch to repo-or-artifact delete. Shared by CLI users who // type a target explicitly AND by the interactive picker after the user // selects "Delete this repository". -func runDeleteTarget(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, +// +// ctx bounds the listing/lookup calls; promptCtx is the command root ctx — +// prompts run on it directly (think-time is never --timeout-bounded) and +// the post-confirm delete re-bounds from it so a slow answer can't hand +// the delete an expired ctx. +func runDeleteTarget(ctx, promptCtx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, lister RepositoryLister, creds *options.RegistryCredentials, target string, yes bool) error { isArtifact, _ := classifyTarget(target) @@ -258,9 +263,9 @@ func runDeleteTarget(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.I if reference == "" { reference = ref.Tag } - return deleteArtifactFlow(ctx, f, ioStreams, lister, creds, ref.Repository, reference, yes) + return deleteArtifactFlow(ctx, promptCtx, f, ioStreams, lister, creds, ref.Repository, reference, yes) } - return deleteRepositoryFlow(ctx, f, ioStreams, lister, creds, ref.Repository, yes) + return deleteRepositoryFlow(ctx, promptCtx, f, ioStreams, lister, creds, ref.Repository, yes) } // deleteRepositoryFlow implements the "Delete image repository" dialog @@ -269,7 +274,7 @@ func runDeleteTarget(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.I // surfaces the blast radius ("this image repository holds N image"), // matching the UI. A failing count lookup degrades gracefully to a // generic "all artifacts" wording. -func deleteRepositoryFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, +func deleteRepositoryFlow(ctx, promptCtx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, lister RepositoryLister, creds *options.RegistryCredentials, repoName string, yes bool) error { artifactCount := -1 if arts, err := lister.ListArtifacts(ctx, creds.ProjectID, repoName); err == nil { @@ -281,7 +286,7 @@ func deleteRepositoryFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdu return cmdutil.NewConfirmationRequiredError("delete") } } else { - confirmed, err := confirmDeleteRepository(ctx, f, ioStreams, repoName, artifactCount, yes) + confirmed, err := confirmDeleteRepository(promptCtx, f, ioStreams, repoName, artifactCount, yes) if err != nil { return err } @@ -297,7 +302,10 @@ func deleteRepositoryFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdu "artifact_count": artifactCount, }) - err := cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("Deleting repository %s...", repoName), func() error { + // Fresh bound: think-time at the prompt above must not drain the delete. + execCtx, cancel := context.WithTimeout(promptCtx, f.Options().Timeout) + defer cancel() + err := cmdutil.RunWithSpinner(execCtx, f.Status(), fmt.Sprintf("Deleting repository %s...", repoName), func(ctx context.Context) error { return lister.DeleteRepository(ctx, creds.ProjectID, repoName) }) if err != nil { @@ -332,7 +340,7 @@ func deleteRepositoryFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdu // the agent-mode payload carries those fields. On lookup failure we // proceed without the context (Harbor's DELETE is still safe — the // confirmation just shows less info). -func deleteArtifactFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, +func deleteArtifactFlow(ctx, promptCtx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, lister RepositoryLister, creds *options.RegistryCredentials, repoName, reference string, yes bool) error { art := lookupArtifact(ctx, lister, creds.ProjectID, repoName, reference) @@ -341,7 +349,7 @@ func deleteArtifactFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmduti return cmdutil.NewConfirmationRequiredError("delete") } } else { - confirmed, err := confirmDeleteArtifact(ctx, f, ioStreams, repoName, reference, art, yes) + confirmed, err := confirmDeleteArtifact(promptCtx, f, ioStreams, repoName, reference, art, yes) if err != nil { return err } @@ -357,7 +365,10 @@ func deleteArtifactFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmduti "reference": reference, }) - err := cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("Deleting image %s...", reference), func() error { + // Fresh bound: think-time at the prompt above must not drain the delete. + execCtx, cancel := context.WithTimeout(promptCtx, f.Options().Timeout) + defer cancel() + err := cmdutil.RunWithSpinner(execCtx, f.Status(), fmt.Sprintf("Deleting image %s...", reference), func(ctx context.Context) error { return lister.DeleteArtifact(ctx, creds.ProjectID, repoName, reference) }) if err != nil { @@ -440,7 +451,10 @@ func runDeleteInteractive(ctx context.Context, f cmdutil.Factory, ioStreams cmdu idx, err := prompter.Select(ctx, registryBreadcrumb(creds.Endpoint, ""), labels, tui.WithShowHints(true)) if err != nil { - return nil //nolint:nilerr // intentional: prompter cancel is a clean exit + if cmdutil.IsPromptCancel(err) { + return nil // Prompter cancel (Ctrl-C, Esc) is a clean exit. + } + return err } if idx == len(repos) { return nil @@ -496,7 +510,10 @@ func runDeleteRepoMenu(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil idx, err := prompter.Select(ctx, registryBreadcrumb(creds.Endpoint, repo.Name), choices, tui.WithShowHints(true)) if err != nil { - return true, nil //nolint:nilerr // intentional: prompter cancel is a clean exit + if cmdutil.IsPromptCancel(err) { + return true, nil // Prompter cancel (Ctrl-C, Esc) exits the command. + } + return false, err } switch idx { case menuImages: @@ -507,7 +524,7 @@ func runDeleteRepoMenu(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil // changed. continue case menuRepo: - if err := deleteRepositoryFlow(ctx, f, ioStreams, lister, creds, repo.Name, false); err != nil { + if err := deleteRepositoryFlow(ctx, ctx, f, ioStreams, lister, creds, repo.Name, false); err != nil { return false, err } // A successful repo delete means there's nothing left to do @@ -549,8 +566,11 @@ func runDeleteImagesInteractive(ctx context.Context, f cmdutil.Factory, ioStream "Select image(s) to delete", labels, tui.WithMultiSelectShowHints(true)) if err != nil { - // User canceled the picker — back to the menu. - return nil //nolint:nilerr // intentional: prompter cancel is a clean exit + if cmdutil.IsPromptCancel(err) { + // User canceled the picker — back to the menu. + return nil + } + return err } if len(indices) == 0 { _, _ = fmt.Fprintln(ioStreams.ErrOut, "No images selected.") @@ -593,7 +613,7 @@ func runDeleteImagesInteractive(ctx context.Context, f cmdutil.Factory, ioStream } err := cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("Deleting %s...", shortDigest(a.Digest)), - func() error { + func(ctx context.Context) error { return lister.DeleteArtifact(ctx, creds.ProjectID, repo.Name, ref) }) if err != nil { diff --git a/internal/verda-cli/cmd/registry/ls.go b/internal/verda-cli/cmd/registry/ls.go index 8cf735b..d0b68eb 100644 --- a/internal/verda-cli/cmd/registry/ls.go +++ b/internal/verda-cli/cmd/registry/ls.go @@ -190,9 +190,10 @@ func runLsInteractive(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil. for { idx, err := prompter.Select(ctx, registryBreadcrumb(host, ""), labels, tui.WithShowHints(true)) if err != nil { - // Prompter-layer cancellation (Ctrl-C, ESC) returns a - // sentinel error; vm list treats it as a clean exit. - return nil //nolint:nilerr // intentional: prompter cancel is a clean exit + if cmdutil.IsPromptCancel(err) { + return nil // Prompter-layer cancellation (Ctrl-C, ESC) is a clean exit. + } + return err } if idx == len(payload.Repositories) { // "Exit" return nil @@ -331,7 +332,10 @@ func runRepoActions(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IO if cmdutil.IsPromptInterrupt(err) { return true, nil // Ctrl+C quits the command } - return false, nil // Esc → back to the repository list + if cmdutil.IsPromptBack(err) { + return false, nil // Esc → back to the repository list + } + return false, err } switch idx { case actPull: @@ -436,7 +440,10 @@ func runTagPicker(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt if cmdutil.IsPromptInterrupt(err) { return true, nil // Ctrl+C quits the whole command } - return false, nil // Esc → back to the previous menu + if cmdutil.IsPromptBack(err) { + return false, nil // Esc → back to the previous menu + } + return false, err } if idx == len(entries) { // "← Back" return false, nil diff --git a/internal/verda-cli/cmd/registry/push.go b/internal/verda-cli/cmd/registry/push.go index 3d1996b..827bf59 100644 --- a/internal/verda-cli/cmd/registry/push.go +++ b/internal/verda-cli/cmd/registry/push.go @@ -201,7 +201,11 @@ func runPush(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, } loader := sourceLoaderBuilder(ping) - ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + // Load+Write are data-plane: a multi-GB image legitimately outlives the + // per-request --timeout, so pushes run on cmd.Context() with Ctrl+C as the + // stop signal (mirrors objectstorage cp). cancel stays explicit so the + // progress view's Esc aborts in-flight transfers. + ctx, cancel := context.WithCancel(cmd.Context()) defer cancel() // --no-mount is accepted but not yet wired: ggcr's remote.Write always diff --git a/internal/verda-cli/cmd/serverless/CLAUDE.md b/internal/verda-cli/cmd/serverless/CLAUDE.md index 5c716ff..3ab5779 100644 --- a/internal/verda-cli/cmd/serverless/CLAUDE.md +++ b/internal/verda-cli/cmd/serverless/CLAUDE.md @@ -133,7 +133,7 @@ Describe cards (`renderContainerDeploymentCard`, `renderJobDeploymentCard`) prin ## Relationships - `cmdutil` (`internal/verda-cli/cmd/util`) — `Factory`, `IOStreams`, `WithSpinner`, `RunWithSpinner`, `DebugJSON`, `WriteStructured`, `NewMissingFlagsError`, `NewConfirmationRequiredError`, `UsageErrorf`, `LongDesc`, `Examples`, `DefaultSubCommandRun`. -- `pkg/tui/wizard` — `Flow`, `Step`, `Choice`, `Store`, `Engine`, `NewEngine`, `StaticChoices`, `WithOutput`, `WithExitConfirmation`, prompt-type enums. +- `pkg/tui/wizard` — `Flow`, `Step`, `Choice`, `Store`, `Engine`, `NewEngine`, `StaticChoices`, `WithOutput`, prompt-type enums. - `pkg/tui` — `Prompter`, `Status`, `WithConfirmDefault`. - SDK (`verdacloud-sdk-go/pkg/verda`): - `ContainerDeploymentsService` — `GetDeployments`, `CreateDeployment`, `GetDeploymentByName`, `DeleteDeployment`, `GetDeploymentStatus`, `PauseDeployment`, `ResumeDeployment`, `RestartDeployment`, `PurgeDeploymentQueue`, `GetServerlessComputeResources`, `GetRegistryCredentials`, `GetSecrets`, `GetFileSecrets`, `ValidateCreateDeploymentRequest`. diff --git a/internal/verda-cli/cmd/serverless/batchjob_actions.go b/internal/verda-cli/cmd/serverless/batchjob_actions.go index 37b28d5..5e0402c 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_actions.go +++ b/internal/verda-cli/cmd/serverless/batchjob_actions.go @@ -75,7 +75,7 @@ func runBatchjobAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() - err = cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("%s %s...", spinner, name), func() error { + err = cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("%s %s...", spinner, name), func(ctx context.Context) error { return fn(ctx, client, name) }) if err != nil { diff --git a/internal/verda-cli/cmd/serverless/batchjob_create.go b/internal/verda-cli/cmd/serverless/batchjob_create.go index d5887a0..6fde416 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_create.go +++ b/internal/verda-cli/cmd/serverless/batchjob_create.go @@ -143,7 +143,7 @@ func runBatchjobCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() - deployment, err := cmdutil.WithSpinner(ctx, f.Status(), "Creating batch-job deployment...", func() (*verda.JobDeployment, error) { + deployment, err := cmdutil.WithSpinner(ctx, f.Status(), "Creating batch-job deployment...", func(ctx context.Context) (*verda.JobDeployment, error) { return client.ServerlessJobs.CreateJobDeployment(ctx, req) }) if err != nil { @@ -163,8 +163,7 @@ func runBatchjobCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. func runBatchjobWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *batchjobCreateOptions) error { flow := buildBatchjobCreateFlow(ctx, f.VerdaClient, opts) engine := wizard.NewEngine(f.Prompter(), f.Status(), - wizard.WithOutput(ioStreams.ErrOut), - wizard.WithExitConfirmation()) + wizard.WithOutput(ioStreams.ErrOut)) return engine.Run(ctx, flow) } diff --git a/internal/verda-cli/cmd/serverless/batchjob_delete.go b/internal/verda-cli/cmd/serverless/batchjob_delete.go index ecc9b27..fa408f6 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_delete.go +++ b/internal/verda-cli/cmd/serverless/batchjob_delete.go @@ -73,7 +73,7 @@ func runBatchjobDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() - err = cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("Deleting %s...", name), func() error { + err = cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("Deleting %s...", name), func(ctx context.Context) error { return client.ServerlessJobs.DeleteJobDeployment(ctx, name, timeoutMs) }) if err != nil { diff --git a/internal/verda-cli/cmd/serverless/batchjob_describe.go b/internal/verda-cli/cmd/serverless/batchjob_describe.go index 682ef6c..c98dff7 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_describe.go +++ b/internal/verda-cli/cmd/serverless/batchjob_describe.go @@ -64,7 +64,7 @@ func selectBatchjobDeployment(ctx context.Context, f cmdutil.Factory, ioStreams listCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) defer cancel() - jobs, err := cmdutil.WithSpinner(listCtx, f.Status(), "Loading batch-job deployments...", func() ([]verda.JobDeploymentShortInfo, error) { + jobs, err := cmdutil.WithSpinner(listCtx, f.Status(), "Loading batch-job deployments...", func(ctx context.Context) ([]verda.JobDeploymentShortInfo, error) { return client.ServerlessJobs.GetJobDeployments(listCtx) }) if err != nil { @@ -112,7 +112,7 @@ func runBatchjobDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmduti // subordinate timeout so a slow status RPC can't blank it (see // container_describe.go). var status string - job, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading deployment...", func() (*verda.JobDeployment, error) { + job, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading deployment...", func(ctx context.Context) (*verda.JobDeployment, error) { d, derr := client.ServerlessJobs.GetJobDeploymentByName(ctx, name) if derr != nil { return nil, derr diff --git a/internal/verda-cli/cmd/serverless/batchjob_list.go b/internal/verda-cli/cmd/serverless/batchjob_list.go index 930d016..372c157 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_list.go +++ b/internal/verda-cli/cmd/serverless/batchjob_list.go @@ -48,7 +48,7 @@ func runBatchjobList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() - jobs, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading batch-job deployments...", func() ([]verda.JobDeploymentShortInfo, error) { + jobs, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading batch-job deployments...", func(ctx context.Context) ([]verda.JobDeploymentShortInfo, error) { return client.ServerlessJobs.GetJobDeployments(ctx) }) if err != nil { diff --git a/internal/verda-cli/cmd/serverless/container_actions.go b/internal/verda-cli/cmd/serverless/container_actions.go index 0db5046..a657eba 100644 --- a/internal/verda-cli/cmd/serverless/container_actions.go +++ b/internal/verda-cli/cmd/serverless/container_actions.go @@ -76,7 +76,7 @@ func runContainerAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() - err = cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("%s %s...", spinner, name), func() error { + err = cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("%s %s...", spinner, name), func(ctx context.Context) error { return fn(ctx, client, name) }) if err != nil { diff --git a/internal/verda-cli/cmd/serverless/container_create.go b/internal/verda-cli/cmd/serverless/container_create.go index 266ed30..5f8f197 100644 --- a/internal/verda-cli/cmd/serverless/container_create.go +++ b/internal/verda-cli/cmd/serverless/container_create.go @@ -210,7 +210,7 @@ func runContainerCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() - deployment, err := cmdutil.WithSpinner(ctx, f.Status(), "Creating container deployment...", func() (*verda.ContainerDeployment, error) { + deployment, err := cmdutil.WithSpinner(ctx, f.Status(), "Creating container deployment...", func(ctx context.Context) (*verda.ContainerDeployment, error) { return client.ContainerDeployments.CreateDeployment(ctx, req) }) if err != nil { @@ -230,8 +230,7 @@ func runContainerCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil func runContainerWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *containerCreateOptions) error { flow := buildContainerCreateFlow(ctx, f.VerdaClient, opts) engine := wizard.NewEngine(f.Prompter(), f.Status(), - wizard.WithOutput(ioStreams.ErrOut), - wizard.WithExitConfirmation()) + wizard.WithOutput(ioStreams.ErrOut)) return engine.Run(ctx, flow) } diff --git a/internal/verda-cli/cmd/serverless/container_delete.go b/internal/verda-cli/cmd/serverless/container_delete.go index eeea9bd..bda3104 100644 --- a/internal/verda-cli/cmd/serverless/container_delete.go +++ b/internal/verda-cli/cmd/serverless/container_delete.go @@ -74,7 +74,7 @@ func runContainerDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() - err = cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("Deleting %s...", name), func() error { + err = cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("Deleting %s...", name), func(ctx context.Context) error { return client.ContainerDeployments.DeleteDeployment(ctx, name, timeoutMs) }) if err != nil { diff --git a/internal/verda-cli/cmd/serverless/container_describe.go b/internal/verda-cli/cmd/serverless/container_describe.go index 36fb75f..ee5f2ca 100644 --- a/internal/verda-cli/cmd/serverless/container_describe.go +++ b/internal/verda-cli/cmd/serverless/container_describe.go @@ -66,7 +66,7 @@ func selectContainerDeployment(ctx context.Context, f cmdutil.Factory, ioStreams listCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) defer cancel() - deployments, err := cmdutil.WithSpinner(listCtx, f.Status(), "Loading container deployments...", func() ([]verda.ContainerDeployment, error) { + deployments, err := cmdutil.WithSpinner(listCtx, f.Status(), "Loading container deployments...", func(ctx context.Context) ([]verda.ContainerDeployment, error) { return client.ContainerDeployments.GetDeployments(listCtx) }) if err != nil { @@ -114,7 +114,7 @@ func runContainerDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdut // subordinate timeout so a slow status RPC can't blank it. Describe still // succeeds if the status RPC fails. var status string - deployment, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading deployment...", func() (*verda.ContainerDeployment, error) { + deployment, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading deployment...", func(ctx context.Context) (*verda.ContainerDeployment, error) { d, derr := client.ContainerDeployments.GetDeploymentByName(ctx, name) if derr != nil { return nil, derr diff --git a/internal/verda-cli/cmd/serverless/container_list.go b/internal/verda-cli/cmd/serverless/container_list.go index 2cd9f53..6ff9321 100644 --- a/internal/verda-cli/cmd/serverless/container_list.go +++ b/internal/verda-cli/cmd/serverless/container_list.go @@ -70,7 +70,7 @@ func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I defer cancel() statuses := newContainerStatusCache(containerStatusCacheTTL) - deployments, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading container deployments...", func() ([]verda.ContainerDeployment, error) { + deployments, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading container deployments...", func(ctx context.Context) ([]verda.ContainerDeployment, error) { return client.ContainerDeployments.GetDeployments(ctx) }) if err != nil { @@ -84,7 +84,7 @@ func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I // List response omits status; prefetch when filtering/structured/non-interactive, // otherwise LiveList fills rows lazily. if opts.Status != "" || !interactive { - _ = cmdutil.RunWithSpinner(ctx, f.Status(), "Loading statuses...", func() error { + _ = cmdutil.RunWithSpinner(ctx, f.Status(), "Loading statuses...", func(ctx context.Context) error { statuses.refresh(ctx, client, deployments) return nil }) @@ -203,7 +203,7 @@ func runContainerListEager( ) error { for { if statuses.anyStale(deployments) { - _ = cmdutil.RunWithSpinner(cmd.Context(), f.Status(), "Loading statuses...", func() error { + _ = cmdutil.RunWithSpinner(cmd.Context(), f.Status(), "Loading statuses...", func(ctx context.Context) error { refreshCtx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() statuses.refresh(refreshCtx, client, deployments) diff --git a/internal/verda-cli/cmd/serverless/wizard_cache.go b/internal/verda-cli/cmd/serverless/wizard_cache.go index 2626db9..1ba6089 100644 --- a/internal/verda-cli/cmd/serverless/wizard_cache.go +++ b/internal/verda-cli/cmd/serverless/wizard_cache.go @@ -18,31 +18,19 @@ import ( "context" "fmt" + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/pkg/tui" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" ) // withFetchSpinner runs fn while showing a spinner labeled msg. If status is // nil (e.g. tests with no TUI) or the spinner can't start, fn still runs. +// Ctrl+C on the spinner cancels fn's context (via cmdutil.WithSpinner). // Used by wizard loaders so the API calls hidden inside cache fetchers // (compute resources, registry creds, secrets) show progress instead of // looking like a hang while the API responds. func withFetchSpinner[T any](ctx context.Context, status tui.Status, msg string, fn func(context.Context) (T, error)) (T, error) { - var zero T - if status == nil { - return fn(ctx) - } - sp, err := status.Spinner(ctx, msg) - if err != nil { - return fn(ctx) - } - res, ferr := fn(ctx) - if ferr != nil { - sp.Stop("") - return zero, ferr - } - sp.Stop("") - return res, nil + return cmdutil.WithSpinner(ctx, status, msg, fn) } // clientFunc lazily resolves a Verda API client. Early wizard steps (name, diff --git a/internal/verda-cli/cmd/settings/theme.go b/internal/verda-cli/cmd/settings/theme.go index b28972f..8dab592 100644 --- a/internal/verda-cli/cmd/settings/theme.go +++ b/internal/verda-cli/cmd/settings/theme.go @@ -15,6 +15,7 @@ package settings import ( + "errors" "fmt" "slices" @@ -64,6 +65,13 @@ func selectThemeWizard(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. names := bubbletea.ThemeNames() slices.Sort(names) + + // The wizard engine drives its own terminal UI (it bypasses f.Prompter), + // so the factory's agentPrompter cannot block it — gate here instead. + if f.AgentMode() { + return cmdutil.NewPromptBlockedError("select", "Select theme", names) + } + choices := make([]wizard.Choice, len(names)) for i, name := range names { t := bubbletea.Themes[name] @@ -95,7 +103,10 @@ func selectThemeWizard(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) if err := engine.Run(cmd.Context(), flow); err != nil { - return nil //nolint:nilerr // User pressed Esc/Ctrl+C. + if errors.Is(err, wizard.ErrCancelled) { + return nil // User pressed Esc/Ctrl+C — clean exit. + } + return err } if selected == "" || selected == current { diff --git a/internal/verda-cli/cmd/sshkey/README.md b/internal/verda-cli/cmd/sshkey/README.md index 437a017..92305e5 100644 --- a/internal/verda-cli/cmd/sshkey/README.md +++ b/internal/verda-cli/cmd/sshkey/README.md @@ -6,7 +6,7 @@ |---------|-------------|-----------| | `verda ssh-key list` | List all SSH keys (Name, ID, Fingerprint) | _(none)_ | | `verda ssh-key add` | Add an SSH key to your account | `--name`, `--public-key` | -| `verda ssh-key delete` | Delete an SSH key from your account | `--id` | +| `verda ssh-key delete` | Delete an SSH key from your account | `--id`, `--yes` | ## Usage Examples @@ -32,6 +32,9 @@ verda ssh-key delete # Non-interactive verda ssh-key delete --id abc-123 + +# Agent mode (structured result, no prompts) +verda --agent ssh-key delete --id abc-123 --yes ``` ## Interactive vs Non-Interactive @@ -39,10 +42,10 @@ verda ssh-key delete --id abc-123 | Command | Non-interactive flags | Prompted when missing | |---------|----------------------|----------------------| | `add` | `--name`, `--public-key` | Name via text input, public key via text input | -| `delete` | `--id` | Fetches all keys, presents select list, then confirms | +| `delete` | `--id`, `--yes` | Fetches all keys, presents select list, then confirms | | `list` | _(always non-interactive)_ | N/A | -All destructive actions (`delete`) require confirmation even in non-interactive mode. +Destructive deletes ask for confirmation interactively; pass `--yes` to skip it. In agent mode (`--agent`), `--id` and `--yes` are required — without `--yes` the command fails with `CONFIRMATION_REQUIRED`, and success prints a structured JSON result. ## Architecture Notes diff --git a/internal/verda-cli/cmd/sshkey/add.go b/internal/verda-cli/cmd/sshkey/add.go index f68ab25..0b02ecf 100644 --- a/internal/verda-cli/cmd/sshkey/add.go +++ b/internal/verda-cli/cmd/sshkey/add.go @@ -75,7 +75,10 @@ func runAdd(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, if name == "" { name, err = prompter.TextInput(ctx, "SSH key name") if err != nil { - return nil + if cmdutil.IsPromptCancel(err) { + return nil // User pressed Esc/Ctrl+C. + } + return err } if name == "" { return errors.New("name is required") @@ -86,7 +89,10 @@ func runAdd(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, if publicKey == "" { publicKey, err = prompter.TextInput(ctx, "Public key (paste)") if err != nil { - return nil + if cmdutil.IsPromptCancel(err) { + return nil // User pressed Esc/Ctrl+C. + } + return err } if publicKey == "" { return errors.New("public key is required") diff --git a/internal/verda-cli/cmd/sshkey/delete.go b/internal/verda-cli/cmd/sshkey/delete.go index ba1cc2c..c8ae596 100644 --- a/internal/verda-cli/cmd/sshkey/delete.go +++ b/internal/verda-cli/cmd/sshkey/delete.go @@ -20,12 +20,14 @@ import ( "github.com/spf13/cobra" "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) type deleteOptions struct { - ID string + ID string + Yes bool } // NewCmdDelete creates the ssh-key delete cobra command. @@ -39,7 +41,7 @@ func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command Long: cmdutil.LongDesc(` Delete an SSH key from your account. In interactive mode you will be prompted to select a key and confirm deletion. Use --id for - non-interactive use. + non-interactive use. Agent mode requires --id and --yes. `), Example: cmdutil.Examples(` # Interactive @@ -47,6 +49,9 @@ func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command # Non-interactive verda ssh-key delete --id abc-123 + + # Agent mode + verda --agent ssh-key delete --id abc-123 --yes `), Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -55,11 +60,17 @@ func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command } cmd.Flags().StringVar(&opts.ID, "id", "", "SSH key ID to delete") + cmd.Flags().BoolVar(&opts.Yes, "yes", false, "Skip confirmation for destructive actions (required in agent mode)") return cmd } func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *deleteOptions) error { + // Agent mode never prompts: deleting without --yes is an explicit error. + if f.AgentMode() && !opts.Yes { + return cmdutil.NewConfirmationRequiredError("delete") + } + client, err := f.VerdaClient() if err != nil { return err @@ -71,51 +82,32 @@ func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream keyID := opts.ID keyName := keyID - if keyID == "" { //nolint:nestif // Interactive prompt flow requires nested conditionals. + if keyID == "" { // Interactive: list keys and let user select. - listCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) - defer cancel() - - var sp interface{ Stop(string) } - if status := f.Status(); status != nil { - sp, _ = status.Spinner(listCtx, "Loading SSH keys...") - } - keys, err := client.SSHKeys.GetAllSSHKeys(listCtx) - if sp != nil { - sp.Stop("") - } + id, name, err := selectKey(ctx, f, ioStreams, prompter, client) if err != nil { return err } - - if len(keys) == 0 { - _, _ = fmt.Fprintln(ioStreams.Out, "No SSH keys found.") - return nil + if id == "" { + return nil // Canceled or no keys available. } + keyID, keyName = id, name + } - labels := make([]string, 0, len(keys)+1) - for _, k := range keys { - labels = append(labels, fmt.Sprintf("%s %s %s", k.Name, k.ID, k.Fingerprint)) - } - labels = append(labels, "Cancel") - - idx, err := prompter.Select(ctx, "Select SSH key to delete", labels, tui.WithShowHints(true)) + // Confirm deletion. + if !opts.Yes { + confirmed, err := prompter.Confirm(ctx, fmt.Sprintf("Are you sure you want to delete SSH key %q?", keyName)) if err != nil { - return nil + if cmdutil.IsPromptCancel(err) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return err } - if idx == len(keys) { + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } - - keyID = keys[idx].ID - keyName = keys[idx].Name - } - - // Confirm deletion. - confirmed, err := prompter.Confirm(ctx, fmt.Sprintf("Are you sure you want to delete SSH key %q?", keyName)) - if err != nil || !confirmed { - _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") - return nil } cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deleting SSH key:", map[string]string{"id": keyID, "name": keyName}) @@ -135,6 +127,59 @@ func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream return err } + if f.AgentMode() { + result := map[string]string{ + "id": keyID, + "name": keyName, + "action": "delete", + "status": "completed", + } + _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), result) + return nil + } + _, _ = fmt.Fprintf(ioStreams.Out, "Deleted SSH key: %s (%s)\n", keyName, keyID) return nil } + +// selectKey lists SSH keys and prompts the user to pick one for deletion. +// Returns zero values when the user cancels or no keys exist. +func selectKey(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, prompter tui.Prompter, client *verda.Client) (keyID, keyName string, _ error) { + listCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) + defer cancel() + + var sp interface{ Stop(string) } + if status := f.Status(); status != nil { + sp, _ = status.Spinner(listCtx, "Loading SSH keys...") + } + keys, err := client.SSHKeys.GetAllSSHKeys(listCtx) + if sp != nil { + sp.Stop("") + } + if err != nil { + return "", "", err + } + + if len(keys) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No SSH keys found.") + return "", "", nil + } + + labels := make([]string, 0, len(keys)+1) + for _, k := range keys { + labels = append(labels, fmt.Sprintf("%s %s %s", k.Name, k.ID, k.Fingerprint)) + } + labels = append(labels, "Cancel") + + idx, err := prompter.Select(ctx, "Select SSH key to delete", labels, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", "", nil // Esc/Ctrl+C — clean exit. + } + return "", "", err + } + if idx == len(keys) { + return "", "", nil + } + return keys[idx].ID, keys[idx].Name, nil +} diff --git a/internal/verda-cli/cmd/sshkey/delete_test.go b/internal/verda-cli/cmd/sshkey/delete_test.go new file mode 100644 index 0000000..39aa87f --- /dev/null +++ b/internal/verda-cli/cmd/sshkey/delete_test.go @@ -0,0 +1,70 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package sshkey + +import ( + "bytes" + "testing" + + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// Agent mode must refuse delete without --yes (before touching the API). +func TestDeleteAgentModeRequiresYes(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf} + f := &cmdutil.TestFactory{AgentModeOverride: true} + + root := &cobra.Command{Use: "verda", SilenceUsage: true, SilenceErrors: true} + root.AddCommand(NewCmdSSHKey(f, ioStreams)) + root.SetArgs([]string{"ssh-key", "delete", "--id", "key-123"}) + + err := root.Execute() + if err == nil { + t.Fatal("expected error: agent mode delete requires --yes") + } + ae := cmdutil.ClassifyError(err) + if ae.Code != "CONFIRMATION_REQUIRED" { + t.Fatalf("code = %q, want CONFIRMATION_REQUIRED (err: %v)", ae.Code, err) + } +} + +func TestDeleteHasYesFlag(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf} + f := cmdutil.NewTestFactory(nil) + + keyCmd := NewCmdSSHKey(f, ioStreams) + + var deleteCmd *cobra.Command + for _, sub := range keyCmd.Commands() { + if sub.Name() == "delete" { + deleteCmd = sub + break + } + } + if deleteCmd == nil { + t.Fatal("delete subcommand not found") + } + if deleteCmd.Flags().Lookup("yes") == nil { + t.Error("delete missing --yes flag") + } +} diff --git a/internal/verda-cli/cmd/startupscript/README.md b/internal/verda-cli/cmd/startupscript/README.md index a6885ec..b9a2ef3 100644 --- a/internal/verda-cli/cmd/startupscript/README.md +++ b/internal/verda-cli/cmd/startupscript/README.md @@ -6,7 +6,7 @@ |---------|-------------|-----------| | `verda startup-script list` | List all startup scripts (Name, ID, Created) | _(none)_ | | `verda startup-script add` | Add a startup script | `--name`, `--file`, `--script` | -| `verda startup-script delete` | Delete a startup script | `--id` | +| `verda startup-script delete` | Delete a startup script | `--id`, `--yes` | ## Usage Examples @@ -35,6 +35,9 @@ verda startup-script delete # Non-interactive verda startup-script delete --id abc-123 + +# Agent mode (structured result, no prompts) +verda --agent startup-script delete --id abc-123 --yes ``` ## Interactive vs Non-Interactive @@ -42,10 +45,10 @@ verda startup-script delete --id abc-123 | Command | Non-interactive flags | Prompted when missing | |---------|----------------------|----------------------| | `add` | `--name`, `--file` or `--script` | Name via text input; script source via select ("Load from file" / "Paste content") | -| `delete` | `--id` | Fetches all scripts, presents select list, then confirms | +| `delete` | `--id`, `--yes` | Fetches all scripts, presents select list, then confirms | | `list` | _(always non-interactive)_ | N/A | -All destructive actions (`delete`) require confirmation even in non-interactive mode. +Destructive deletes ask for confirmation interactively; pass `--yes` to skip it. In agent mode (`--agent`), `--id` and `--yes` are required — without `--yes` the command fails with `CONFIRMATION_REQUIRED`, and success prints a structured JSON result. ## Architecture Notes diff --git a/internal/verda-cli/cmd/startupscript/add.go b/internal/verda-cli/cmd/startupscript/add.go index c7d7674..8cd9464 100644 --- a/internal/verda-cli/cmd/startupscript/add.go +++ b/internal/verda-cli/cmd/startupscript/add.go @@ -83,7 +83,10 @@ func runAdd(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, if name == "" { name, err = prompter.TextInput(ctx, "Script name") if err != nil { - return nil + if cmdutil.IsPromptCancel(err) { + return nil // User pressed Esc/Ctrl+C. + } + return err } if name == "" { return errors.New("name is required") @@ -102,32 +105,12 @@ func runAdd(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, content = opts.Script default: // Interactive: ask user to load from file or paste content. - sourceIdx, err := prompter.Select(ctx, "Script source", []string{ - "Load from file", - "Paste content", - }, tui.WithShowHints(true)) + content, err = promptScriptContent(ctx, prompter) if err != nil { - return nil + return err } - - switch sourceIdx { - case 0: // Load from file - path, err := prompter.TextInput(ctx, "File path") - if err != nil || strings.TrimSpace(path) == "" { - return nil - } - data, err := os.ReadFile(strings.TrimSpace(path)) - if err != nil { - return fmt.Errorf("reading script file: %w", err) - } - content = string(data) - case 1: // Paste content - content, err = prompter.Editor(ctx, "Script content", - tui.WithEditorDefault("#!/bin/bash\n\n# Your startup script here\n"), - tui.WithFileExt(".sh")) - if err != nil { - return nil - } + if content == "" { + return nil // User canceled or left input blank. } } @@ -159,3 +142,54 @@ func runAdd(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, _, _ = fmt.Fprintf(ioStreams.Out, "Added startup script: %s (%s)\n", script.Name, script.ID) return nil } + +// promptScriptContent asks the user for the script source and collects the +// content. Returns ("", nil) when the user cancels. +func promptScriptContent(ctx context.Context, prompter tui.Prompter) (string, error) { + sourceIdx, err := prompter.Select(ctx, "Script source", []string{ + "Load from file", + "Paste content", + }, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", nil // User pressed Esc/Ctrl+C. + } + return "", err + } + + if sourceIdx == 0 { // Load from file + return promptScriptFromFile(ctx, prompter) + } + + // Paste content + content, err := prompter.Editor(ctx, "Script content", + tui.WithEditorDefault("#!/bin/bash\n\n# Your startup script here\n"), + tui.WithFileExt(".sh")) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", nil // User pressed Esc/Ctrl+C. + } + return "", err + } + return content, nil +} + +// promptScriptFromFile asks for a file path and reads the script from it. +// Returns ("", nil) when the user cancels or leaves the path blank. +func promptScriptFromFile(ctx context.Context, prompter tui.Prompter) (string, error) { + path, err := prompter.TextInput(ctx, "File path") + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", nil // User pressed Esc/Ctrl+C. + } + return "", err + } + if strings.TrimSpace(path) == "" { + return "", nil + } + data, err := os.ReadFile(strings.TrimSpace(path)) + if err != nil { + return "", fmt.Errorf("reading script file: %w", err) + } + return string(data), nil +} diff --git a/internal/verda-cli/cmd/startupscript/delete.go b/internal/verda-cli/cmd/startupscript/delete.go index f9c6a54..623730d 100644 --- a/internal/verda-cli/cmd/startupscript/delete.go +++ b/internal/verda-cli/cmd/startupscript/delete.go @@ -20,12 +20,14 @@ import ( "github.com/spf13/cobra" "github.com/verda-cloud/verda-cli/pkg/tui" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) type deleteOptions struct { - ID string + ID string + Yes bool } // NewCmdDelete creates the startup-script delete cobra command. @@ -39,7 +41,7 @@ func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command Long: cmdutil.LongDesc(` Delete a startup script from your account. In interactive mode you will be prompted to select a script and confirm deletion. Use --id - for non-interactive use. + for non-interactive use. Agent mode requires --id and --yes. `), Example: cmdutil.Examples(` # Interactive @@ -47,6 +49,9 @@ func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command # Non-interactive verda startup-script delete --id abc-123 + + # Agent mode + verda --agent startup-script delete --id abc-123 --yes `), Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -55,11 +60,17 @@ func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command } cmd.Flags().StringVar(&opts.ID, "id", "", "Startup script ID to delete") + cmd.Flags().BoolVar(&opts.Yes, "yes", false, "Skip confirmation for destructive actions (required in agent mode)") return cmd } func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *deleteOptions) error { + // Agent mode never prompts: deleting without --yes is an explicit error. + if f.AgentMode() && !opts.Yes { + return cmdutil.NewConfirmationRequiredError("delete") + } + client, err := f.VerdaClient() if err != nil { return err @@ -71,51 +82,32 @@ func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream scriptID := opts.ID scriptName := scriptID - if scriptID == "" { //nolint:nestif // Interactive prompt flow requires nested conditionals. + if scriptID == "" { // Interactive: list scripts and let user select. - listCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) - defer cancel() - - var sp interface{ Stop(string) } - if status := f.Status(); status != nil { - sp, _ = status.Spinner(listCtx, "Loading startup scripts...") - } - scripts, err := client.StartupScripts.GetAllStartupScripts(listCtx) - if sp != nil { - sp.Stop("") - } + id, name, err := selectScript(ctx, f, ioStreams, prompter, client) if err != nil { return err } - - if len(scripts) == 0 { - _, _ = fmt.Fprintln(ioStreams.Out, "No startup scripts found.") - return nil + if id == "" { + return nil // Canceled or no scripts available. } + scriptID, scriptName = id, name + } - labels := make([]string, 0, len(scripts)+1) - for _, s := range scripts { - labels = append(labels, fmt.Sprintf("%s %s", s.Name, s.ID)) - } - labels = append(labels, "Cancel") - - idx, err := prompter.Select(ctx, "Select startup script to delete", labels, tui.WithShowHints(true)) + // Confirm deletion. + if !opts.Yes { + confirmed, err := prompter.Confirm(ctx, fmt.Sprintf("Are you sure you want to delete startup script %q?", scriptName)) if err != nil { - return nil + if cmdutil.IsPromptCancel(err) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return err } - if idx == len(scripts) { + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } - - scriptID = scripts[idx].ID - scriptName = scripts[idx].Name - } - - // Confirm deletion. - confirmed, err := prompter.Confirm(ctx, fmt.Sprintf("Are you sure you want to delete startup script %q?", scriptName)) - if err != nil || !confirmed { - _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") - return nil } cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deleting startup script:", map[string]string{"id": scriptID, "name": scriptName}) @@ -135,6 +127,59 @@ func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream return err } + if f.AgentMode() { + result := map[string]string{ + "id": scriptID, + "name": scriptName, + "action": "delete", + "status": "completed", + } + _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), result) + return nil + } + _, _ = fmt.Fprintf(ioStreams.Out, "Deleted startup script: %s (%s)\n", scriptName, scriptID) return nil } + +// selectScript lists startup scripts and prompts the user to pick one for +// deletion. Returns zero values when the user cancels or no scripts exist. +func selectScript(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, prompter tui.Prompter, client *verda.Client) (scriptID, scriptName string, _ error) { + listCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) + defer cancel() + + var sp interface{ Stop(string) } + if status := f.Status(); status != nil { + sp, _ = status.Spinner(listCtx, "Loading startup scripts...") + } + scripts, err := client.StartupScripts.GetAllStartupScripts(listCtx) + if sp != nil { + sp.Stop("") + } + if err != nil { + return "", "", err + } + + if len(scripts) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No startup scripts found.") + return "", "", nil + } + + labels := make([]string, 0, len(scripts)+1) + for _, s := range scripts { + labels = append(labels, fmt.Sprintf("%s %s", s.Name, s.ID)) + } + labels = append(labels, "Cancel") + + idx, err := prompter.Select(ctx, "Select startup script to delete", labels, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", "", nil // Esc/Ctrl+C — clean exit. + } + return "", "", err + } + if idx == len(scripts) { + return "", "", nil + } + return scripts[idx].ID, scripts[idx].Name, nil +} diff --git a/internal/verda-cli/cmd/startupscript/delete_test.go b/internal/verda-cli/cmd/startupscript/delete_test.go new file mode 100644 index 0000000..80bcebe --- /dev/null +++ b/internal/verda-cli/cmd/startupscript/delete_test.go @@ -0,0 +1,70 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package startupscript + +import ( + "bytes" + "testing" + + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// Agent mode must refuse delete without --yes (before touching the API). +func TestDeleteAgentModeRequiresYes(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf} + f := &cmdutil.TestFactory{AgentModeOverride: true} + + root := &cobra.Command{Use: "verda", SilenceUsage: true, SilenceErrors: true} + root.AddCommand(NewCmdStartupScript(f, ioStreams)) + root.SetArgs([]string{"startup-script", "delete", "--id", "script-123"}) + + err := root.Execute() + if err == nil { + t.Fatal("expected error: agent mode delete requires --yes") + } + ae := cmdutil.ClassifyError(err) + if ae.Code != "CONFIRMATION_REQUIRED" { + t.Fatalf("code = %q, want CONFIRMATION_REQUIRED (err: %v)", ae.Code, err) + } +} + +func TestDeleteHasYesFlag(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf} + f := cmdutil.NewTestFactory(nil) + + scriptCmd := NewCmdStartupScript(f, ioStreams) + + var deleteCmd *cobra.Command + for _, sub := range scriptCmd.Commands() { + if sub.Name() == "delete" { + deleteCmd = sub + break + } + } + if deleteCmd == nil { + t.Fatal("delete subcommand not found") + } + if deleteCmd.Flags().Lookup("yes") == nil { + t.Error("delete missing --yes flag") + } +} diff --git a/internal/verda-cli/cmd/status/status.go b/internal/verda-cli/cmd/status/status.go index 3650ec0..03dd7c2 100644 --- a/internal/verda-cli/cmd/status/status.go +++ b/internal/verda-cli/cmd/status/status.go @@ -182,8 +182,9 @@ func buildDashboard(instances []verda.Instance, volumes []verda.Volume, balance } // Offline instances still charge — include all non-terminated instances in burn rate. + // price_per_hour is the TOTAL hourly price: burn is the plain sum of totals. if inst.Status == verda.StatusRunning || inst.Status == verda.StatusOffline { - d.Financials.BurnRateHourly += cmdutil.InstanceTotalHourlyCost(inst) + d.Financials.BurnRateHourly += float64(inst.PricePerHour) } // Location tracking. diff --git a/internal/verda-cli/cmd/status/status_test.go b/internal/verda-cli/cmd/status/status_test.go index 61841d7..f24b7ef 100644 --- a/internal/verda-cli/cmd/status/status_test.go +++ b/internal/verda-cli/cmd/status/status_test.go @@ -24,10 +24,12 @@ import ( func TestBuildDashboard(t *testing.T) { t.Parallel() + // price_per_hour values are TOTAL hourly prices (staging fixture + // temp/docs/c1-ondemand-instance.json: CPU.4V.16G on-demand = 0.0279). instances := []verda.Instance{ - {ID: "i1", Status: verda.StatusRunning, Location: "FIN-01", PricePerHour: 0.10, IsSpot: false, GPU: verda.InstanceGPU{NumberOfGPUs: 2}}, - {ID: "i2", Status: verda.StatusRunning, Location: "FIN-01", PricePerHour: 0.05, IsSpot: true, GPU: verda.InstanceGPU{NumberOfGPUs: 1}}, - {ID: "i3", Status: verda.StatusRunning, Location: "US-TX-3", PricePerHour: 0.20, IsSpot: false, GPU: verda.InstanceGPU{NumberOfGPUs: 4}}, + {ID: "i1", Status: verda.StatusRunning, Location: "FIN-01", PricePerHour: 0.50, GPU: verda.InstanceGPU{NumberOfGPUs: 1}}, + {ID: "i2", Status: verda.StatusRunning, Location: "FIN-01", PricePerHour: 0.0279, IsSpot: true, CPU: verda.InstanceCPU{NumberOfCores: 4}}, + {ID: "i3", Status: verda.StatusRunning, Location: "US-TX-3", PricePerHour: 4.00, GPU: verda.InstanceGPU{NumberOfGPUs: 8}}, // 8x i1's rate, like the catalog {ID: "i4", Status: verda.StatusOffline, Location: "US-TX-5", PricePerHour: 0.10, GPU: verda.InstanceGPU{NumberOfGPUs: 1}}, } volumes := []verda.Volume{ @@ -67,13 +69,13 @@ func TestBuildDashboard(t *testing.T) { t.Fatalf("expected 120 GB total, got %d", d.Volumes.TotalSizeGB) } - // Financials: burn rate = price_per_hour * units for each instance + all volumes - // i1: 0.10 * 2 GPUs = 0.20, i2: 0.05 * 1 = 0.05, i3: 0.20 * 4 = 0.80, i4: 0.10 * 1 = 0.10 - // Instance total: 1.15 + // Financials: burn rate = sum of price_per_hour TOTALS + all volumes + // i1: 0.50, i2: 0.0279, i3: 4.00 (NOT 4.00 * 8 GPUs = 32.00), i4: 0.10 + // Instance total: 4.6279 // All volumes: 0.007 + 0.007 + 0.003 = 0.017 - expectedHourly := 1.167 + expectedHourly := 4.6449 if math.Abs(d.Financials.BurnRateHourly-expectedHourly) > 0.001 { - t.Fatalf("expected hourly burn rate ~$%.3f, got $%.4f", expectedHourly, d.Financials.BurnRateHourly) + t.Fatalf("expected hourly burn rate ~$%.4f, got $%.4f", expectedHourly, d.Financials.BurnRateHourly) } if math.Abs(d.Financials.BurnRateDaily-expectedHourly*24) > 0.1 { t.Fatalf("expected daily burn rate ~$%.2f, got $%.4f", expectedHourly*24, d.Financials.BurnRateDaily) @@ -84,9 +86,9 @@ func TestBuildDashboard(t *testing.T) { if d.Financials.Currency != "USD" { t.Fatalf("expected currency USD, got %s", d.Financials.Currency) } - // Runway = 847.23 / (1.167 * 24) ≈ 30 days - if d.Financials.RunwayDays < 25 || d.Financials.RunwayDays > 35 { - t.Fatalf("expected runway ~30 days, got %d", d.Financials.RunwayDays) + // Runway = 847.23 / (4.6449 * 24) ≈ 7 days + if d.Financials.RunwayDays < 6 || d.Financials.RunwayDays > 9 { + t.Fatalf("expected runway ~7 days, got %d", d.Financials.RunwayDays) } // Locations @@ -95,6 +97,23 @@ func TestBuildDashboard(t *testing.T) { } } +// TestBuildDashboardBurnRateUsesCatalogTotal (review C1 regression): an 8-GPU +// instance contributes exactly its catalog price_per_hour to the burn rate. +// A re-multiplication by GPU count breaks this exact equality (8 * 0.5 * 8). +func TestBuildDashboardBurnRateUsesCatalogTotal(t *testing.T) { + t.Parallel() + + instances := []verda.Instance{ + {ID: "gpu8", Status: verda.StatusRunning, Location: "FIN-01", PricePerHour: 4.00, GPU: verda.InstanceGPU{NumberOfGPUs: 8}}, + } + + d := buildDashboard(instances, nil, nil) + + if d.Financials.BurnRateHourly != 4.00 { + t.Fatalf("8-GPU burn contribution = $%.4f/hr, want catalog total $4.00/hr", d.Financials.BurnRateHourly) + } +} + func TestBuildDashboardEmpty(t *testing.T) { t.Parallel() diff --git a/internal/verda-cli/cmd/template/CLAUDE.md b/internal/verda-cli/cmd/template/CLAUDE.md index 3bd02bd..5c1fa47 100644 --- a/internal/verda-cli/cmd/template/CLAUDE.md +++ b/internal/verda-cli/cmd/template/CLAUDE.md @@ -108,7 +108,7 @@ Displays all template fields, including those previously hidden: - **Import cycle**: `cmd/template/` cannot import `cmd/vm/` for the Template type (circular dependency). Shared types live in `internal/verda-cli/template/`, re-exported by `cmd/template/types.go` via type aliases and `var` bindings. - **`billingTypeSet` / `locationSet` flags**: Needed because `IsSet` in the wizard can't distinguish `"on-demand"` (falsy `IsSpot=false`) from "unset". When a template sets billing type or location, these booleans are set to `true` so the wizard skips those steps. -- **Template without location triggers wizard**: When `--from` is used and the template has no location (`!opts.locationSet`), `resolveCreateInputs` triggers the wizard so the user is prompted for location instead of silently defaulting to FIN-01. +- **Template without location triggers wizard**: When `--from` is used and the template has no location (`!opts.locationSet`) and the user did not pass `--location`, `resolveCreateInputs` triggers the wizard so the user is prompted for location instead of silently defaulting to FIN-01. The wizard's "None (decide at deploy time)" choice is the `locationDecideLater` sentinel, which the step Setter translates to an unset location — the saved template stays locationless (engine Default substitution would otherwise persist FIN-01, review H5). - **Template instance type/location use different APIs**: Template wizard (create) uses instance-types API and locations API directly. Deploy wizard uses availability API to filter. Template edit also uses instance-types and locations APIs directly. - **Template error message**: `Resolve()` now shows `template name is required — template "X" not found` with guidance to run `verda template list` or use `--from` interactively. - **`NoOptDefVal` on `--from` flag**: Set to `" "` (space) so `--from` without a value is recognized as "flag changed but empty". When the user writes `verda vm create --from gpu-training`, cobra parses `gpu-training` as a positional arg; `RunE` recombines it into `opts.From`. diff --git a/internal/verda-cli/cmd/template/README.md b/internal/verda-cli/cmd/template/README.md index 9c7c4c1..9699056 100644 --- a/internal/verda-cli/cmd/template/README.md +++ b/internal/verda-cli/cmd/template/README.md @@ -10,7 +10,7 @@ Save, list, show, edit, and delete reusable resource configuration templates. Te | `verda template edit [resource/name]` | Edit specific fields of a template | _(none)_ | | `verda template list` | List all saved templates | `--type` | | `verda template show [resource/name]` | Display template details | `-o json` | -| `verda template delete [resource/name]` | Delete a template (with confirmation) | _(none)_ | +| `verda template delete [resource/name]` | Delete a template (with confirmation) | `--yes` | Aliases: `verda tmpl`, `verda tmpl ls` (list), `verda tmpl rm` (delete) diff --git a/internal/verda-cli/cmd/template/create.go b/internal/verda-cli/cmd/template/create.go index 7487334..06a6ad8 100644 --- a/internal/verda-cli/cmd/template/create.go +++ b/internal/verda-cli/cmd/template/create.go @@ -81,7 +81,10 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream // 1. Select resource type. idx, err := prompter.Select(ctx, "Resource type", resourceTypes, tui.WithShowHints(true)) if err != nil { - return nil //nolint:nilerr // user cancellation (Ctrl+C) is not an error + if cmdutil.IsPromptCancel(err) { + return nil // user cancellation (Ctrl+C/Esc) is not an error + } + return err } resource := resourceMap[idx] @@ -96,7 +99,10 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream if name == "" { name, err = prompter.TextInput(ctx, "Template name") if err != nil { - return nil //nolint:nilerr // user cancellation (Ctrl+C) is not an error + if cmdutil.IsPromptCancel(err) { + return nil // user cancellation (Ctrl+C/Esc) is not an error + } + return err } } diff --git a/internal/verda-cli/cmd/template/delete.go b/internal/verda-cli/cmd/template/delete.go index d6f728b..14e3cf0 100644 --- a/internal/verda-cli/cmd/template/delete.go +++ b/internal/verda-cli/cmd/template/delete.go @@ -24,6 +24,8 @@ import ( // NewCmdDelete creates the template delete command. func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + var yes bool + cmd := &cobra.Command{ Use: "delete [resource/name]", Aliases: []string{"rm"}, @@ -32,6 +34,7 @@ func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command Delete a saved resource configuration template. Without arguments, shows an interactive picker with confirmation. The argument must be in resource/name format (e.g. vm/gpu-training). + Agent mode requires the template argument and --yes. `), Example: cmdutil.Examples(` # Interactive picker @@ -42,20 +45,30 @@ func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command # Short alias verda tmpl rm vm/gpu-training + + # Agent mode + verda --agent template delete vm/gpu-training --yes `), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if len(args) == 1 { - return runDelete(cmd, f, ioStreams, args[0]) + return runDelete(cmd, f, ioStreams, args[0], yes) } - return runDeleteInteractive(cmd, f, ioStreams) + return runDeleteInteractive(cmd, f, ioStreams, yes) }, } + cmd.Flags().BoolVar(&yes, "yes", false, "Skip confirmation for destructive actions (required in agent mode)") + return cmd } -func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, ref string) error { +func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, ref string, yes bool) error { + // Agent mode never prompts: deleting without --yes is an explicit error. + if f.AgentMode() && !yes { + return cmdutil.NewConfirmationRequiredError("delete") + } + resource, name, err := parseRef(ref) if err != nil { return err @@ -72,26 +85,42 @@ func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream } // Confirm deletion. - prompter := f.Prompter() - confirmed, err := prompter.Confirm(cmd.Context(), fmt.Sprintf("Delete template %s/%s?", resource, name)) - if err != nil { - _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") - return nil //nolint:nilerr // user cancellation (Ctrl+C) is not an error - } - if !confirmed { - _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") - return nil + if !yes { + prompter := f.Prompter() + confirmed, err := prompter.Confirm(cmd.Context(), fmt.Sprintf("Delete template %s/%s?", resource, name)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return err + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } } if err := Delete(baseDir, resource, name); err != nil { return err } + if f.AgentMode() { + result := map[string]string{ + "resource": resource, + "name": name, + "action": "delete", + "status": "completed", + } + _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), result) + return nil + } + _, _ = fmt.Fprintf(ioStreams.Out, "Deleted template: %s/%s\n", resource, name) return nil } -func runDeleteInteractive(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams) error { +func runDeleteInteractive(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, yes bool) error { entry, err := pickTemplateEntry(cmd, f) if err != nil { return err @@ -99,5 +128,5 @@ func runDeleteInteractive(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdut if entry == nil { return nil // user canceled } - return runDelete(cmd, f, ioStreams, entry.Resource+"/"+entry.Name) + return runDelete(cmd, f, ioStreams, entry.Resource+"/"+entry.Name, yes) } diff --git a/internal/verda-cli/cmd/template/delete_test.go b/internal/verda-cli/cmd/template/delete_test.go new file mode 100644 index 0000000..2bc26ba --- /dev/null +++ b/internal/verda-cli/cmd/template/delete_test.go @@ -0,0 +1,70 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package template + +import ( + "bytes" + "testing" + + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// Agent mode must refuse delete without --yes (before touching the filesystem). +func TestDeleteAgentModeRequiresYes(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf} + f := &cmdutil.TestFactory{AgentModeOverride: true} + + root := &cobra.Command{Use: "verda", SilenceUsage: true, SilenceErrors: true} + root.AddCommand(NewCmdTemplate(f, ioStreams)) + root.SetArgs([]string{"template", "delete", "vm/gpu-training"}) + + err := root.Execute() + if err == nil { + t.Fatal("expected error: agent mode delete requires --yes") + } + ae := cmdutil.ClassifyError(err) + if ae.Code != "CONFIRMATION_REQUIRED" { + t.Fatalf("code = %q, want CONFIRMATION_REQUIRED (err: %v)", ae.Code, err) + } +} + +func TestDeleteHasYesFlag(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf} + f := cmdutil.NewTestFactory(nil) + + tmplCmd := NewCmdTemplate(f, ioStreams) + + var deleteCmd *cobra.Command + for _, sub := range tmplCmd.Commands() { + if sub.Name() == "delete" { + deleteCmd = sub + break + } + } + if deleteCmd == nil { + t.Fatal("delete subcommand not found") + } + if deleteCmd.Flags().Lookup("yes") == nil { + t.Error("delete missing --yes flag") + } +} diff --git a/internal/verda-cli/cmd/template/edit.go b/internal/verda-cli/cmd/template/edit.go index a6fa473..65303e8 100644 --- a/internal/verda-cli/cmd/template/edit.go +++ b/internal/verda-cli/cmd/template/edit.go @@ -102,8 +102,10 @@ func runEdit(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, idx, selErr := prompter.Select(ctx, "Edit field", labels, tui.WithSelectDefault(len(fields)), tui.WithPageSize(len(labels)), tui.WithShowHints(true)) if selErr != nil { - // Ctrl+C — save what we have - break + if cmdutil.IsPromptCancel(selErr) { + break // Ctrl+C/Esc — save what we have + } + return selErr } if idx == len(fields) { @@ -139,7 +141,10 @@ func buildFieldMenu(tmpl *Template) []editableField { choices := []string{"on-demand", "spot"} idx, err := f.Prompter().Select(ctx, "Billing type", choices, tui.WithShowHints(true)) if err != nil { - return nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(err) { + return nil // user canceled + } + return err } t.BillingType = choices[idx] if t.BillingType == "spot" { @@ -155,7 +160,10 @@ func buildFieldMenu(tmpl *Template) []editableField { choices := []string{"gpu", "cpu"} idx, err := f.Prompter().Select(ctx, "Kind", choices, tui.WithShowHints(true)) if err != nil { - return nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(err) { + return nil // user canceled + } + return err } t.Kind = choices[idx] // Clear instance type and image when kind changes — they @@ -193,7 +201,10 @@ func buildFieldMenu(tmpl *Template) []editableField { } val, err := f.Prompter().TextInput(ctx, "OS volume size (GiB)", tui.WithDefault(current)) if err != nil { - return nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(err) { + return nil // user canceled + } + return err } if val != "" { n, parseErr := strconv.Atoi(val) @@ -232,7 +243,10 @@ func buildFieldMenu(tmpl *Template) []editableField { } val, err := f.Prompter().TextInput(ctx, "Hostname pattern ({random}, {location})", tui.WithDefault(hint)) if err != nil { - return nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(err) { + return nil // user canceled + } + return err } t.HostnamePattern = val return nil @@ -244,7 +258,10 @@ func buildFieldMenu(tmpl *Template) []editableField { edit: func(ctx context.Context, f cmdutil.Factory, t *Template) error { val, err := f.Prompter().TextInput(ctx, "Description", tui.WithDefault(t.Description)) if err != nil { - return nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(err) { + return nil // user canceled + } + return err } t.Description = val return nil @@ -276,7 +293,7 @@ func editInstanceType(ctx context.Context, f cmdutil.Factory, t *Template) error return err } isSpot := t.BillingType == "spot" - types, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading instance types...", func() ([]verda.InstanceTypeInfo, error) { + types, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading instance types...", func(ctx context.Context) ([]verda.InstanceTypeInfo, error) { return client.InstanceTypes.Get(ctx, "usd") }) if err != nil { @@ -304,8 +321,14 @@ func editInstanceType(ctx context.Context, f cmdutil.Factory, t *Template) error choices = append(choices, "← Back") idx, selErr := f.Prompter().Select(ctx, "Instance type", choices, tui.WithShowHints(true)) - if selErr != nil || idx == len(values) { - return nil //nolint:nilerr // user canceled or back + if selErr != nil { + if cmdutil.IsPromptCancel(selErr) { + return nil // user canceled + } + return selErr + } + if idx == len(values) { + return nil // ← Back } t.InstanceType = values[idx] return nil @@ -316,7 +339,7 @@ func editLocation(ctx context.Context, f cmdutil.Factory, t *Template) error { if err != nil { return err } - locations, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading locations...", func() ([]verda.Location, error) { + locations, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading locations...", func(ctx context.Context) ([]verda.Location, error) { return client.Locations.Get(ctx) }) if err != nil { @@ -330,7 +353,10 @@ func editLocation(ctx context.Context, f cmdutil.Factory, t *Template) error { idx, selErr := f.Prompter().Select(ctx, "Location", choices, tui.WithShowHints(true)) if selErr != nil { - return nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(selErr) { + return nil // user canceled + } + return selErr } if idx == 0 { t.Location = "" @@ -346,7 +372,7 @@ func editImage(ctx context.Context, f cmdutil.Factory, t *Template) error { return err } // Filter images by instance type when available. - images, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading images...", func() ([]verda.Image, error) { + images, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading images...", func(ctx context.Context) ([]verda.Image, error) { if t.InstanceType != "" { return client.Images.GetImagesByInstanceType(ctx, t.InstanceType) } @@ -366,7 +392,10 @@ func editImage(ctx context.Context, f cmdutil.Factory, t *Template) error { idx, selErr := f.Prompter().Select(ctx, "Image", choices, tui.WithShowHints(true)) if selErr != nil { - return nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(selErr) { + return nil // user canceled + } + return selErr } t.Image = choices[idx] return nil @@ -377,7 +406,7 @@ func editSSHKeys(ctx context.Context, f cmdutil.Factory, t *Template) error { if err != nil { return err } - keys, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading SSH keys...", func() ([]verda.SSHKey, error) { + keys, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading SSH keys...", func(ctx context.Context) ([]verda.SSHKey, error) { return client.SSHKeys.GetAllSSHKeys(ctx) }) if err != nil { @@ -403,7 +432,10 @@ func editSSHKeys(ctx context.Context, f cmdutil.Factory, t *Template) error { selected, selErr := f.Prompter().MultiSelect(ctx, "SSH keys to inject", choices, tui.WithMultiSelectDefaults(defaults)) if selErr != nil { - return nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(selErr) { + return nil // user canceled + } + return selErr } t.SSHKeys = make([]string, len(selected)) @@ -418,7 +450,7 @@ func editStartupScript(ctx context.Context, f cmdutil.Factory, t *Template) erro if err != nil { return err } - scripts, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading startup scripts...", func() ([]verda.StartupScript, error) { + scripts, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading startup scripts...", func(ctx context.Context) ([]verda.StartupScript, error) { return client.StartupScripts.GetAllStartupScripts(ctx) }) if err != nil { @@ -432,7 +464,10 @@ func editStartupScript(ctx context.Context, f cmdutil.Factory, t *Template) erro idx, selErr := f.Prompter().Select(ctx, "Startup script", choices, tui.WithShowHints(true)) if selErr != nil { - return nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(selErr) { + return nil // user canceled + } + return selErr } if idx == 0 { diff --git a/internal/verda-cli/cmd/template/show.go b/internal/verda-cli/cmd/template/show.go index 1706c2b..77e8cbe 100644 --- a/internal/verda-cli/cmd/template/show.go +++ b/internal/verda-cli/cmd/template/show.go @@ -176,7 +176,10 @@ func pickTemplateEntry(cmd *cobra.Command, f cmdutil.Factory) (*Entry, error) { idx, err := f.Prompter().Select(cmd.Context(), "Select a template", labels, tui.WithShowHints(true)) if err != nil { - return nil, nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(err) { + return nil, nil // user canceled + } + return nil, err } return &entries[idx], nil } diff --git a/internal/verda-cli/cmd/update/CLAUDE.md b/internal/verda-cli/cmd/update/CLAUDE.md index 473a424..d8406cd 100644 --- a/internal/verda-cli/cmd/update/CLAUDE.md +++ b/internal/verda-cli/cmd/update/CLAUDE.md @@ -2,17 +2,52 @@ ## Quick Reference - Parent: `verda update` (no aliases) -- Subcommands: none (single command with `--list` and `--target` flags) +- Subcommands: none (single command with `--target`, `--list`, `--verify`, `--skip-verify` flags) - Files: - - `update.go` -- All logic: command def, GitHub API, archive extraction, binary replacement + - `update.go` -- Command def, GitHub API, archive download+verification, archive extraction, binary replacement + - `verify.go` -- Checksum fetch/hash/match helpers; `--verify` flow (installed binary vs release binary sums) ## Domain-Specific Logic +### Integrity Verification (fail closed, on by default) +- `runUpdate` verifies the downloaded ARCHIVE against the release's archive sums + file `verda__SHA256SUMS` (goreleaser `checksum` pipe output, always uploaded + with the release) BEFORE extracting or replacing anything. Sums entry key = + exact archive artifact name (`findArchiveChecksum`). +- ANY failure aborts without touching the installed binary: hash mismatch, + sums fetch error (404/500/network), or missing sums asset. The error names + `--skip-verify` as the explicit escape hatch (`checksumAbort`). +- Archive sums are verified (not binary sums): the update path downloads the + archive, so the checked artifact is the exact downloaded bytes — earliest + possible abort, and the same sums file `scripts/install.sh` verifies against + (`VERDA_INSTALL_SKIP_VERIFY=1` escape hatch there, `VERDA_INSTALL_BASE_URL` + overrides the asset base URL for testing). +- Binary sums (`verda__binary_SHA256SUMS`, generated by a `release.yml` + post-step with dist-dir keys like `verda_linux_amd64_v1/verda`) remain for + `--verify` of an already-installed binary (`findMatchingChecksum` prefix match). + +### Cosign verification (documented next step — NOT implemented) +- `release.yml` signs BOTH sums files with cosign keyless and publishes + `.sig` + `.pem` assets. Neither `update` nor `install.sh` verifies the + signature yet — current checks are integrity-only (a compromised release + assets page could serve doctored sums). +- Next step: verify `verda__SHA256SUMS` against its cosign bundle with + `--certificate-identity-regexp "^https://github\.com/verda-cloud/verda-cli/\.github/workflows/release\.yml@refs/.*$"` + and OIDC issuer `https://token.actions.githubusercontent.com`. Deferred + because it pulls in the sigstore dependency tree (heavy); pick a minimal + verifier lib or shell out to cosign with a graceful fallback story. + ### Version Resolution - Current version from `version.Get().GitVersion` (from `pkg/version`) - Auto-prepends `v` prefix if missing from `--target` flag - Skips update if target == current +### Output contract +- Success outcome is an `updateResult` written via `cmdutil.WriteStructured` + in `-o json`/`--agent` mode (agent mode forces json; spinners are nil there). + Errors surface through the central agent-error envelope in `main.go`. + Human/table mode keeps the plain `Updated to vX.Y.Z` line. + ### Asset Matching - Asset name: `verda_{versionWithoutV}_{runtime.GOOS}_{runtime.GOARCH}.{ext}` - ext = `tar.gz` (non-Windows) or `zip` (Windows) @@ -30,10 +65,17 @@ - `--list` fetches up to 20 releases (`per_page=20`) - If no asset matches the current OS/arch, returns a clear error with the expected asset name - `runUpdate` uses `f.Debug()` for debug output but `runList` does not (list is simpler) +- `apiBase` is a package var (not const) so tests can point at an httptest + fixture; `executablePath` wraps `resolveExecutable` for the same reason — + a happy-path `runUpdate` test would otherwise rewrite the running test binary. +- Updating to a PRE-checksum-era release (sums asset missing) fails closed — + such updates require `--skip-verify`. ## Relationships -- Imports `cmdutil` (`internal/verda-cli/cmd/util`) for Factory, IOStreams, DebugJSON, LongDesc, Examples +- Imports `cmdutil` (`internal/verda-cli/cmd/util`) for Factory, IOStreams, DebugJSON, WriteStructured, LongDesc, Examples - Imports `version` from `pkg/version` for current version info - Does NOT use the Verda API client -- only GitHub API via raw HTTP - No dependency on the Verda SDK (`verdacloud-sdk-go`) at all - Uses standard library only for HTTP, archive handling, and file operations +- Installer twin: `scripts/install.sh` verifies the same archive sums file + (`awk` selects the one asset line; `sha256sum -c`/`shasum -a 256 -c`) diff --git a/internal/verda-cli/cmd/update/README.md b/internal/verda-cli/cmd/update/README.md index c74e2ae..6a4b430 100644 --- a/internal/verda-cli/cmd/update/README.md +++ b/internal/verda-cli/cmd/update/README.md @@ -6,7 +6,7 @@ This is a single command (no subcommands). | Command | Description | Key Flags | |---------|-------------|-----------| -| `verda update` | Update CLI binary in-place from GitHub Releases | `--target`, `--list` | +| `verda update` | Update CLI binary in-place from GitHub Releases | `--target`, `--list`, `--verify`, `--skip-verify` | ## Usage Examples @@ -19,6 +19,12 @@ verda update --target v1.0.0 # List available versions (marks current with *) verda update --list + +# Verify the installed binary against the release's binary checksums +verda update --verify + +# Bypass checksum verification (NOT recommended; escape hatch only) +verda update --skip-verify ``` ## Interactive vs Non-Interactive @@ -27,19 +33,41 @@ This command is entirely non-interactive. No prompts are used. Behavior is contr - No flags: fetches and installs the latest release. - `--target `: installs the specified version. Accepts with or without `v` prefix. - `--list`: prints up to 20 available versions and exits. The current version is marked with `*`. +- `--verify`: checks the installed binary against the release's binary checksums and exits. +- `--skip-verify`: skips the default archive checksum verification (see below). If already at the target version, it prints "Already at vX.Y.Z" and exits. +In `-o json` / `--agent` mode the outcome is a structured `updateResult` +(`version`, `previousVersion`, `path`, `updated`, `checksumVerified`) instead +of the plain text lines. + +## Integrity Verification + +Verification is ON by default and fails closed: the downloaded archive is +checked against the release's `verda__SHA256SUMS` (published by +goreleaser) before the running binary is replaced. Any failure — hash +mismatch, or the checksum file being unreachable/unparseable — aborts the +update and leaves the existing binary untouched; the error names +`--skip-verify` as the escape hatch. + +`curl | sh` installs via `scripts/install.sh` verify the same sums file +(`sha256sum -c` / `shasum -a 256 -c`). Escape hatch there: +`VERDA_INSTALL_SKIP_VERIFY=1`. The release trusts cosign signatures for +authenticity; CLI-side cosign verification is a documented next step (see +`CLAUDE.md` in this directory) and intentionally not implemented yet. ## Architecture Notes -- **update.go** -- Single file containing all logic: command definition, GitHub API interaction, archive extraction, and binary replacement. +- **update.go** -- Command definition, GitHub API interaction, archive download and verification, archive extraction, binary replacement. +- **verify.go** -- Checksum helpers (fetch, parse, hash, match) plus the `--verify` flow that checks an installed binary against the release's binary sums. ### Update Flow 1. Resolve target version (latest via API, or from `--target` flag) 2. Compare with current version from `version.Get().GitVersion` 3. Download platform-specific archive asset from GitHub Releases -4. Extract binary from tar.gz (Linux/macOS) or zip (Windows) -5. Atomic binary replacement: write to temp file, chmod 0755, rename over current executable +4. Verify archive bytes against the release's `verda__SHA256SUMS` (skipped only with `--skip-verify`); abort without replacing on any failure +5. Extract binary from tar.gz (Linux/macOS) or zip (Windows) +6. Atomic binary replacement: write to temp file, chmod 0755, rename over current executable ### GitHub API - Base URL: `https://api.github.com` @@ -56,6 +84,10 @@ If already at the target version, it prints "Already at vX.Y.Z" and exits. - Version is without `v` prefix (e.g., `1.0.0`) - Extension: `tar.gz` on Linux/macOS, `zip` on Windows - Binary name inside archive: `verda` (or `verda.exe` on Windows) +- Checksum assets on every release: `verda_{version}_SHA256SUMS` (archive + checksums; used by the update path and install.sh) and + `verda_{version}_binary_SHA256SUMS` (unpacked-binary checksums; used by + `--verify`), each with cosign `.sig`/`.pem` bundles (not yet verified CLI-side) ### Binary Replacement Strategy - Resolves symlinks via `filepath.EvalSymlinks` to find the real executable path diff --git a/internal/verda-cli/cmd/update/update.go b/internal/verda-cli/cmd/update/update.go index 8972a1d..fe64b4a 100644 --- a/internal/verda-cli/cmd/update/update.go +++ b/internal/verda-cli/cmd/update/update.go @@ -42,16 +42,31 @@ import ( const ( repo = "verda-cloud/verda-cli" - apiBase = "https://api.github.com" httpTimeout = 60 * time.Second osWindows = "windows" + zipExt = "zip" + + binNameUnix = "verda" + binNameWin = "verda.exe" ) +// apiBase is a var so tests can point the GitHub client at a fixture server. +var apiBase = "https://api.github.com" + +// platformBinaryName returns the installed binary name for the current OS. +func platformBinaryName() string { + if runtime.GOOS == osWindows { + return binNameWin + } + return binNameUnix +} + // NewCmdUpdate creates the update command. func NewCmdUpdate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { var targetVersion string var listVersions bool var verify bool + var skipVerify bool cmd := &cobra.Command{ Use: "update", @@ -62,6 +77,11 @@ func NewCmdUpdate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command The binary is installed to ~/.verda/bin/ (no sudo required). + The downloaded archive is verified against the release's SHA256SUMS + before the running binary is replaced; on any verification failure + the update aborts and the binary is left untouched. Use --skip-verify + only as a deliberate escape hatch. + Without flags, updates to the latest version. Use --target to install a specific version (upgrade or downgrade). Use --list to show available versions. @@ -83,23 +103,24 @@ func NewCmdUpdate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command } if verify { info := version.Get() - return runVerify(ioStreams.Out, ioStreams.ErrOut, f.OutputFormat(), f.HTTPClient(), info.GitVersion, runtime.GOOS, runtime.GOARCH) + verifyCtx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + return runVerify(verifyCtx, ioStreams.Out, ioStreams.ErrOut, f.OutputFormat(), f.HTTPClient(), info.GitVersion, runtime.GOOS, runtime.GOARCH) } - return runUpdate(cmd.Context(), f, ioStreams, targetVersion) + return runUpdate(cmd.Context(), f, ioStreams, targetVersion, skipVerify) }, } cmd.Flags().StringVar(&targetVersion, "target", "", "Version to install (e.g. v1.0.0)") cmd.Flags().BoolVar(&listVersions, "list", false, "List available versions") cmd.Flags().BoolVar(&verify, "verify", false, "Verify the binary checksum against the GitHub release") + cmd.Flags().BoolVar(&skipVerify, "skip-verify", false, "Skip checksum verification of the downloaded release archive (NOT recommended)") return cmd } func runList(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams) error { - versions, err := cmdutil.WithSpinner(ctx, f.Status(), "Fetching available versions...", func() ([]string, error) { - return fetchVersions(ctx) - }) + versions, err := cmdutil.WithSpinner(ctx, f.Status(), "Fetching available versions...", fetchVersions) if err != nil { return err } @@ -119,7 +140,17 @@ func runList(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams return nil } -func runUpdate(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, targetVersion string) error { +// updateResult is the machine-readable (-o json / --agent) update outcome. +type updateResult struct { + Version string `json:"version"` + PreviousVersion string `json:"previousVersion"` + Path string `json:"path,omitempty"` + Updated bool `json:"updated"` + ChecksumVerified bool `json:"checksumVerified"` +} + +func runUpdate(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, targetVersion string, skipVerify bool) error { + format := f.OutputFormat() current := version.Get().GitVersion if !strings.HasPrefix(current, "v") { current = "v" + current @@ -128,9 +159,7 @@ func runUpdate(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStrea // Resolve target version. target := targetVersion if target == "" { - latest, err := cmdutil.WithSpinner(ctx, f.Status(), "Checking for latest version...", func() (string, error) { - return fetchLatestVersion(ctx) - }) + latest, err := cmdutil.WithSpinner(ctx, f.Status(), "Checking for latest version...", fetchLatestVersion) if err != nil { return err } @@ -141,6 +170,10 @@ func runUpdate(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStrea } if target == current { + res := updateResult{Version: current, PreviousVersion: current} + if wrote, err := cmdutil.WriteStructured(ioStreams.Out, format, res); wrote { + return err + } _, _ = fmt.Fprintf(ioStreams.Out, "Already at %s\n", current) return nil } @@ -154,12 +187,12 @@ func runUpdate(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStrea "arch": runtime.GOARCH, }) - // Download. + // Download (checksum-verified unless --skip-verify). var sp interface{ Stop(string) } if status := f.Status(); status != nil { sp, _ = status.Spinner(ctx, fmt.Sprintf("Downloading %s...", target)) } - binary, err := downloadRelease(ctx, target) + binary, err := downloadRelease(ctx, target, skipVerify) if sp != nil { sp.Stop("") } @@ -172,24 +205,33 @@ func runUpdate(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStrea if err != nil { return fmt.Errorf("preparing install directory: %w", err) } - binaryName := "verda" - if runtime.GOOS == osWindows { - binaryName = "verda.exe" - } - dst := filepath.Join(binDir, binaryName) + dst := filepath.Join(binDir, platformBinaryName()) if err := replaceBinary(dst, binary); err != nil { return fmt.Errorf("replacing binary: %w", err) } - _, _ = fmt.Fprintf(ioStreams.Out, "Updated to %s\n", target) + res := updateResult{ + Version: target, + PreviousVersion: current, + Path: dst, + Updated: true, + ChecksumVerified: !skipVerify, + } + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, format, res); wrote { + if werr != nil { + return werr + } + } else { + _, _ = fmt.Fprintf(ioStreams.Out, "Updated to %s\n", target) + } // Update installed skills if any agents have them. updateInstalledSkills(ctx, dst, ioStreams) // Migrate: if the currently running binary is outside ~/.verda/bin/, // handle the old location based on how it was installed. - oldExe, _ := resolveExecutable() + oldExe, _ := executablePath() if oldExe != "" && oldExe != dst { if isManagedByPackageManager(oldExe) { // Installed via Homebrew, apt, rpm, etc. — don't touch it. @@ -256,7 +298,7 @@ func fetchVersions(ctx context.Context) ([]string, error) { return versions, nil } -func downloadRelease(ctx context.Context, tag string) ([]byte, error) { +func downloadRelease(ctx context.Context, tag string, skipVerify bool) ([]byte, error) { // Fetch release to get asset URLs. url := fmt.Sprintf("%s/repos/%s/releases/tags/%s", apiBase, repo, tag) var rel ghRelease @@ -268,24 +310,51 @@ func downloadRelease(ctx context.Context, tag string) ([]byte, error) { versionNum := strings.TrimPrefix(tag, "v") ext := "tar.gz" if runtime.GOOS == osWindows { - ext = "zip" + ext = zipExt } assetName := fmt.Sprintf("verda_%s_%s_%s.%s", versionNum, runtime.GOOS, runtime.GOARCH, ext) - var downloadURL string - for i := range rel.Assets { - if rel.Assets[i].Name == assetName { - downloadURL = rel.Assets[i].BrowserDownloadURL - break - } - } + downloadURL := findAssetURL(&rel, assetName) if downloadURL == "" { return nil, fmt.Errorf("no asset %q found in release %s", assetName, tag) } // Download the archive. client := &http.Client{Timeout: httpTimeout} - req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, http.NoBody) + archiveData, err := downloadAsset(ctx, client, downloadURL) + if err != nil { + return nil, err + } + + // Integrity gate: verify the archive bytes against the release's archive + // sums (goreleaser checksum pipe output verda__SHA256SUMS) before + // anything is extracted or replaced. Fails closed unless --skip-verify. + if !skipVerify { + if err := verifyReleaseArchive(ctx, client, &rel, archiveData, assetName, versionNum); err != nil { + return nil, err + } + } + + // Extract the binary from the archive. + binaryName := platformBinaryName() + if ext == zipExt { + return extractFromZip(archiveData, binaryName) + } + return extractFromTarGz(archiveData, binaryName) +} + +// findAssetURL returns the browser download URL of the named release asset. +func findAssetURL(rel *ghRelease, name string) string { + for i := range rel.Assets { + if rel.Assets[i].Name == name { + return rel.Assets[i].BrowserDownloadURL + } + } + return "" +} + +func downloadAsset(ctx context.Context, client *http.Client, url string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return nil, err } @@ -297,22 +366,31 @@ func downloadRelease(ctx context.Context, tag string) ([]byte, error) { if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("download failed: HTTP %d", resp.StatusCode) } + return io.ReadAll(resp.Body) +} - archiveData, err := io.ReadAll(resp.Body) +// verifyReleaseArchive checks the downloaded archive against the release's +// archive sums file. Any fetch/parse/mismatch failure aborts the update. +func verifyReleaseArchive(ctx context.Context, client *http.Client, rel *ghRelease, archiveData []byte, assetName, versionNum string) error { + sumsName := fmt.Sprintf("verda_%s_SHA256SUMS", versionNum) + sumsURL := findAssetURL(rel, sumsName) + if sumsURL == "" { + return checksumAbort(fmt.Errorf("checksum file %q not found in release assets", sumsName)) + } + sumsBody, err := fetchChecksums(ctx, client, sumsURL) if err != nil { - return nil, err + return checksumAbort(err) } - - // Extract the binary from the archive. - binaryName := "verda" - if runtime.GOOS == osWindows { - binaryName = "verda.exe" + if err := verifyArchiveChecksum(archiveData, sumsBody, assetName); err != nil { + return checksumAbort(err) } + return nil +} - if ext == "zip" { - return extractFromZip(archiveData, binaryName) - } - return extractFromTarGz(archiveData, binaryName) +// checksumAbort wraps a verification failure so the message names the +// --skip-verify escape hatch and states that the binary was left untouched. +func checksumAbort(err error) error { + return fmt.Errorf("checksum verification failed: %w; update aborted, binary left untouched (use --skip-verify to bypass)", err) } func extractFromTarGz(data []byte, name string) ([]byte, error) { @@ -384,6 +462,10 @@ func isManagedByPackageManager(exePath string) bool { // --- Binary replacement --- +// executablePath is wrapped so update tests can stub the running-binary path; +// otherwise a happy-path runUpdate test would try to rewrite the test binary. +var executablePath = resolveExecutable + func resolveExecutable() (string, error) { exe, err := os.Executable() if err != nil { diff --git a/internal/verda-cli/cmd/update/update_test.go b/internal/verda-cli/cmd/update/update_test.go new file mode 100644 index 0000000..2ed4bec --- /dev/null +++ b/internal/verda-cli/cmd/update/update_test.go @@ -0,0 +1,288 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package update + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// These tests override the package-level apiBase, so they must not run +// in parallel with each other (other tests in this package never read it). + +const fakeTag = "v9.9.9" + +type fakeRelease struct { + tamperSums bool // sums entry carries a hash that does not match the archive + sumsStatus int // serve this non-200 status from the sums endpoint instead of a body + omitSums bool // checksum file absent from the release asset list +} + +// setupFakeRelease hosts a fake GitHub release API plus asset downloads and +// points apiBase at it. Returns the binary payload embedded in the archive. +func setupFakeRelease(t *testing.T, fr fakeRelease) []byte { + t.Helper() + + binaryContent := []byte("fake verda binary payload") + binaryName := platformBinaryName() + ext := "tar.gz" + if runtime.GOOS == osWindows { + ext = zipExt + } + assetName := fmt.Sprintf("verda_9.9.9_%s_%s.%s", runtime.GOOS, runtime.GOARCH, ext) + sumsName := "verda_9.9.9_SHA256SUMS" + archive := buildArchive(t, ext, binaryName, binaryContent) + + sum := sha256.Sum256(archive) + hash := hex.EncodeToString(sum[:]) + if fr.tamperSums { + sum = sha256.Sum256([]byte("not the archive")) + hash = hex.EncodeToString(sum[:]) + } + sumsBody := fmt.Sprintf("%s %s\n", hash, assetName) + + mux := http.NewServeMux() + var srv *httptest.Server + mux.HandleFunc("/repos/"+repo+"/releases/tags/"+fakeTag, func(w http.ResponseWriter, _ *http.Request) { + assets := []ghAsset{ + {Name: assetName, BrowserDownloadURL: srv.URL + "/dl/" + assetName}, + } + if !fr.omitSums { + assets = append(assets, ghAsset{Name: sumsName, BrowserDownloadURL: srv.URL + "/dl/" + sumsName}) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(ghRelease{TagName: fakeTag, Assets: assets}) + }) + mux.HandleFunc("/dl/"+assetName, func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write(archive) + }) + mux.HandleFunc("/dl/"+sumsName, func(w http.ResponseWriter, _ *http.Request) { + if fr.sumsStatus != 0 { + w.WriteHeader(fr.sumsStatus) + return + } + _, _ = w.Write([]byte(sumsBody)) + }) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + oldBase := apiBase + apiBase = srv.URL + t.Cleanup(func() { apiBase = oldBase }) + + return binaryContent +} + +// stubExecutable reroutes the old-binary migration probe so a successful +// runUpdate under test never touches the real test binary. +func stubExecutable(t *testing.T) { + t.Helper() + old := executablePath + executablePath = func() (string, error) { return "", nil } + t.Cleanup(func() { executablePath = old }) +} + +func buildArchive(t *testing.T, ext, name string, content []byte) []byte { + t.Helper() + if ext == zipExt { + return buildZipArchive(t, name, content) + } + return buildTarGzArchive(t, name, content) +} + +func buildTarGzArchive(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + hdr := &tar.Header{Name: name, Mode: 0o755, Size: int64(len(content)), Typeflag: tar.TypeReg} + if err := tw.WriteHeader(hdr); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(content); err != nil { + t.Fatal(err) + } + if err := tw.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func buildZipArchive(t *testing.T, name string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + w, err := zw.Create(name) + if err != nil { + t.Fatal(err) + } + if _, err := w.Write(content); err != nil { + t.Fatal(err) + } + if err := zw.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestRunUpdateVerifiedHappyPath(t *testing.T) { + binary := setupFakeRelease(t, fakeRelease{}) + home := t.TempDir() + t.Setenv("VERDA_HOME", home) + stubExecutable(t) + + f := &cmdutil.TestFactory{} + var out, errOut bytes.Buffer + ioStreams := cmdutil.IOStreams{In: bytes.NewReader(nil), Out: &out, ErrOut: &errOut} + + err := runUpdate(context.Background(), f, ioStreams, fakeTag, false) + if err != nil { + t.Fatalf("runUpdate error: %v", err) + } + + dst := filepath.Join(home, "bin", platformBinaryName()) + got, err := os.ReadFile(dst) // #nosec G304 -- dst is under t.TempDir() + if err != nil { + t.Fatalf("reading installed binary: %v", err) + } + if !bytes.Equal(got, binary) { + t.Errorf("installed binary content mismatch") + } + if !strings.Contains(out.String(), "Updated to "+fakeTag) { + t.Errorf("expected 'Updated to %s' in output, got %q", fakeTag, out.String()) + } +} + +func TestRunUpdateAbortsOnChecksumMismatch(t *testing.T) { + setupFakeRelease(t, fakeRelease{tamperSums: true}) + home := t.TempDir() + t.Setenv("VERDA_HOME", home) + + // Pre-existing install: must be left untouched by the aborted update. + binDir := filepath.Join(home, "bin") + dst := filepath.Join(binDir, platformBinaryName()) + if err := os.MkdirAll(binDir, 0o700); err != nil { + t.Fatal(err) + } + // #nosec G306 -- fixture must be executable like a real install + if err := os.WriteFile(dst, []byte("old binary"), 0o755); err != nil { + t.Fatal(err) + } + + f := &cmdutil.TestFactory{} + var out, errOut bytes.Buffer + ioStreams := cmdutil.IOStreams{In: bytes.NewReader(nil), Out: &out, ErrOut: &errOut} + + err := runUpdate(context.Background(), f, ioStreams, fakeTag, false) + if err == nil { + t.Fatal("expected checksum verification error, got nil") + } + if !strings.Contains(err.Error(), "checksum verification failed") { + t.Errorf("error should report checksum verification failure, got: %v", err) + } + if !strings.Contains(err.Error(), "--skip-verify") { + t.Errorf("error should name --skip-verify as escape hatch, got: %v", err) + } + + got, err := os.ReadFile(dst) // #nosec G304 -- dst is under t.TempDir() + if err != nil { + t.Fatalf("old binary missing after aborted update: %v", err) + } + if string(got) != "old binary" { + t.Errorf("old binary was modified: %q", got) + } +} + +func TestRunUpdateAbortsOnChecksumFetchFailure(t *testing.T) { + setupFakeRelease(t, fakeRelease{sumsStatus: http.StatusInternalServerError}) + home := t.TempDir() + t.Setenv("VERDA_HOME", home) + + f := &cmdutil.TestFactory{} + var out, errOut bytes.Buffer + ioStreams := cmdutil.IOStreams{In: bytes.NewReader(nil), Out: &out, ErrOut: &errOut} + + err := runUpdate(context.Background(), f, ioStreams, fakeTag, false) + if err == nil { + t.Fatal("expected error when checksum endpoint fails, got nil") + } + if !strings.Contains(err.Error(), "HTTP 500") { + t.Errorf("error should surface the fetch failure, got: %v", err) + } + if !strings.Contains(err.Error(), "--skip-verify") { + t.Errorf("error should name --skip-verify as escape hatch, got: %v", err) + } + + // Fail closed: abort happened before the install directory was prepared. + if _, statErr := os.Stat(filepath.Join(home, "bin")); !os.IsNotExist(statErr) { + t.Errorf("bin directory should not exist after aborted update, stat err = %v", statErr) + } +} + +func TestRunUpdateSkipVerifyProceeds(t *testing.T) { + binary := setupFakeRelease(t, fakeRelease{tamperSums: true}) + home := t.TempDir() + t.Setenv("VERDA_HOME", home) + stubExecutable(t) + + // JSON format doubles as the --agent output-contract assertion. + f := &cmdutil.TestFactory{OutputFormatOverride: "json"} + var out, errOut bytes.Buffer + ioStreams := cmdutil.IOStreams{In: bytes.NewReader(nil), Out: &out, ErrOut: &errOut} + + err := runUpdate(context.Background(), f, ioStreams, fakeTag, true) + if err != nil { + t.Fatalf("runUpdate with skip-verify should proceed despite bad sums: %v", err) + } + + dst := filepath.Join(home, "bin", platformBinaryName()) + got, err := os.ReadFile(dst) // #nosec G304 -- dst is under t.TempDir() + if err != nil { + t.Fatalf("reading installed binary: %v", err) + } + if !bytes.Equal(got, binary) { + t.Errorf("installed binary content mismatch") + } + + var res updateResult + if err := json.Unmarshal(out.Bytes(), &res); err != nil { + t.Fatalf("stdout should be pure JSON in json/agent mode, got %q: %v", out.String(), err) + } + if !res.Updated || res.Version != fakeTag || res.ChecksumVerified { + t.Errorf("unexpected JSON result: %+v", res) + } + if res.Path != dst { + t.Errorf("result path = %q, want %q", res.Path, dst) + } +} diff --git a/internal/verda-cli/cmd/update/verify.go b/internal/verda-cli/cmd/update/verify.go index 0185023..eebfdd0 100644 --- a/internal/verda-cli/cmd/update/verify.go +++ b/internal/verda-cli/cmd/update/verify.go @@ -49,8 +49,8 @@ func checksumURL(ver string) string { } // fetchChecksums downloads the checksum file from the given URL. -func fetchChecksums(client *http.Client, url string) (string, error) { - req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody) +func fetchChecksums(ctx context.Context, client *http.Client, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return "", fmt.Errorf("creating request: %w", err) } @@ -128,9 +128,41 @@ func findMatchingChecksum(body, goos, goarch string) (string, error) { return "", fmt.Errorf("no checksum entry found for %s/%s", goos, goarch) } +// findArchiveChecksum finds the expected hash for an exact archive artifact +// name (e.g. "verda_1.0.0_linux_amd64.tar.gz") in a GoReleaser archive sums +// file (verda__SHA256SUMS). Unlike findMatchingChecksum (dist-dir keys +// with variant suffixes), archive keys match exactly. +func findArchiveChecksum(body, artifactName string) (string, error) { + for _, line := range strings.Split(body, "\n") { + hexStr, key, ok := parseChecksumLine(line) + if !ok { + continue + } + if key == artifactName { + return hexStr, nil + } + } + return "", fmt.Errorf("no checksum entry found for %q", artifactName) +} + +// verifyArchiveChecksum checks data against the archive sums entry for +// artifactName. +func verifyArchiveChecksum(data []byte, sumsBody, artifactName string) error { + expected, err := findArchiveChecksum(sumsBody, artifactName) + if err != nil { + return err + } + sum := sha256.Sum256(data) + actual := hex.EncodeToString(sum[:]) + if actual != expected { + return fmt.Errorf("checksum mismatch for %q: expected %s, got %s", artifactName, expected, actual) + } + return nil +} + // verifyBinary fetches the checksums and compares against the binary at binPath. -func verifyBinary(client *http.Client, binPath, url, goos, goarch string) (*VerifyResult, error) { - body, err := fetchChecksums(client, url) +func verifyBinary(ctx context.Context, client *http.Client, binPath, url, goos, goarch string) (*VerifyResult, error) { + body, err := fetchChecksums(ctx, client, url) if err != nil { return nil, err } @@ -154,8 +186,10 @@ func verifyBinary(client *http.Client, binPath, url, goos, goarch string) (*Veri } // runVerify is the top-level verify logic, writing output to out and warnings -// to errOut. It uses the provided HTTP client for fetching checksums. -func runVerify(out, errOut io.Writer, outputFormat string, client *http.Client, ver, goos, goarch string) error { +// to errOut. It uses the provided HTTP client for fetching checksums. The ctx +// carries the caller's deadline (Ctrl+C / --timeout); a bare background +// context here could not be aborted (review: update --verify fetch). +func runVerify(ctx context.Context, out, errOut io.Writer, outputFormat string, client *http.Client, ver, goos, goarch string) error { bare := strings.TrimPrefix(ver, "v") if bare == "0.0.0-dev" || bare == "" { _, _ = fmt.Fprintf(errOut, "Warning: cannot verify a development build (%s)\n", ver) @@ -172,7 +206,7 @@ func runVerify(out, errOut io.Writer, outputFormat string, client *http.Client, } url := checksumURL(ver) - result, err := verifyBinary(client, binPath, url, goos, goarch) + result, err := verifyBinary(ctx, client, binPath, url, goos, goarch) if err != nil { return err } diff --git a/internal/verda-cli/cmd/update/verify_test.go b/internal/verda-cli/cmd/update/verify_test.go index ed2af1b..c27af87 100644 --- a/internal/verda-cli/cmd/update/verify_test.go +++ b/internal/verda-cli/cmd/update/verify_test.go @@ -16,6 +16,7 @@ package update import ( "bytes" + "context" "crypto/sha256" "encoding/hex" "fmt" @@ -231,7 +232,7 @@ func TestFetchChecksums(t *testing.T) { defer srv.Close() client := srv.Client() - body, err := fetchChecksums(client, srv.URL) + body, err := fetchChecksums(context.Background(), client, srv.URL) if err != nil { t.Fatalf("fetchChecksums error: %v", err) } @@ -249,7 +250,7 @@ func TestFetchChecksums404(t *testing.T) { defer srv.Close() client := srv.Client() - _, err := fetchChecksums(client, srv.URL) + _, err := fetchChecksums(context.Background(), client, srv.URL) if err == nil { t.Fatal("expected error for 404") } @@ -278,7 +279,7 @@ func TestVerifyBinaryMatch(t *testing.T) { })) defer srv.Close() - result, err := verifyBinary(srv.Client(), binPath, srv.URL, goos, goarch) + result, err := verifyBinary(context.Background(), srv.Client(), binPath, srv.URL, goos, goarch) if err != nil { t.Fatalf("verifyBinary error: %v", err) } @@ -311,7 +312,7 @@ func TestVerifyBinaryMismatch(t *testing.T) { })) defer srv.Close() - result, err := verifyBinary(srv.Client(), binPath, srv.URL, goos, goarch) + result, err := verifyBinary(context.Background(), srv.Client(), binPath, srv.URL, goos, goarch) if err != nil { t.Fatalf("verifyBinary error: %v", err) } @@ -320,6 +321,101 @@ func TestVerifyBinaryMismatch(t *testing.T) { } } +func TestFindMatchingChecksumEdgeCases(t *testing.T) { + t.Parallel() + + // Keys without a GoReleaser variant suffix (e.g. "verda_linux_arm64/verda") + // must match too. + body := "fff666 verda_linux_arm64/verda\n" + got, err := findMatchingChecksum(body, "linux", "arm64") + if err != nil { + t.Fatalf("unexpected error for suffix-less key: %v", err) + } + if got != "fff666" { + t.Errorf("got %q, want %q", got, "fff666") + } + + // A key that merely starts with the os_arch prefix (no "/" or "_" boundary) + // must not match. + body = "zzz999 verda_linux_amd64evil/verda\n" + if _, err := findMatchingChecksum(body, "linux", "amd64"); err == nil { + t.Error("expected error for prefix-collision key, got match") + } + + // Body with only comments and blanks yields no match. + body = "# comment\n\n \n" + if _, err := findMatchingChecksum(body, "linux", "amd64"); err == nil { + t.Error("expected error for comment-only body") + } +} + +func TestFindArchiveChecksum(t *testing.T) { + t.Parallel() + + body := `# goreleaser archive sums +aaa111 verda_1.0.0_linux_amd64.tar.gz +bbb222 verda_1.0.0_linux_amd64.deb +ccc333 verda_1.0.0_darwin_arm64.tar.gz +` + + tests := []struct { + name string + artifact string + wantHash string + wantErr bool + }{ + {name: "exact archive", artifact: "verda_1.0.0_linux_amd64.tar.gz", wantHash: "aaa111"}, + {name: "same prefix different ext", artifact: "verda_1.0.0_linux_amd64.deb", wantHash: "bbb222"}, + {name: "other platform", artifact: "verda_1.0.0_darwin_arm64.tar.gz", wantHash: "ccc333"}, + {name: "prefix without ext must not match", artifact: "verda_1.0.0_linux_amd64", wantErr: true}, + {name: "unknown artifact", artifact: "verda_2.0.0_linux_amd64.tar.gz", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, err := findArchiveChecksum(body, tt.artifact) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got match %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.wantHash { + t.Errorf("got %q, want %q", got, tt.wantHash) + } + }) + } +} + +func TestVerifyArchiveChecksum(t *testing.T) { + t.Parallel() + + data := []byte("archive payload") + sum := sha256.Sum256(data) + goodBody := hex.EncodeToString(sum[:]) + " verda_1.0.0_linux_amd64.tar.gz\n" + + if err := verifyArchiveChecksum(data, goodBody, "verda_1.0.0_linux_amd64.tar.gz"); err != nil { + t.Errorf("expected match, got error: %v", err) + } + + badBody := strings.Replace(goodBody, hex.EncodeToString(sum[:]), "0000000000000000000000000000000000000000000000000000000000000000", 1) + err := verifyArchiveChecksum(data, badBody, "verda_1.0.0_linux_amd64.tar.gz") + if err == nil { + t.Fatal("expected mismatch error, got nil") + } + if !strings.Contains(err.Error(), "checksum mismatch") { + t.Errorf("error should describe the mismatch, got: %v", err) + } + + if err := verifyArchiveChecksum(data, goodBody, "verda_9.9.9_windows_amd64.zip"); err == nil { + t.Error("expected error for artifact missing from sums file") + } +} + func TestRunVerifyDevBuild(t *testing.T) { t.Parallel() @@ -336,7 +432,7 @@ func TestRunVerifyDevBuild(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() var outBuf, errBuf bytes.Buffer - err := runVerify(&outBuf, &errBuf, "", nil, tt.version, "", "") + err := runVerify(context.Background(), &outBuf, &errBuf, "", nil, tt.version, "", "") if err == nil { t.Fatal("expected error for dev build") } diff --git a/internal/verda-cli/cmd/util/agent_error.go b/internal/verda-cli/cmd/util/agent_error.go index fe79668..39e392d 100644 --- a/internal/verda-cli/cmd/util/agent_error.go +++ b/internal/verda-cli/cmd/util/agent_error.go @@ -162,10 +162,11 @@ func IsAgentError(err error) bool { // // Classification priority: // 1. Already an *AgentError → return as-is -// 2. SDK *verda.APIError → map status codes to error codes -// 3. SDK *verda.ValidationError → VALIDATION_ERROR -// 4. Auth-related error messages → AUTH_ERROR -// 5. Fallback → generic ERROR +// 2. CLI *UsageError (flag/argument misuse) → VALIDATION_ERROR, exit 2 +// 3. SDK *verda.APIError → map status codes to error codes +// 4. SDK *verda.ValidationError → VALIDATION_ERROR +// 5. Auth-related error messages → AUTH_ERROR +// 6. Fallback → generic ERROR func ClassifyError(err error) *AgentError { if err == nil { return nil @@ -177,25 +178,35 @@ func ClassifyError(err error) *AgentError { return ae } - // 2. SDK API error with status code. + // 2. CLI usage errors (flag misuse) — bad input, exit 2. + var usageErr *UsageError + if errors.As(err, &usageErr) { + return &AgentError{ + Code: "VALIDATION_ERROR", + Message: usageErr.Message(), + ExitCode: ExitBadArgs, + } + } + + // 3. SDK API error with status code. var apiErr *verda.APIError if errors.As(err, &apiErr) { return classifyAPIError(apiErr) } - // 3. SDK validation error. + // 4. SDK validation error. var valErr *verda.ValidationError if errors.As(err, &valErr) { return NewValidationError(valErr.Field, valErr.Message) } - // 4. Auth-related errors (heuristic on message). + // 5. Auth-related errors (heuristic on message). msg := err.Error() if isAuthError(msg) { return NewAuthError(msg) } - // 5. Fallback. + // 6. Fallback. return &AgentError{ Code: "ERROR", Message: msg, diff --git a/internal/verda-cli/cmd/util/agent_error_test.go b/internal/verda-cli/cmd/util/agent_error_test.go index 40ec3b6..6d2cfbc 100644 --- a/internal/verda-cli/cmd/util/agent_error_test.go +++ b/internal/verda-cli/cmd/util/agent_error_test.go @@ -18,8 +18,11 @@ import ( "bytes" "encoding/json" "errors" + "fmt" + "strings" "testing" + "github.com/spf13/cobra" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" ) @@ -162,3 +165,33 @@ func TestClassifyError_Fallback(t *testing.T) { t.Errorf("exit code = %d, want %d", got.ExitCode, ExitGeneral) } } + +// Usage/flag-misuse errors must classify as VALIDATION_ERROR with exit 2 so +// agents can tell bad input apart from server failures (exit 4/1) and fix the +// call. The --help hint stays out of the envelope (human-facing text only). +func TestClassifyError_UsageError(t *testing.T) { + cmd := &cobra.Command{Use: "verda volume delete"} + err := UsageErrorf(cmd, "--status can only be used with --all") + + got := ClassifyError(err) + if got.Code != "VALIDATION_ERROR" { + t.Errorf("code = %q, want VALIDATION_ERROR", got.Code) + } + if got.ExitCode != ExitBadArgs { + t.Errorf("exit code = %d, want %d", got.ExitCode, ExitBadArgs) + } + if got.Message != "--status can only be used with --all" { + t.Errorf("message = %q — the --help hint must not leak into the envelope", got.Message) + } + + // Human-facing text keeps the hint. + if !strings.Contains(err.Error(), "--help") { + t.Errorf("Error() lost the help hint: %q", err.Error()) + } + + // Wrapping keeps the classification. + wrapped := fmt.Errorf("volume delete: %w", err) + if ClassifyError(wrapped).Code != "VALIDATION_ERROR" { + t.Error("wrapped UsageError lost the VALIDATION_ERROR classification") + } +} diff --git a/internal/verda-cli/cmd/util/agent_prompter_test.go b/internal/verda-cli/cmd/util/agent_prompter_test.go index 7068213..d5d5794 100644 --- a/internal/verda-cli/cmd/util/agent_prompter_test.go +++ b/internal/verda-cli/cmd/util/agent_prompter_test.go @@ -15,9 +15,13 @@ package util import ( + "bytes" "context" "errors" + "io" "testing" + + clioptions "github.com/verda-cloud/verda-cli/internal/verda-cli/options" ) func TestAgentPrompter_ReturnsAgentError(t *testing.T) { @@ -52,3 +56,41 @@ func TestAgentPrompter_ReturnsAgentError(t *testing.T) { }) } } + +// The factory is constructed during command-tree building, before cobra parses +// flags and before opts.Complete() resolves VERDA_AGENT. Prompter() must +// therefore resolve the agent prompter lazily at call time (C2 regression test). +func TestFactoryPrompter_AgentSetAfterConstruction(t *testing.T) { + opts := clioptions.NewOptions() + f := NewFactory(opts, IOStreams{In: bytes.NewReader(nil), Out: io.Discard, ErrOut: io.Discard}) + + opts.Agent = true + + _, err := f.Prompter().Confirm(context.Background(), "sure?") + if err == nil { + t.Fatal("expected INTERACTIVE_PROMPT_BLOCKED, got nil") + } + var ae *AgentError + if !errors.As(err, &ae) { + t.Fatalf("expected *AgentError, got %T: %v", err, err) + } + if ae.Code != "INTERACTIVE_PROMPT_BLOCKED" { + t.Errorf("code = %q, want INTERACTIVE_PROMPT_BLOCKED", ae.Code) + } + if ae.ExitCode != ExitBadArgs { + t.Errorf("exit code = %d, want %d", ae.ExitCode, ExitBadArgs) + } + + if f.Status() != nil { + t.Error("expected nil Status in agent mode (no spinners on the JSON surface)") + } +} + +func TestFactoryPrompter_InteractiveByDefault(t *testing.T) { + opts := clioptions.NewOptions() + f := NewFactory(opts, IOStreams{In: bytes.NewReader(nil), Out: io.Discard, ErrOut: io.Discard}) + + if _, blocked := f.Prompter().(*agentPrompter); blocked { + t.Error("non-agent factory must return the interactive prompter") + } +} diff --git a/internal/verda-cli/cmd/util/factory.go b/internal/verda-cli/cmd/util/factory.go index f6b55a3..d843e96 100644 --- a/internal/verda-cli/cmd/util/factory.go +++ b/internal/verda-cli/cmd/util/factory.go @@ -44,24 +44,52 @@ const ( ) // sensitiveJSONFieldRe matches "field": "value" JSON entries whose values must -// not appear in debug output (OAuth credentials, bearer tokens, etc.). -// Value pattern allows escaped quotes (\") so values containing them are -// redacted whole — a bare [^"]* would stop at the first escaped quote and -// leak the remainder while emitting malformed JSON. +// not appear in debug output (OAuth credentials, bearer tokens, provisioning +// secrets — the key list mirrors the SDK's struct tags plus the API payloads +// where verify-live testing saw them leak). Value pattern allows escaped +// quotes (\") so values containing them are redacted whole — a bare [^"]* +// would stop at the first escaped quote and leak the remainder while emitting +// malformed JSON. var sensitiveJSONFieldRe = regexp.MustCompile( - `("(?:client_secret|access_token|refresh_token|id_token|password|api_key|bearer|authorization)")(\s*:\s*)"(?:[^"\\]|\\.)*"`) + `("(?:client_secret|secret_access_key|service_account_key|value_or_reference_to_secret|jupyter_token|access_token|refresh_token|id_token|password|api_key|bearer|authorization)")(\s*:\s*)"(?:[^"\\]|\\.)*"`) + +// sensitiveFormFieldRe matches name=value pairs in form-encoded bodies whose +// values are secrets. The SDK retries /oauth2/token form-encoded when the API +// rejects the JSON attempt with 400 — without this, --debug prints +// client_secret verbatim exactly when the user captures logs for a bug +// report (review H1). +var sensitiveFormFieldRe = regexp.MustCompile( + `\b(client_secret|access_token|refresh_token|id_token|password|token)=[^&]*`) func redactSensitiveJSON(s string) string { return sensitiveJSONFieldRe.ReplaceAllString(s, `$1$2""`) } +func redactSensitiveForm(s string) string { + return sensitiveFormFieldRe.ReplaceAllString(s, `$1=`) +} + +// redactSensitiveBody picks a redactor by content type. Unknown types fall +// back to the JSON redactor: the API speaks JSON, and the JSON pattern +// harmlessly no-ops on non-JSON bytes. +func redactSensitiveBody(contentType, s string) string { + if strings.HasPrefix(strings.ToLower(contentType), "application/x-www-form-urlencoded") { + return redactSensitiveForm(s) + } + return redactSensitiveJSON(s) +} + // Factory provides shared resources that are created once in the root command // and passed down to every subcommand. This pattern keeps commands testable // and shared configuration in one place. type Factory interface { // ServerAddr returns the configured API server address. ServerAddr() string - // HTTPClient returns a shared HTTP client with the configured timeout. + // HTTPClient returns a shared HTTP client. It intentionally has no + // client-level Timeout: that cap applies to whole-body reads and would + // kill long transfers. Callers bound requests with a context instead + // (control plane: WithTimeout(cmd.Context(), Options().Timeout); data + // plane: cmd.Context()). HTTPClient() *http.Client // Options returns the underlying Options for advanced use. Options() *clioptions.Options @@ -150,7 +178,7 @@ func (t *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { _, _ = fmt.Fprintf(t.out, "DEBUG: %s: %s\n", k, strings.Join(req.Header[k], ", ")) } if len(reqBody) > 0 { - _, _ = fmt.Fprintf(t.out, "DEBUG: request body: %s\n", redactSensitiveJSON(string(reqBody))) + _, _ = fmt.Fprintf(t.out, "DEBUG: request body: %s\n", redactSensitiveBody(req.Header.Get("Content-Type"), string(reqBody))) } resp, err := t.base.RoundTrip(req) @@ -175,36 +203,52 @@ func (t *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { } _, _ = fmt.Fprintf(t.out, "DEBUG: HTTP response %s\n", resp.Status) if len(respBody) > 0 { - _, _ = fmt.Fprintf(t.out, "DEBUG: response body: %s\n", redactSensitiveJSON(string(respBody))) + _, _ = fmt.Fprintf(t.out, "DEBUG: response body: %s\n", redactSensitiveBody(resp.Header.Get("Content-Type"), string(respBody))) } return resp, nil } -// NewFactory creates a Factory from the given Options. debugOut receives -// HTTP request/response dumps when --debug is enabled. -func NewFactory(opts *clioptions.Options, debugOut io.Writer) Factory { +// NewFactory creates a Factory from the given Options. ioStreams wires all +// harness output: --debug dumps HTTP details to ErrOut, and prompt UI +// (select/confirm/text input, spinners) renders on ErrOut so stdout stays +// machine-consumable data (house rule; a bare tui.Default() rendered prompts +// to os.Stdout and polluted pipes — review MEDIUM "prompts honor IO"). +// +// The client has no client-level Timeout (review H2): Client.Timeout covers +// the entire body read and silently clamped any request to opts.Timeout, +// killing multi-GB transfers. Dial/TLS bounds stay on http.DefaultTransport; +// per-call deadlines come from request contexts instead. +func NewFactory(opts *clioptions.Options, ioStreams IOStreams) Factory { f := &factoryImpl{opts: opts} var rt http.RoundTripper = &userAgentTransport{base: http.DefaultTransport, userAgent: userAgentString()} - rt = &debugTransport{base: rt, out: debugOut, enabled: f.Debug} - f.client = &http.Client{ - Timeout: opts.Timeout, - Transport: rt, - } - f.prompter = tui.Default() - f.status = tui.DefaultStatus() - if opts.Agent { - f.prompter = &agentPrompter{} - f.status = nil + rt = &debugTransport{base: rt, out: ioStreams.ErrOut, enabled: f.Debug} + f.client = &http.Client{Transport: rt} + streamIO := func(s *tui.IO) { + s.In, s.Out, s.ErrOut = ioStreams.In, ioStreams.Out, ioStreams.ErrOut } + f.prompter = tui.Default(streamIO) + f.status = tui.DefaultStatus(streamIO) return f } func (f *factoryImpl) ServerAddr() string { return f.opts.Server } func (f *factoryImpl) HTTPClient() *http.Client { return f.client } func (f *factoryImpl) Options() *clioptions.Options { return f.opts } -func (f *factoryImpl) Prompter() tui.Prompter { return f.prompter } + +// Prompter resolves the prompt implementation at call time: the factory is +// built during command-tree construction, before flags are parsed and +// opts.Complete() runs, so opts.Agent is never reliable in NewFactory. +// In agent mode every prompt attempt must fail fast with a structured +// INTERACTIVE_PROMPT_BLOCKED error instead of blocking on stdin. +func (f *factoryImpl) Prompter() tui.Prompter { + if f.opts.Agent { + return &agentPrompter{} + } + return f.prompter +} + func (f *factoryImpl) Status() tui.Status { - if f.opts.Output != "table" { + if f.opts.Agent || f.opts.Output != "table" { return nil } return f.status diff --git a/internal/verda-cli/cmd/util/factory_test.go b/internal/verda-cli/cmd/util/factory_test.go new file mode 100644 index 0000000..3df56fc --- /dev/null +++ b/internal/verda-cli/cmd/util/factory_test.go @@ -0,0 +1,188 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/verda-cloud/verda-cli/pkg/tui" + + clioptions "github.com/verda-cloud/verda-cli/internal/verda-cli/options" +) + +// newFactoryPreFlagParse mirrors production wiring: NewRootCommand builds the +// factory during command-tree construction, so Options carries zero values — +// --agent has not been parsed yet. +func newFactoryPreFlagParse() (Factory, *clioptions.Options) { + opts := &clioptions.Options{Output: "table"} + return NewFactory(opts, IOStreams{In: bytes.NewReader(nil), Out: io.Discard, ErrOut: io.Discard}), opts +} + +// Regression: the factory is constructed before flag parsing, so an agent-mode +// decision taken in NewFactory reads Agent==false forever and every command +// keeps the interactive bubbletea prompter — `verda --agent ` then blocks +// on os.Stdin instead of failing fast. Prompter() must therefore resolve at +// call time, after flags land on the shared Options. +func TestFactory_PrompterResolvesAgentModeSetAfterConstruction(t *testing.T) { + f, opts := newFactoryPreFlagParse() + + if _, isAgent := f.Prompter().(*agentPrompter); isAgent { + t.Fatal("pre-parse factory returned agentPrompter; interactive mode would be broken") + } + + opts.Agent = true // what flag parsing / opts.Complete() does + + if _, isAgent := f.Prompter().(*agentPrompter); !isAgent { + t.Fatalf("Prompter() = %T after --agent was parsed, want *agentPrompter "+ + "(agent mode would block on stdin)", f.Prompter()) + } +} + +// Every prompt entry point must fail fast with the documented structured error +// rather than reading stdin. Covers the whole Prompter surface because a +// command reaching *any* of these in agent mode is the hang. +func TestFactory_AgentModePromptsFailFast(t *testing.T) { + f, opts := newFactoryPreFlagParse() + opts.Agent = true + p := f.Prompter() + ctx := context.Background() + + calls := map[string]func() error{ + "Confirm": func() (err error) { _, err = p.Confirm(ctx, "sure?"); return }, + "TextInput": func() (err error) { _, err = p.TextInput(ctx, "name?"); return }, + "Password": func() (err error) { _, err = p.Password(ctx, "secret?"); return }, + "Select": func() (err error) { _, err = p.Select(ctx, "pick", []string{"a"}); return }, + "MultiSelect": func() (err error) { _, err = p.MultiSelect(ctx, "pick", []string{"a"}); return }, + "Editor": func() (err error) { _, err = p.Editor(ctx, "edit"); return }, + } + + for name, call := range calls { + t.Run(name, func(t *testing.T) { + err := call() + if err == nil { + t.Fatal("prompt returned nil error in agent mode; caller would proceed as if answered") + } + var ae *AgentError + if !errors.As(err, &ae) { + t.Fatalf("error = %T (%v), want *AgentError", err, err) + } + if ae.Code != "INTERACTIVE_PROMPT_BLOCKED" { + t.Errorf("code = %q, want INTERACTIVE_PROMPT_BLOCKED", ae.Code) + } + }) + } +} + +// Interactive mode must keep the real prompter — guards against "fix" that +// returns agentPrompter unconditionally. +func TestFactory_InteractiveModeKeepsRealPrompter(t *testing.T) { + f, _ := newFactoryPreFlagParse() + if f.Prompter() == nil { + t.Fatal("Prompter() = nil in interactive mode") + } + if _, isAgent := f.Prompter().(*agentPrompter); isAgent { + t.Fatal("interactive mode returned agentPrompter") + } +} + +// Spinners write ANSI to the terminal and corrupt the agent stderr JSON +// channel; agent mode must have no Status regardless of output format. +func TestFactory_StatusSuppressedInAgentMode(t *testing.T) { + tests := []struct { + name string + agent bool + output string + wantNil bool + rationale string + }{ + {"agent table", true, "table", true, "agent mode never renders a spinner"}, + {"agent json", true, "json", true, "agent mode never renders a spinner"}, + {"interactive json", false, "json", true, "non-table output is machine-consumed"}, + {"interactive table", false, "table", false, "humans get the spinner"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &clioptions.Options{Output: tt.output, Agent: tt.agent} + got := NewFactory(opts, IOStreams{In: bytes.NewReader(nil), Out: io.Discard, ErrOut: io.Discard}).Status() + if (got == nil) != tt.wantNil { + t.Errorf("Status() nil = %v, want %v (%s)", got == nil, tt.wantNil, tt.rationale) + } + }) + } +} + +// Prompts are UI, not data (house rule): a factory built on piped streams must +// render prompt frames on ErrOut and leave Out clean for machine consumers. +// Backend-level split lives in pkg/tui/bubbletea (TestWithIO_*). +func TestFactory_PromptsRenderToErrOut(t *testing.T) { + t.Parallel() + + var out, errOut bytes.Buffer + f := NewFactory(&clioptions.Options{Output: "table"}, IOStreams{ + In: bytes.NewBufferString("\r"), // Enter: pick the first choice + Out: &out, + ErrOut: &errOut, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + idx, err := f.Prompter().Select(ctx, "Pick one", []string{"alpha", "beta"}, tui.WithShowHints(true)) + if err != nil { + t.Fatalf("Select: %v", err) + } + if idx != 0 { + t.Errorf("Select returned %d, want 0", idx) + } + if out.Len() != 0 { + t.Errorf("prompt UI leaked into stdout: %q", out.String()) + } + if errOut.Len() == 0 { + t.Error("prompt rendered nowhere — ErrOut wiring lost") + } +} + +// Status.Table is data: it must land on Out even though prompts live on ErrOut. +func TestFactory_StatusTableWritesDataToOut(t *testing.T) { + t.Parallel() + + var out, errOut bytes.Buffer + f := NewFactory(&clioptions.Options{Output: "table"}, IOStreams{ + In: bytes.NewReader(nil), + Out: &out, + ErrOut: &errOut, + }) + + status := f.Status() + if status == nil { + t.Fatal("Status() = nil in interactive table mode") + } + if err := status.Table(context.Background(), []string{"NAME"}, [][]string{{"row-1"}}); err != nil { + t.Fatalf("Table: %v", err) + } + if !strings.Contains(out.String(), "NAME") { + t.Errorf("table data missing from Out: %q", out.String()) + } + if errOut.Len() != 0 { + t.Errorf("table data polluted ErrOut: %q", errOut.String()) + } +} diff --git a/internal/verda-cli/cmd/util/helpers.go b/internal/verda-cli/cmd/util/helpers.go index 60a2ebb..d5ab23c 100644 --- a/internal/verda-cli/cmd/util/helpers.go +++ b/internal/verda-cli/cmd/util/helpers.go @@ -82,10 +82,26 @@ func CheckErr(err error) { os.Exit(1) } +// UsageError marks flag/argument misuse (bad values, invalid combinations). +// In agent mode main classifies it as VALIDATION_ERROR with exit code 2 — +// bad input, distinct from server-side failures (docs/agent-errors.md). +type UsageError struct { + msg string + hint string +} + +// Error returns the message plus the human --help hint. +func (e *UsageError) Error() string { return e.msg + e.hint } + +// Message returns the bare message, without the --help hint (agent envelope). +func (e *UsageError) Message() string { return e.msg } + // UsageErrorf creates a formatted usage error that hints the user to run --help. func UsageErrorf(cmd *cobra.Command, format string, args ...any) error { - msg := fmt.Sprintf(format, args...) - return fmt.Errorf("%s\nSee '%s --help' for help and examples", msg, cmd.CommandPath()) + return &UsageError{ + msg: fmt.Sprintf(format, args...), + hint: fmt.Sprintf("\nSee '%s --help' for help and examples", cmd.CommandPath()), + } } // DefaultSubCommandRun prints help when a parent command is invoked without a subcommand. diff --git a/internal/verda-cli/cmd/util/helpers_test.go b/internal/verda-cli/cmd/util/helpers_test.go new file mode 100644 index 0000000..af56fa5 --- /dev/null +++ b/internal/verda-cli/cmd/util/helpers_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/verda-cloud/verda-cli/pkg/tui" + tuitesting "github.com/verda-cloud/verda-cli/pkg/tui/testing" + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" +) + +// The predicates in helpers.go are the single classification point main.go +// uses to map a user cancel to a silent exit 0. The wizard engine returns +// wrapping sentinels (ErrCancelled + low-level cause); this matrix pins that +// every shape of clean cancel classifies exactly one way, and real failures +// do not classify at all. +func TestPromptCancelClassification(t *testing.T) { + stubFlow := func() *wizard.Flow { + return &wizard.Flow{ + Name: "test", + Steps: []wizard.Step{{Name: "only", Prompt: wizard.TextInputPrompt, Required: true}}, + } + } + runWizard := func(result wizard.TestResult) error { + engine := wizard.NewEngine(tuitesting.New(), nil, wizard.WithTestResults(result)) + return engine.Run(context.Background(), stubFlow()) + } + + ctrlC := runWizard(wizard.ExitResult()) + escFirst := runWizard(wizard.BackResult()) + boom := errors.New("api unreachable") + + tests := []struct { + name string + err error + wantInterrupt, wantBack bool + }{ + {"prompter Ctrl+C", tui.ErrInterrupted, true, false}, + {"prompter Esc", context.Canceled, false, true}, + {"wizard Ctrl+C", ctrlC, true, false}, + {"wizard Esc at first step", escFirst, false, true}, + // Ctrl+C during a wizard loader arrives wrapped in the step prefix. + {"wizard loader ctx cancel", fmt.Errorf("step %q: %w", "x", context.Canceled), false, true}, + {"real failure", boom, false, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsPromptInterrupt(tt.err); got != tt.wantInterrupt { + t.Errorf("IsPromptInterrupt(%v) = %v, want %v", tt.err, got, tt.wantInterrupt) + } + if got := IsPromptBack(tt.err); got != tt.wantBack { + t.Errorf("IsPromptBack(%v) = %v, want %v", tt.err, got, tt.wantBack) + } + wantCancel := tt.wantInterrupt || tt.wantBack + if got := IsPromptCancel(tt.err); got != wantCancel { + t.Errorf("IsPromptCancel(%v) = %v, want %v", tt.err, got, wantCancel) + } + }) + } +} diff --git a/internal/verda-cli/cmd/util/pricing.go b/internal/verda-cli/cmd/util/pricing.go index bf4a285..8a0cab7 100644 --- a/internal/verda-cli/cmd/util/pricing.go +++ b/internal/verda-cli/cmd/util/pricing.go @@ -14,38 +14,37 @@ package util -import "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" +import ( + "math" + "sort" -// InstanceBillableUnits returns the number of billable units for an instance. -// GPU instances are billed per GPU; CPU instances are billed per vCPU. -func InstanceBillableUnits(inst *verda.Instance) int { - if inst.GPU.NumberOfGPUs > 0 { - return inst.GPU.NumberOfGPUs - } - if inst.CPU.NumberOfCores > 0 { - return inst.CPU.NumberOfCores - } - return 1 + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" +) + +// HoursInMonth converts an hourly rate to a monthly estimate: 365*24/12, +// matching the web frontend's hoursInMonth. +const HoursInMonth = 730 + +// VolumeHourlyPrice converts volume pricing (monthlyPerGB per GiB) to the +// hourly rate for a sizeGB volume: monthlyPerGB*sizeGB spread over the month, +// rounded up to 4 decimals. The ceiling is applied AFTER multiplying by size — +// per-GB rounding would overstate the rate (match the web frontend exactly). +func VolumeHourlyPrice(monthlyPerGB float64, sizeGB int) float64 { + return math.Ceil(monthlyPerGB*float64(sizeGB)/HoursInMonth*10000) / 10000 } -// InstanceTypeBillableUnits returns the number of billable units for an instance type. -// GPU types are billed per GPU; CPU types are billed per vCPU. -func InstanceTypeBillableUnits(t *verda.InstanceTypeInfo) int { - if t.GPU.NumberOfGPUs > 0 { - return t.GPU.NumberOfGPUs - } - if t.CPU.NumberOfCores > 0 { - return t.CPU.NumberOfCores - } - return 1 +// VolumeMonthlyPrice returns the monthly price of a sizeGB volume. +func VolumeMonthlyPrice(monthlyPerGB float64, sizeGB int) float64 { + return monthlyPerGB * float64(sizeGB) } -// InstanceTotalHourlyCost returns the total hourly cost for an instance. -// -// The API field PricePerHour is the per-unit price: -// - GPU instances: price per GPU (multiply by GPU count) -// - CPU instances: price per vCPU (multiply by vCPU count) -func InstanceTotalHourlyCost(inst *verda.Instance) float64 { - pricePerUnit := float64(inst.PricePerHour) - return pricePerUnit * float64(InstanceBillableUnits(inst)) +// ValidVolumeTypeNames returns the sorted type names of a volume-type catalog, +// for error messages that list the accepted values. +func ValidVolumeTypeNames(vtMap map[string]verda.VolumeType) []string { + names := make([]string, 0, len(vtMap)) + for name := range vtMap { + names = append(names, name) + } + sort.Strings(names) + return names } diff --git a/internal/verda-cli/cmd/util/pricing_test.go b/internal/verda-cli/cmd/util/pricing_test.go index 0e0bca1..c599c9e 100644 --- a/internal/verda-cli/cmd/util/pricing_test.go +++ b/internal/verda-cli/cmd/util/pricing_test.go @@ -15,83 +15,52 @@ package util import ( - "math" "testing" - - "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" ) -func TestInstanceBillableUnits(t *testing.T) { +func TestVolumeHourlyPrice(t *testing.T) { t.Parallel() + // Golden: staging 2026-08-09 — 500GB NVMe at $0.20/GB/mo bills $0.1370/hr. tests := []struct { - name string - inst verda.Instance - units int + name string + monthlyPerGB float64 + sizeGB int + want float64 }{ + {name: "500GB NVMe at $0.20/GB/mo", monthlyPerGB: 0.20, sizeGB: 500, want: 0.1370}, + {name: "100GB NVMe at $0.10/GB/mo", monthlyPerGB: 0.10, sizeGB: 100, want: 0.0137}, + {name: "exact 4-decimal division stays exact", monthlyPerGB: 0.73, sizeGB: 10, want: 0.0100}, + {name: "zero size", monthlyPerGB: 0.20, sizeGB: 0, want: 0}, + {name: "zero price", monthlyPerGB: 0, sizeGB: 500, want: 0}, { - name: "GPU instance 4x", - inst: verda.Instance{GPU: verda.InstanceGPU{NumberOfGPUs: 4}}, - units: 4, - }, - { - name: "GPU instance 1x", - inst: verda.Instance{GPU: verda.InstanceGPU{NumberOfGPUs: 1}}, - units: 1, - }, - { - name: "CPU instance 8 cores", - inst: verda.Instance{CPU: verda.InstanceCPU{NumberOfCores: 8}}, - units: 8, - }, - { - name: "fallback to 1 when no GPU or CPU info", - inst: verda.Instance{}, - units: 1, + // Per-GB rounding would give ceil(0.20/730*1e4)/1e4 * 500 = 0.0003*500 = 0.15; + // the ceiling must apply after the size multiplication. + name: "ceiling after size multiplication, not per GB", + monthlyPerGB: 0.20, + sizeGB: 500, + want: 0.1370, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := InstanceBillableUnits(&tt.inst) - if got != tt.units { - t.Fatalf("InstanceBillableUnits() = %d, want %d", got, tt.units) + got := VolumeHourlyPrice(tt.monthlyPerGB, tt.sizeGB) + if got != tt.want { + t.Fatalf("VolumeHourlyPrice(%v, %d) = %v, want %v", tt.monthlyPerGB, tt.sizeGB, got, tt.want) } }) } } -func TestInstanceTotalHourlyCost(t *testing.T) { +func TestVolumeMonthlyPrice(t *testing.T) { t.Parallel() - tests := []struct { - name string - inst verda.Instance - want float64 - }{ - { - name: "GPU: $2.29/GPU * 4 GPUs = $9.16", - inst: verda.Instance{PricePerHour: 2.29, GPU: verda.InstanceGPU{NumberOfGPUs: 4}}, - want: 9.16, - }, - { - name: "CPU: $0.006975/vCPU * 8 vCPUs", - inst: verda.Instance{PricePerHour: 0.006975, CPU: verda.InstanceCPU{NumberOfCores: 8}}, - want: 0.0558, - }, - { - name: "single GPU", - inst: verda.Instance{PricePerHour: 2.29, GPU: verda.InstanceGPU{NumberOfGPUs: 1}}, - want: 2.29, - }, + // Golden: 500GB NVMe at $0.20/GB/mo → $100.00/mo. + if got := VolumeMonthlyPrice(0.20, 500); got != 100.0 { + t.Fatalf("VolumeMonthlyPrice(0.20, 500) = %v, want 100.0", got) } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := InstanceTotalHourlyCost(&tt.inst) - if math.Abs(got-tt.want) > 0.001 { - t.Fatalf("InstanceTotalHourlyCost() = %f, want %f", got, tt.want) - } - }) + if got := VolumeMonthlyPrice(0.10, 100); got != 10.0 { + t.Fatalf("VolumeMonthlyPrice(0.10, 100) = %v, want 10.0", got) } } diff --git a/internal/verda-cli/cmd/util/redact_test.go b/internal/verda-cli/cmd/util/redact_test.go new file mode 100644 index 0000000..9e9b005 --- /dev/null +++ b/internal/verda-cli/cmd/util/redact_test.go @@ -0,0 +1,138 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package util + +import ( + "strings" + "testing" +) + +// --debug dumps request/response bodies; every secret-shaped value the SDK or +// API puts on the wire must be redacted (review H1). + +func TestRedactSensitiveBody_JSON(t *testing.T) { + t.Parallel() + + secretKeys := []string{ + "client_secret", "secret_access_key", "service_account_key", + "value_or_reference_to_secret", "jupyter_token", + "access_token", "refresh_token", "id_token", + "password", "api_key", "bearer", "authorization", + } + for _, key := range secretKeys { + t.Run(key, func(t *testing.T) { + t.Parallel() + in := `{"` + key + `": "SUPERSECRET-VALUE", "ok": true}` + got := redactSensitiveBody("application/json", in) + if strings.Contains(got, "SUPERSECRET-VALUE") { + t.Errorf("%q value leaked: %s", key, got) + } + if !strings.Contains(got, `"`+key+`": ""`) { + t.Errorf("%q not redacted in-place: %s", key, got) + } + if !strings.Contains(got, `"ok": true`) { + t.Errorf("non-secret key mangled: %s", got) + } + }) + } + + t.Run("escaped quote inside value redacted whole", func(t *testing.T) { + t.Parallel() + in := `{"client_secret": "a\"b} tail"` + got := redactSensitiveBody("application/json", in) + if strings.Contains(got, `b} tail`) { + t.Errorf("escaped-quote value leaked its remainder: %s", got) + } + }) + + t.Run("spacing variants", func(t *testing.T) { + t.Parallel() + in := "{ \"client_secret\":\"x\", \"refresh_token\"\t: \"y\" }" + got := redactSensitiveBody("application/json", in) + if strings.Contains(got, `"x"`) || strings.Contains(got, `"y"`) { + t.Errorf("compact/spaced JSON secrets leaked: %s", got) + } + }) +} + +func TestRedactSensitiveBody_Form(t *testing.T) { + t.Parallel() + + // The SDK's form fallback shape: grant_type=client_credentials&client_id=…&client_secret=… + in := "grant_type=client_credentials&client_id=my-id&client_secret=SUPERSECRET-VALUE" + + "&refresh_token=REFRESH-SECRET&access_token=ACCESS-SECRET&password=PW-SECRET&token=TOK-SECRET" + got := redactSensitiveBody("application/x-www-form-urlencoded", in) + + for _, leaked := range []string{"SUPERSECRET-VALUE", "REFRESH-SECRET", "ACCESS-SECRET", "PW-SECRET", "TOK-SECRET"} { + if strings.Contains(got, leaked) { + t.Errorf("form value %q leaked: %s", leaked, got) + } + } + for _, marker := range []string{ + "client_secret=", "refresh_token=", "access_token=", + "password=", "token=", + } { + if !strings.Contains(got, marker) { + t.Errorf("missing %q in: %s", marker, got) + } + } + // Non-secret fields stay verbatim — debuggability is the whole point. + for _, kept := range []string{"grant_type=client_credentials", "client_id=my-id"} { + if !strings.Contains(got, kept) { + t.Errorf("non-secret field %q mangled: %s", kept, got) + } + } +} + +func TestRedactSensitiveBody_ContentTypeDispatch(t *testing.T) { + t.Parallel() + + t.Run("form shape under json type stays as-is", func(t *testing.T) { + t.Parallel() + in := "client_secret=SUPERSECRET-VALUE" + if got := redactSensitiveBody("application/json", in); got != in { + t.Errorf("form body under JSON content type was mangled: %s", got) + } + }) + + t.Run("json shape under form type stays as-is", func(t *testing.T) { + t.Parallel() + // Under-redaction is the security failure mode; mangling non-matching + // bodies is the debuggability failure mode. The form pattern requires + // key=value, which JSON never has, so the body passes through. + in := `{"client_id": "my-id"}` + if got := redactSensitiveBody("application/x-www-form-urlencoded", in); got != in { + t.Errorf("json body under form content type was mangled: %s", got) + } + }) + + t.Run("content type params and case tolerated", func(t *testing.T) { + t.Parallel() + in := "client_secret=SUPERSECRET-VALUE" + got := redactSensitiveBody("Application/X-WWW-FORM-Urlencoded; charset=utf-8", in) + if strings.Contains(got, "SUPERSECRET-VALUE") { + t.Errorf("form body with params/case leaked: %s", got) + } + }) + + t.Run("empty content type defaults to json", func(t *testing.T) { + t.Parallel() + in := `{"access_token": "TOK-SECRET"}` + got := redactSensitiveBody("", in) + if strings.Contains(got, "TOK-SECRET") { + t.Errorf("json body with empty content type leaked: %s", got) + } + }) +} diff --git a/internal/verda-cli/cmd/util/spinner.go b/internal/verda-cli/cmd/util/spinner.go index 62e1b31..212fd4d 100644 --- a/internal/verda-cli/cmd/util/spinner.go +++ b/internal/verda-cli/cmd/util/spinner.go @@ -22,24 +22,35 @@ import ( // WithSpinner runs fn while showing a spinner message. If status is nil or the // spinner cannot be created, fn is executed directly without visual feedback. -func WithSpinner[T any](ctx context.Context, status tui.Status, msg string, fn func() (T, error)) (T, error) { +// +// fn receives a context derived from ctx: Ctrl+C on the spinner quits the UI +// and cancels it, so the guarded operation aborts instead of running to +// completion unseen (surfacing as context.Canceled through fn's error). +func WithSpinner[T any](ctx context.Context, status tui.Status, msg string, fn func(context.Context) (T, error)) (T, error) { if status == nil { - return fn() + return fn(ctx) } sp, err := status.Spinner(ctx, msg) if err != nil { - return fn() // fallback: run without spinner + return fn(ctx) // fallback: run without spinner } - result, fnErr := fn() + opCtx, cancel := context.WithCancel(ctx) + defer cancel() + go func() { + if sp.Interrupted() { + cancel() + } + }() + result, fnErr := fn(opCtx) sp.Stop("") return result, fnErr } // RunWithSpinner runs fn while showing a spinner message. It is a convenience // wrapper around [WithSpinner] for functions that return only an error. -func RunWithSpinner(ctx context.Context, status tui.Status, msg string, fn func() error) error { - _, err := WithSpinner(ctx, status, msg, func() (struct{}, error) { - return struct{}{}, fn() +func RunWithSpinner(ctx context.Context, status tui.Status, msg string, fn func(context.Context) error) error { + _, err := WithSpinner(ctx, status, msg, func(ctx context.Context) (struct{}, error) { + return struct{}{}, fn(ctx) }) return err } diff --git a/internal/verda-cli/cmd/util/status_messages.go b/internal/verda-cli/cmd/util/status_messages.go index f8b6d56..c93c9f3 100644 --- a/internal/verda-cli/cmd/util/status_messages.go +++ b/internal/verda-cli/cmd/util/status_messages.go @@ -48,6 +48,15 @@ var VolumeTerminalStatuses = map[string]bool{ "detached": true, } +// VolumeFailedStatuses contains volume statuses that mean the operation +// failed; polling must stop and report an error instead of running until +// timeout. +var VolumeFailedStatuses = map[string]bool{ + verda.VolumeStatusCanceled: true, + verda.VolumeStatusDeleted: true, + "error": true, // no SDK constant exists; kept defensively +} + // InstanceStatusMessage returns a human-friendly message for an instance status. // Falls back to the raw status string if no mapping exists. func InstanceStatusMessage(status string) string { diff --git a/internal/verda-cli/cmd/util/wait.go b/internal/verda-cli/cmd/util/wait.go index c8a219a..8b90db0 100644 --- a/internal/verda-cli/cmd/util/wait.go +++ b/internal/verda-cli/cmd/util/wait.go @@ -112,7 +112,9 @@ func Poll(ctx context.Context, w io.Writer, interval time.Duration, opts WaitOpt } // PollInstanceStatus polls an instance until it reaches one of the expected -// statuses (or a terminal status). +// statuses (or a terminal status). A terminal failure status (error, +// not_found) is returned as an error — the operation did not succeed even +// though polling is done, and agents key on the exit code. func PollInstanceStatus(ctx context.Context, w io.Writer, client *verda.Client, instanceID string, opts WaitOptions, expectStatus ...string) (*verda.Instance, error) { target := "" if len(expectStatus) > 0 { @@ -127,17 +129,32 @@ func PollInstanceStatus(ctx context.Context, w io.Writer, client *verda.Client, } lastInst = inst msg := InstanceStatusMessage(inst.Status) + failed := inst.Status == verda.StatusError || inst.Status == verda.StatusNotFound if target != "" { - return msg, inst.Status == target || inst.Status == verda.StatusError || inst.Status == verda.StatusNotFound, nil + if inst.Status == target { + return msg, true, nil + } + if failed { + return msg, false, fmt.Errorf("instance %s in failed status %q while waiting for %q", instanceID, inst.Status, target) + } + return msg, false, nil + } + if InstanceTerminalStatuses[inst.Status] { + if failed { + return msg, false, fmt.Errorf("instance %s in failed status %q", instanceID, inst.Status) + } + return msg, true, nil } - return msg, InstanceTerminalStatuses[inst.Status], nil + return msg, false, nil } _, err := Poll(ctx, w, 5*time.Second, opts, pollFn) return lastInst, err } -// PollVolumeStatus polls a volume until it reaches one of the expected statuses. +// PollVolumeStatus polls a volume until it reaches one of the expected +// statuses. A failed status (canceled, deleted, error) stops polling +// immediately with an error instead of burning the full timeout. func PollVolumeStatus(ctx context.Context, w io.Writer, client *verda.Client, volumeID string, opts WaitOptions, expectStatus ...string) (*verda.Volume, error) { target := "" if len(expectStatus) > 0 { @@ -151,10 +168,16 @@ func PollVolumeStatus(ctx context.Context, w io.Writer, client *verda.Client, vo return "", false, fmt.Errorf("polling volume: %w", err) } lastVol = vol - if target != "" { - return vol.Status, vol.Status == target, nil + if target != "" && vol.Status == target { + return vol.Status, true, nil + } + if VolumeFailedStatuses[vol.Status] { + return vol.Status, false, fmt.Errorf("volume %s in failed status %q", volumeID, vol.Status) + } + if target == "" && VolumeTerminalStatuses[vol.Status] { + return vol.Status, true, nil } - return vol.Status, VolumeTerminalStatuses[vol.Status], nil + return vol.Status, false, nil } _, err := Poll(ctx, w, 5*time.Second, opts, pollFn) diff --git a/internal/verda-cli/cmd/util/wait_test.go b/internal/verda-cli/cmd/util/wait_test.go index df1494b..81451de 100644 --- a/internal/verda-cli/cmd/util/wait_test.go +++ b/internal/verda-cli/cmd/util/wait_test.go @@ -17,9 +17,14 @@ package util import ( "bytes" "context" + "encoding/json" + "net/http" + "net/http/httptest" "strings" "testing" "time" + + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" ) func TestWaitOptionsAddFlags(t *testing.T) { @@ -150,3 +155,154 @@ func TestPollReturnsError(t *testing.T) { t.Fatal("expected error from Poll()") } } + +// --- Poll{Instance,Volume}Status failure propagation --- +// Regression coverage for review NEW-3 (wait.go:131): an instance landing in +// "error"/"notfound" (or a volume in a failed status) must surface as an +// error, never as done-with-nil. Every mock below resolves on the first poll, +// so the hardcoded 5s poll interval never delays these tests. + +func newPollTestClient(t *testing.T, mux *http.ServeMux) *verda.Client { + t.Helper() + mux.HandleFunc("POST /oauth2/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "access_token": "test-token", + "token_type": "Bearer", + }) + }) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + client, err := verda.NewClient( + verda.WithBaseURL(srv.URL), + verda.WithClientID("test-id"), + verda.WithClientSecret("test-secret"), + ) + if err != nil { + t.Fatalf("creating client: %v", err) + } + return client +} + +func instanceStatusClient(t *testing.T, status string) *verda.Client { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /instances/inst-1", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(verda.Instance{ID: "inst-1", Status: status}) + }) + return newPollTestClient(t, mux) +} + +func volumeStatusClient(t *testing.T, status string) *verda.Client { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("GET /volumes/vol-1", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(verda.Volume{ID: "vol-1", Status: status}) + }) + return newPollTestClient(t, mux) +} + +func TestPollInstanceStatusReachesTarget(t *testing.T) { + t.Parallel() + client := instanceStatusClient(t, verda.StatusRunning) + + inst, err := PollInstanceStatus(context.Background(), nil, client, "inst-1", + WaitOptions{Wait: true, Timeout: time.Minute}, verda.StatusRunning) + if err != nil { + t.Fatalf("PollInstanceStatus() error: %v", err) + } + if inst == nil || inst.Status != verda.StatusRunning { + t.Fatalf("got %+v, want running instance", inst) + } +} + +func TestPollInstanceStatusErrorIsFailure(t *testing.T) { + t.Parallel() + client := instanceStatusClient(t, verda.StatusError) + + _, err := PollInstanceStatus(context.Background(), nil, client, "inst-1", + WaitOptions{Wait: true, Timeout: time.Minute}, verda.StatusRunning) + if err == nil { + t.Fatal("PollInstanceStatus() = nil error, want failure on 'error' status") + } + if !strings.Contains(err.Error(), `"error"`) { + t.Fatalf("error %q should name the failed status", err) + } +} + +func TestPollInstanceStatusNotFoundIsFailure(t *testing.T) { + t.Parallel() + client := instanceStatusClient(t, verda.StatusNotFound) + + _, err := PollInstanceStatus(context.Background(), nil, client, "inst-1", + WaitOptions{Wait: true, Timeout: time.Minute}, verda.StatusRunning) + if err == nil { + t.Fatal("PollInstanceStatus() = nil error, want failure on 'notfound' status") + } +} + +func TestPollInstanceStatusNoTargetErrorIsFailure(t *testing.T) { + t.Parallel() + client := instanceStatusClient(t, verda.StatusError) + + // No expected status: waits for any terminal status, still must not + // report success (vm create --wait exited 0 on error before the fix). + _, err := PollInstanceStatus(context.Background(), nil, client, "inst-1", + WaitOptions{Wait: true, Timeout: time.Minute}) + if err == nil { + t.Fatal("PollInstanceStatus() without target = nil error, want failure on 'error' status") + } +} + +func TestPollInstanceStatusNoTargetTerminalOK(t *testing.T) { + t.Parallel() + client := instanceStatusClient(t, verda.StatusRunning) + + inst, err := PollInstanceStatus(context.Background(), nil, client, "inst-1", + WaitOptions{Wait: true, Timeout: time.Minute}) + if err != nil { + t.Fatalf("PollInstanceStatus() error: %v", err) + } + if inst == nil { + t.Fatal("expected last polled instance") + } +} + +func TestPollVolumeStatusReachesTarget(t *testing.T) { + t.Parallel() + client := volumeStatusClient(t, verda.VolumeStatusDetached) + + vol, err := PollVolumeStatus(context.Background(), nil, client, "vol-1", + WaitOptions{Wait: true, Timeout: time.Minute}, verda.VolumeStatusDetached) + if err != nil { + t.Fatalf("PollVolumeStatus() error: %v", err) + } + if vol == nil || vol.Status != verda.VolumeStatusDetached { + t.Fatalf("got %+v, want detached volume", vol) + } +} + +func TestPollVolumeStatusFailedTerminatesImmediately(t *testing.T) { + t.Parallel() + for _, status := range []string{verda.VolumeStatusCanceled, verda.VolumeStatusDeleted, "error"} { + t.Run(status, func(t *testing.T) { + t.Parallel() + client := volumeStatusClient(t, status) + + start := time.Now() + _, err := PollVolumeStatus(context.Background(), nil, client, "vol-1", + WaitOptions{Wait: true, Timeout: 5 * time.Minute}, verda.VolumeStatusDetached) + if err == nil { + t.Fatalf("PollVolumeStatus() = nil error, want failure on %q status", status) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("poll took %s; failed status must terminate immediately, not burn the timeout", elapsed) + } + if !strings.Contains(err.Error(), status) { + t.Fatalf("error %q should name the failed status %q", err, status) + } + }) + } +} diff --git a/internal/verda-cli/cmd/vm/CLAUDE.md b/internal/verda-cli/cmd/vm/CLAUDE.md index c1e7cc8..c0be31c 100644 --- a/internal/verda-cli/cmd/vm/CLAUDE.md +++ b/internal/verda-cli/cmd/vm/CLAUDE.md @@ -23,11 +23,11 @@ ## Domain-Specific Logic ### Pricing (IMPORTANT) -- `price_per_hour` from API is the **TOTAL** instance price, not per-GPU -- Per-unit price = `totalPrice / instanceUnits(t)` where units = GPU count or vCPU count +- `price_per_hour` from API is the **TOTAL** instance price, not per-GPU (verified live on staging 2026-08-09, see `temp/docs/c1-ondemand-instance.json`) +- Per-unit display price = `totalPrice / instanceUnits(t)` where units = GPU count or vCPU count — division only, display only - Spot pricing uses `SpotPrice` field instead of `PricePerHour` -- Volume hourly price: `ceil(monthlyPerGB * sizeGB / 730 * 10000) / 10000` -- 730 = hours in month (365*24/12), matching web frontend constant `hoursInMonth` +- Volume hourly price: `cmdutil.VolumeHourlyPrice(monthlyPerGB, sizeGB)` (canonical helper, do not re-implement) +- `cmdutil.HoursInMonth` = 730 (365*24/12), matching web frontend constant `hoursInMonth` ### Contract Normalization - `normalizeContract()` accepts many aliases: `pay_as_go`, `pay-as-you-go`, `payg`, `spot`, `long_term`, etc. @@ -68,7 +68,7 @@ Fields are populated in a defined sequence — each stage reads fields set by prior stages: 1. **Flag parsing (cobra)** — Sets all public fields from CLI flags. LocationCode defaults to FIN-01. -2. **Template application (applyTemplate)** — Overwrites empty fields with template values. Sets `billingTypeSet`, `locationSet`, `storageSkip`, `startupScriptSkip` coordination flags. Expands HostnamePattern. +2. **Template application (applyTemplate)** — Fills fields the user did not pass explicitly (`cmd.Flags().Changed` is the authority: flags beat template values, e.g. `--from t --location FIN-03` keeps FIN-03). Sets `billingTypeSet`, `locationSet`, `storageSkip`, `startupScriptSkip` coordination flags (only for template-sourced values). Expands HostnamePattern (kept in `opts.hostnamePattern` so the wizard location step can re-expand `{location}` against the effective location). 3. **Name resolution (resolveTemplateNames)** — Resolves `sshKeyNames` → `SSHKeyIDs`, `startupScriptName` → `StartupScriptID` via API. On failure, prints warnings and leaves IDs empty for wizard. 4. **Wizard (buildCreateFlow steps)** — Fills remaining gaps interactively. Steps check `IsSet` to skip pre-filled values. Steps 8/9/10 manage state directly via Loader closures. 5. **Request building (request())** — Reads all fields to assemble `CreateInstanceRequest`. Auto-sets `Contract=contractSpot` when IsSpot && Contract is empty. @@ -84,7 +84,7 @@ startup-script -> hostname -> description -> confirm-deploy - Steps with `DependsOn` re-run their Loader when dependencies change - `contract` step: `ShouldSkip` returns true for spot billing - `instance-type` step: accepts `WizardMode`. Deploy mode filters by real-time availability; template mode shows all instance types from the instance-types API (no availability filtering) -- `location` step: accepts `WizardMode`. Deploy mode shows only locations where the instance type is available; template mode shows all locations with a "None (decide at deploy time)" skip option. Deploy mode returns a clear error when no locations are available for the instance type +- `location` step: accepts `WizardMode`. Deploy mode shows only locations where the instance type is available and returns a clear error when none are; template mode shows all locations with a "None (decide at deploy time)" choice whose value is the `locationDecideLater` sentinel — an empty value would trip the engine's Default substitution and silently persist FIN-01 (review H5). The Setter also re-expands a template `hostnamePattern`'s `{location}` against the picked location so deploy hostnames use the effective location. - `location` step: `IsSet` treats default `FIN-01` as unset (so wizard prompts) - `location` step: `Required` is dynamic — true in deploy mode, false in template mode - `storage`, `ssh-keys`, `startup-script` steps: manage values directly in Loader (Setter/Resetter are no-ops), include inline sub-flows for creating new resources via API @@ -103,7 +103,8 @@ startup-script -> hostname -> description -> confirm-deploy - **Wizard triggers when ANY of instance-type, os, or hostname is missing** -- not all three. Providing two of three still launches the wizard. Also triggers when `--from` was used but the template had no location (`templateWithoutLocation` check in `resolveCreateInputs`). - **Location default quirk**: `LocationCode` defaults to `FIN-01` in createOptions, but the wizard's `IsSet` returns false for `FIN-01` specifically, so the wizard always prompts for location even when the default is in effect. -- **Flags override template values**: When `--from` is used alongside other flags (e.g. `--hostname`, `--location`), flags are parsed first, then `applyTemplate` only overwrites empty fields. CLI flags take precedence. +- **Flags override template values**: `applyTemplate` fills only flags the user did not pass — `cmd.Flags().Changed` is the authority (not field emptiness), and template-sourced values are the only ones that arm the `*Set` coordination flags. +- **Contract step offers only deployable contracts**: the Loader drops long-term periods whose codes `normalizeContract` would reject at request time (POST /v1/instances takes no durations). Non-fatal API errors: if fetching periods fails, the step falls back to offering only "Pay as you go". - **apiCache invalidation**: Cache is invalidated when `isSpot` changes (user switches billing type), because availability differs between spot and on-demand. - **Lazy client resolution**: `clientFunc` defers credential resolution until the first API-dependent wizard step fires. Early steps (billing-type, kind, text inputs) run without credentials. - **Hidden flag aliases**: `--type`, `--image`, `--ssh-key-id`, `--startup-script-id`, `--spot` are hidden aliases for their primary flags. @@ -113,8 +114,9 @@ startup-script -> hostname -> description -> confirm-deploy - **Delete does NOT poll** -- `action.Execute` is nil for delete, handled by `runDeleteFlow` which returns after the API call. - **SSH key / startup script inline creation**: These wizard steps create resources via API during the wizard, not deferred to instance creation. - **Cluster images filtered out**: `stepImage` skips images where `IsCluster` is true. -- **Contract step non-fatal API errors**: If fetching long-term periods fails, the step gracefully falls back to offering only "Pay as you go". - **Agent-mode missing flags checked before template application**: `missingCreateFlags` runs before `resolveCreateInputs`, so `--from` alone cannot satisfy required flags in agent mode. +- **Agent mode never waits by default**: `--wait`'s default is locked in at flag registration, before `--agent` is parsed (the factory is built during command-tree construction), so `runCreate` and the `vm action` agent branch apply the override at runtime: they return after issuance with `status: "accepted"` unless `--wait` was passed explicitly, in which case they poll via `cmdutil.PollInstanceStatus` and report `completed` (a failed transition is an error). MCP `vm_action` shares this accepted/completed contract. +- **Delete volume semantics are explicit, never nil**: interactive single delete, agent single delete, and batch delete all pass an explicit `[]string{}` when no volumes should die — nil `volume_ids` invokes the API default of deleting the OS volume, contradicting the "unselected keeps billing" warning. Agent single delete mirrors batch exactly: `--yes` required, volumes only with `--with-volumes`, batch-shaped JSON output. - **Template name resolution warnings**: `resolveSSHKeyNames` and `resolveStartupScriptName` now return warnings instead of silently swallowing errors. ## Relationships diff --git a/internal/verda-cli/cmd/vm/README.md b/internal/verda-cli/cmd/vm/README.md index 305b830..9ef3646 100644 --- a/internal/verda-cli/cmd/vm/README.md +++ b/internal/verda-cli/cmd/vm/README.md @@ -139,7 +139,7 @@ Destructive actions (Shutdown, Force shutdown, Delete) show confirmation prompts - **vm.go** -- Parent command definition, registers subcommands and shortcut commands - **create.go** -- `vm create` command, flag definitions, `createOptions` struct (with 5-stage mutation lifecycle), request building, contract normalization, volume spec parsing, kind validation - **wizard.go** -- 13 wizard step definitions using the wizard engine; `clientFunc` lazy client pattern; `WizardMode` (Deploy vs Template); `RunTemplateWizard`; step Default functions for pre-selection -- **wizard_cache.go** -- `apiCache` struct for deduplicating API calls, `fetchLocations` (locations without availability), `loadAllLocations`/`loadAvailableLocations` (extracted location loaders), `ensurePricingCache`, pricing helpers (`volumeHourlyPrice`, `instanceUnits`), instance type matching (`matchesKind`, `formatGPU`, `formatMemory`) +- **wizard_cache.go** -- `apiCache` struct for deduplicating API calls, `fetchLocations` (locations without availability), `loadAllLocations`/`loadAvailableLocations` (extracted location loaders), `ensurePricingCache`, instance type matching (`instanceUnits`, `matchesKind`, `formatGPU`, `formatMemory`) — volume price math lives in `cmdutil` (`VolumeHourlyPrice`/`VolumeMonthlyPrice`) - **wizard_subflows.go** -- Interactive sub-flows for SSH key creation, startup script creation, storage volume management; choice builders for multi-select prompts - **wizard_summary.go** -- `renderDeploymentSummary` with full cost breakdown (accepts `io.Writer`) - **template_apply.go** -- `resolveCreateInputs` orchestration, `applyTemplate`, `resolveTemplateNames` (with warnings), `printTemplateSummary`, `pickTemplate` diff --git a/internal/verda-cli/cmd/vm/action.go b/internal/verda-cli/cmd/vm/action.go index 242678e..05f96c2 100644 --- a/internal/verda-cli/cmd/vm/action.go +++ b/internal/verda-cli/cmd/vm/action.go @@ -156,6 +156,10 @@ func NewCmdAction(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command cmd.Flags().StringVar(&opts.InstanceID, "id", "", "Instance ID to act on") cmd.Flags().StringVar(&opts.Action, "action", "", "Action to perform: start, shutdown, force_shutdown, hibernate, delete") cmd.Flags().BoolVar(&opts.Yes, "yes", false, "Skip confirmation for destructive actions (required in agent mode)") + cmd.Flags().BoolVar(&opts.WithVolumes, "with-volumes", false, "Also delete all attached volumes (delete only)") + // Hidden like on non-delete shortcuts: `vm delete --with-volumes` is the + // canonical UX; the flag works here for `--action delete` (agent parity). + _ = cmd.Flags().MarkHidden("with-volumes") opts.Wait.AddFlags(cmd.Flags(), true) // --wait defaults to true to preserve existing behavior return cmd @@ -172,7 +176,7 @@ func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream if opts.Hostname != "" { flags = append(flags, "--hostname") } - return fmt.Errorf("%s can only be used with --all", strings.Join(flags, " and ")) + return cmdutil.UsageErrorf(cmd, "%s can only be used with --all", strings.Join(flags, " and ")) } // In agent mode, --id and --action are required. @@ -214,8 +218,11 @@ func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream opts.InstanceID = id } - // Fetch instance details. - inst, err := client.Instances.GetByID(ctx, opts.InstanceID) + // Fetch instance details. Bare cmd.Context() would have no deadline now + // that the HTTP client carries no Timeout (review H2 sweep). + fetchCtx, fetchCancel := context.WithTimeout(ctx, f.Options().Timeout) + inst, err := client.Instances.GetByID(fetchCtx, opts.InstanceID) + fetchCancel() if err != nil { return fmt.Errorf("fetching instance: %w", err) } @@ -238,35 +245,32 @@ func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream var action instanceAction if opts.Action != "" { - var resolveErr error - action, resolveErr = resolveAction(opts.Action, validActions) - if resolveErr != nil { - return resolveErr + var err error + action, err = resolveAction(opts.Action, validActions) + if err != nil { + return err } } else { // Interactive: show instance summary and prompt for action. - _, _ = fmt.Fprint(ioStreams.Out, renderInstanceCard(inst)) - - actionLabels := make([]string, 0, len(validActions)+1) - for _, a := range validActions { - actionLabels = append(actionLabels, a.Label) - } - actionLabels = append(actionLabels, "Cancel") - - actionIdx, err := prompter.Select(ctx, "Select action", actionLabels, tui.WithShowHints(true)) + picked, err := selectAction(ctx, ioStreams, prompter, inst, validActions) if err != nil { - return nil + return err } - if actionIdx == len(validActions) { // Cancel - return nil + if picked == nil { + return nil // User canceled. } - action = validActions[actionIdx] + action = *picked + } + + // --with-volumes only applies to delete (delete has Execute == nil). + if opts.WithVolumes && action.Execute != nil { + return cmdutil.UsageErrorf(cmd, "--with-volumes is only valid with the delete action") } // Special handling for delete — needs volume selection sub-flow. if action.Execute == nil { if f.AgentMode() { - return runDeleteAgent(ctx, f, ioStreams, client, inst, opts.Yes) + return runDeleteAgent(ctx, f, ioStreams, client, inst, opts.Yes, opts.WithVolumes) } return runDeleteFlow(ctx, f, ioStreams, client, inst) } @@ -278,7 +282,14 @@ func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream } if isDestructive && !f.AgentMode() { confirmed, err := confirmDestructive(ctx, ioStreams, prompter, &action, inst) - if err != nil || !confirmed { + if err != nil { + if cmdutil.IsPromptCancel(err) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return err + } + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } @@ -294,7 +305,7 @@ func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream actionCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) defer cancel() - err = cmdutil.RunWithSpinner(actionCtx, f.Status(), fmt.Sprintf("%s %s...", action.Label, inst.Hostname), func() error { + err = cmdutil.RunWithSpinner(actionCtx, f.Status(), fmt.Sprintf("%s %s...", action.Label, inst.Hostname), func(ctx context.Context) error { return action.Execute(actionCtx, client, inst) }) if err != nil { @@ -303,10 +314,21 @@ func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream // Structured output for agent mode. if f.AgentMode() { + // Truthful default: the API accepted the action; nothing has completed + // yet (same contract as MCP vm_action). --wait defaults to true but is + // locked in before --agent is parsed, so only an explicitly passed flag + // opts the agent into polling (same override as vm create). result := map[string]string{ "id": inst.ID, "action": opts.Action, - "status": "completed", + "status": "accepted", + } + wait := opts.Wait.Wait && cmd.Flags().Changed("wait") + if wait && action.ExpectStatus != "" { + if _, err := cmdutil.PollInstanceStatus(ctx, nil, client, inst.ID, opts.Wait, action.ExpectStatus); err != nil { + return err + } + result["status"] = "completed" } _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), result) return nil @@ -328,35 +350,30 @@ func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream return nil } -// runDeleteAgent handles delete in agent mode: requires --yes, deletes all volumes. -func runDeleteAgent(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client *verda.Client, inst *verda.Instance, yes bool) error { +// runDeleteAgent handles a single-instance delete in agent mode. It mirrors +// the batch contract exactly: --yes is required, attached volumes are deleted +// only with --with-volumes, and the JSON output shares the batch shape. +func runDeleteAgent(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client *verda.Client, inst *verda.Instance, yes, withVolumes bool) error { if !yes { return cmdutil.NewConfirmationRequiredError(verda.ActionDelete) } - // In agent mode, delete the instance and all attached volumes. - volumes := fetchInstanceVolumes(ctx, client, inst) - volumeIDs := make([]string, 0, len(volumes)) - for i := range volumes { - volumeIDs = append(volumeIDs, volumes[i].ID) + // VolumeIDs: nil would invoke the API default (OS volume deleted), so the + // default path passes an explicit empty slice and only --with-volumes + // names the attached volumes (same as runBatchDelete). + volumeIDs := []string{} + if withVolumes { + volumeIDs = cmdutil.UniqueVolumeIDs(inst) } deleteCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) defer cancel() - err := client.Instances.Delete(deleteCtx, []string{inst.ID}, volumeIDs, false) - if err != nil { + if err := client.Instances.Delete(deleteCtx, []string{inst.ID}, volumeIDs, false); err != nil { return err } - result := map[string]any{ - "id": inst.ID, - "action": verda.ActionDelete, - "status": "completed", - "volumes_deleted": len(volumeIDs), - } - _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), result) - return nil + return writeBatchAgentOutput(ioStreams, f.OutputFormat(), verda.ActionDelete, []verda.Instance{*inst}, nil) } // resolveInstanceInteractive handles interactive instance selection. @@ -414,7 +431,10 @@ func selectInstance(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IO idx, err := f.Prompter().Select(ctx, "Select instance (type to filter)", labels, tui.WithShowHints(true)) if err != nil { - return "", nil //nolint:nilerr // User pressed Esc/Ctrl+C during prompt. + if cmdutil.IsPromptCancel(err) { + return "", nil // User pressed Esc/Ctrl+C during prompt. + } + return "", err } if idx == len(instances) { return "", nil @@ -423,6 +443,30 @@ func selectInstance(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IO return instances[idx].ID, nil } +// selectAction shows the instance summary and prompts for an action. +// Returns nil when the user cancels or picks "Cancel". +func selectAction(ctx context.Context, ioStreams cmdutil.IOStreams, prompter tui.Prompter, inst *verda.Instance, validActions []instanceAction) (*instanceAction, error) { + _, _ = fmt.Fprint(ioStreams.Out, renderInstanceCard(inst)) + + actionLabels := make([]string, 0, len(validActions)+1) + for _, a := range validActions { + actionLabels = append(actionLabels, a.Label) + } + actionLabels = append(actionLabels, "Cancel") + + actionIdx, err := prompter.Select(ctx, "Select action", actionLabels, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil, nil // User pressed Esc/Ctrl+C. + } + return nil, err + } + if actionIdx == len(validActions) { // Cancel + return nil, nil + } + return &validActions[actionIdx], nil +} + // runDeleteFlow handles the delete action with volume selection. func runDeleteFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client *verda.Client, inst *verda.Instance) error { prompter := f.Prompter() @@ -437,7 +481,9 @@ func runDeleteFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOS // Fetch attached volumes. volumes := fetchInstanceVolumes(ctx, client, inst) - var volumeIDs []string + // Explicit empty slice, not nil: nil volume_ids invokes the API default + // of deleting the OS volume, contradicting the keep-billing warning below. + volumeIDs := []string{} if len(volumes) > 0 { _, _ = fmt.Fprintf(ioStreams.ErrOut, " Choose storage to delete\n") _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n\n", dimStyle.Render("Deleted storage can be restored within 96 hours")) @@ -453,7 +499,10 @@ func runDeleteFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOS indices, err := prompter.MultiSelect(ctx, "Select volumes to delete (optional)", labels) if err != nil { - return nil + if cmdutil.IsPromptCancel(err) { + return nil // User pressed Esc/Ctrl+C. + } + return err } for _, idx := range indices { volumeIDs = append(volumeIDs, volumes[idx].ID) @@ -471,7 +520,14 @@ func runDeleteFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOS warnStyle.Render("This action cannot be undone.")) confirmed, err := prompter.Confirm(ctx, fmt.Sprintf("Delete %s?", inst.Hostname)) - if err != nil || !confirmed { + if err != nil { + if cmdutil.IsPromptCancel(err) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return err + } + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } @@ -486,7 +542,7 @@ func runDeleteFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOS deleteCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) defer cancel() - err = cmdutil.RunWithSpinner(deleteCtx, f.Status(), fmt.Sprintf("Deleting %s...", inst.Hostname), func() error { + err = cmdutil.RunWithSpinner(deleteCtx, f.Status(), fmt.Sprintf("Deleting %s...", inst.Hostname), func(ctx context.Context) error { return client.Instances.Delete(deleteCtx, []string{inst.ID}, volumeIDs, false) }) if err != nil { diff --git a/internal/verda-cli/cmd/vm/action_delete_test.go b/internal/verda-cli/cmd/vm/action_delete_test.go new file mode 100644 index 0000000..4c587fe --- /dev/null +++ b/internal/verda-cli/cmd/vm/action_delete_test.go @@ -0,0 +1,346 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vm + +import ( + "encoding/json" + "net/http" + "slices" + "strings" + "sync/atomic" + "testing" + + tuitest "github.com/verda-cloud/verda-cli/pkg/tui/testing" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// deleteMux serves an instance with an OS volume and one data volume, plus a +// capturing PUT /instances handler so tests can assert on the volume_ids the +// client sent (nil → API default deletes the OS volume; [] → delete none). +type deleteMux struct { + lastAction atomic.Value // map[string]any of the last PUT /instances body + getCalls atomic.Int32 + status atomic.Value // instance status returned by GET /instances/{id} + afterAction atomic.Value // status the instance lands in after an action +} + +func newDeleteMux() (*http.ServeMux, *deleteMux) { + d := &deleteMux{} + d.status.Store("running") + d.afterAction.Store("offline") + + mux := baseMux() + mux.HandleFunc("GET /instances/{id}", func(w http.ResponseWriter, _ *http.Request) { + d.getCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "inst-1", + "hostname": "test-vm", + "status": d.status.Load(), + "instance_type": "1V100.6V", + "location": "FIN-01", + "os_volume_id": "vol-os", + "volume_ids": []string{"vol-data"}, + "price_per_hour": 1.5, + }) + }) + mux.HandleFunc("GET /volumes/{id}", func(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + isOS := id == "vol-os" + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": id, + "name": id, + "size": 100, + "type": "NVMe", + "status": "attached", + "is_os_volume": isOS, + }) + }) + mux.HandleFunc("PUT /instances", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + d.lastAction.Store(body) + if a, _ := body["action"].(string); a == "shutdown" { + d.status.Store(d.afterAction.Load()) + } + w.WriteHeader(http.StatusNoContent) + }) + return mux, d +} + +// capturedVolumeIDs returns the volume_ids of the last action request, +// distinguishing null (API default) from an explicit empty list. +func (d *deleteMux) capturedVolumeIDs(t *testing.T) (ids []string, explicit bool) { + t.Helper() + v := d.lastAction.Load() + if v == nil { + t.Fatal("no action request captured") + } + raw, ok := v.(map[string]any)["volume_ids"] + if !ok || raw == nil { + return nil, false + } + list, ok := raw.([]any) + if !ok { + t.Fatalf("volume_ids has unexpected type %T", raw) + } + for _, id := range list { + ids = append(ids, id.(string)) + } + return ids, true +} + +// TestDeleteFlow_NoVolumesSelectedKeepsVolumes is the H7 regression test for +// the interactive single delete: selecting no volumes in the picker must send +// an explicit empty volume_ids, not nil (nil = API default deletes the OS +// volume, contradicting the "keeps billing" warning). +func TestDeleteFlow_NoVolumesSelectedKeepsVolumes(t *testing.T) { + t.Parallel() + + mux, d := newDeleteMux() + srv := newTestHarness(t, mux) + srv.Factory.AgentModeOverride = false + srv.Factory.OutputFormatOverride = "" + srv.Factory.PrompterOverride = tuitest.New(). + AddMultiSelect([]int{}). // no volumes selected + AddConfirm(true) + + cmd := NewCmdAction(srv.Factory, srv.IOStreams) + cmd.SetArgs([]string{"--id", "inst-1", "--action", "delete"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() returned error: %v\nStderr: %s", err, srv.Stderr.String()) + } + + ids, explicit := d.capturedVolumeIDs(t) + if !explicit { + t.Fatal("volume_ids was null/absent — nil invokes the API default (OS volume deleted)") + } + if len(ids) != 0 { + t.Fatalf("expected empty volume_ids (nothing selected), got %v", ids) + } + if !strings.Contains(srv.Stderr.String(), "continue to charge") { + t.Error("expected the keep-billing warning when not all volumes are selected") + } +} + +// TestDeleteFlow_SelectedVolumesDeleted: exactly the picked volumes are sent. +// fetchInstanceVolumes fetches concurrently, so assert order-insensitively. +func TestDeleteFlow_SelectedVolumesDeleted(t *testing.T) { + t.Parallel() + + mux, d := newDeleteMux() + srv := newTestHarness(t, mux) + srv.Factory.AgentModeOverride = false + srv.Factory.OutputFormatOverride = "" + srv.Factory.PrompterOverride = tuitest.New(). + AddMultiSelect([]int{0, 1}). // both volumes + AddConfirm(true) + + cmd := NewCmdAction(srv.Factory, srv.IOStreams) + cmd.SetArgs([]string{"--id", "inst-1", "--action", "delete"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() returned error: %v\nStderr: %s", err, srv.Stderr.String()) + } + + ids, explicit := d.capturedVolumeIDs(t) + slices.Sort(ids) + if !explicit || !slices.Equal(ids, []string{"vol-data", "vol-os"}) { + t.Fatalf("expected volume_ids=[vol-data vol-os], got %v (explicit=%v)", ids, explicit) + } + if strings.Contains(srv.Stderr.String(), "continue to charge") { + t.Error("keep-billing warning shown although all volumes were selected") + } +} + +// TestDeleteAgent_DefaultKeepsVolumes mirrors the batch contract: without +// --with-volumes, agent-mode single delete sends volume_ids=[] and reports the +// batch JSON shape. +func TestDeleteAgent_DefaultKeepsVolumes(t *testing.T) { + t.Parallel() + + mux, d := newDeleteMux() + srv := newTestHarness(t, mux) // agent mode + JSON by default + + cmd := NewCmdAction(srv.Factory, srv.IOStreams) + cmd.SetArgs([]string{"--id", "inst-1", "--action", "delete", "--yes"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() returned error: %v\nStderr: %s", err, srv.Stderr.String()) + } + + ids, explicit := d.capturedVolumeIDs(t) + if !explicit || len(ids) != 0 { + t.Fatalf("expected explicit empty volume_ids, got %v (explicit=%v)", ids, explicit) + } + + var out map[string]any + if err := json.Unmarshal(srv.Stdout.Bytes(), &out); err != nil { + t.Fatalf("failed to parse JSON output: %v\nOutput: %s", err, srv.Stdout.String()) + } + if out["action"] != "delete" || out["total"] != float64(1) || out["succeeded"] != float64(1) { + t.Errorf("unexpected batch-shaped output: %v", out) + } + results, ok := out["results"].([]any) + if !ok || len(results) != 1 { + t.Fatalf("expected one result entry, got %v", out["results"]) + } + entry := results[0].(map[string]any) + if entry["instance_id"] != "inst-1" || entry["status"] != "success" { + t.Errorf("unexpected result entry: %v", entry) + } + if _, leaked := out["volumes_deleted"]; leaked { + t.Error("volumes_deleted leaked into batch-shaped output") + } +} + +// TestDeleteAgent_WithVolumesDeletesAll: --with-volumes names every attached +// volume (OS first, then data, deduplicated — same helper as batch). +func TestDeleteAgent_WithVolumesDeletesAll(t *testing.T) { + t.Parallel() + + mux, d := newDeleteMux() + srv := newTestHarness(t, mux) + + cmd := NewCmdAction(srv.Factory, srv.IOStreams) + cmd.SetArgs([]string{"--id", "inst-1", "--action", "delete", "--yes", "--with-volumes"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() returned error: %v\nStderr: %s", err, srv.Stderr.String()) + } + + ids, explicit := d.capturedVolumeIDs(t) + if !explicit || len(ids) != 2 || ids[0] != "vol-os" || ids[1] != "vol-data" { + t.Fatalf("expected volume_ids=[vol-os vol-data], got %v (explicit=%v)", ids, explicit) + } +} + +func TestDeleteAgent_RequiresYes(t *testing.T) { + t.Parallel() + + mux, _ := newDeleteMux() + srv := newTestHarness(t, mux) + + cmd := NewCmdAction(srv.Factory, srv.IOStreams) + cmd.SetArgs([]string{"--id", "inst-1", "--action", "delete"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected CONFIRMATION_REQUIRED, got nil") + } + if !cmdutil.IsAgentError(err) || !strings.Contains(err.Error(), "CONFIRMATION_REQUIRED") { + t.Fatalf("expected CONFIRMATION_REQUIRED agent error, got %v", err) + } +} + +// TestWithVolumesRejectedForNonDeleteSingle: same validation as batch. +func TestWithVolumesRejectedForNonDeleteSingle(t *testing.T) { + t.Parallel() + + mux, _ := newDeleteMux() + srv := newTestHarness(t, mux) + + cmd := NewCmdAction(srv.Factory, srv.IOStreams) + cmd.SetArgs([]string{"--id", "inst-1", "--action", "shutdown", "--yes", "--with-volumes"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for --with-volumes on non-delete action") + } + if !strings.Contains(err.Error(), "--with-volumes is only valid with the delete action") { + t.Fatalf("unexpected error: %v", err) + } +} + +// TestActionAgent_DefaultReportsAccepted: agent-mode actions tell the truth — +// the API accepted the action; nothing is polled unless --wait is explicit +// (--wait defaults true but locks in before --agent is parsed). +func TestActionAgent_DefaultReportsAccepted(t *testing.T) { + t.Parallel() + + mux, d := newDeleteMux() + srv := newTestHarness(t, mux) + + cmd := NewCmdAction(srv.Factory, srv.IOStreams) + cmd.SetArgs([]string{"--id", "inst-1", "--action", "shutdown", "--yes"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() returned error: %v\nStderr: %s", err, srv.Stderr.String()) + } + + var out map[string]any + if err := json.Unmarshal(srv.Stdout.Bytes(), &out); err != nil { + t.Fatalf("failed to parse JSON output: %v\nOutput: %s", err, srv.Stdout.String()) + } + if out["status"] != "accepted" { + t.Errorf("status = %v, want accepted (no --wait, no polling claim)", out["status"]) + } + // Exactly one GET (the pre-action fetch); polling would add more. + if got := d.getCalls.Load(); got != 1 { + t.Errorf("GET /instances/{id} calls = %d, want 1 (no polling by default)", got) + } +} + +// TestActionAgent_ExplicitWaitPollsToCompleted: an explicit --wait polls to +// the expected status and reports completed. +func TestActionAgent_ExplicitWaitPollsToCompleted(t *testing.T) { + t.Parallel() + + mux, d := newDeleteMux() + srv := newTestHarness(t, mux) + + cmd := NewCmdAction(srv.Factory, srv.IOStreams) + cmd.SetArgs([]string{"--id", "inst-1", "--action", "shutdown", "--yes", "--wait"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() returned error: %v\nStderr: %s", err, srv.Stderr.String()) + } + + var out map[string]any + if err := json.Unmarshal(srv.Stdout.Bytes(), &out); err != nil { + t.Fatalf("failed to parse JSON output: %v\nOutput: %s", err, srv.Stdout.String()) + } + if out["status"] != "completed" { + t.Errorf("status = %v, want completed after --wait poll", out["status"]) + } + if got := d.getCalls.Load(); got < 2 { + t.Errorf("GET /instances/{id} calls = %d, want >= 2 (fetch + poll)", got) + } +} + +// TestActionAgent_WaitFailureIsError: a failed transition during an explicit +// --wait surfaces as an error, not as a completed success (agents key on the +// exit code). +func TestActionAgent_WaitFailureIsError(t *testing.T) { + t.Parallel() + + mux, d := newDeleteMux() + srv := newTestHarness(t, mux) + d.afterAction.Store("error") // the shutdown action fails server-side + + cmd := NewCmdAction(srv.Factory, srv.IOStreams) + cmd.SetArgs([]string{"--id", "inst-1", "--action", "shutdown", "--yes", "--wait"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error when instance transitions to error during --wait") + } + if !strings.Contains(err.Error(), "error") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/internal/verda-cli/cmd/vm/availability.go b/internal/verda-cli/cmd/vm/availability.go index 57507a6..799aa5b 100644 --- a/internal/verda-cli/cmd/vm/availability.go +++ b/internal/verda-cli/cmd/vm/availability.go @@ -106,7 +106,7 @@ func runAvailability(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO types []verda.InstanceTypeInfo avail []verda.LocationAvailability } - data, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading availability and pricing...", func() (availData, error) { + data, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading availability and pricing...", func(ctx context.Context) (availData, error) { types, typesErr := client.InstanceTypes.Get(ctx, "usd") if typesErr != nil { return availData{}, fmt.Errorf("fetching instance types: %w", typesErr) diff --git a/internal/verda-cli/cmd/vm/batch.go b/internal/verda-cli/cmd/vm/batch.go index 029cacb..f398bfc 100644 --- a/internal/verda-cli/cmd/vm/batch.go +++ b/internal/verda-cli/cmd/vm/batch.go @@ -16,7 +16,6 @@ package vm import ( "context" - "errors" "fmt" "path/filepath" "strings" @@ -34,12 +33,12 @@ import ( func runBatchAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *actionOptions) error { // Validation: --all cannot be combined with --id or a positional instance ID. if opts.InstanceID != "" { - return errors.New("cannot combine --all with --id or positional instance ID") + return cmdutil.UsageErrorf(cmd, "cannot combine --all with --id or positional instance ID") } // Validation: --with-volumes is only valid for delete. if opts.WithVolumes && opts.Action != verda.ActionDelete { - return errors.New("--with-volumes is only valid with the delete action") + return cmdutil.UsageErrorf(cmd, "--with-volumes is only valid with the delete action") } // Agent mode requires --yes for batch operations (always destructive at scale). @@ -77,15 +76,11 @@ func runBatchAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOS // Interactive confirmation. if !f.AgentMode() { - _, _ = fmt.Fprint(ioStreams.ErrOut, formatBatchConfirmation(action.Label, instances)) - - if action.WarningMsg != "" { - warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) - _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n\n", warnStyle.Render(action.WarningMsg)) + confirmed, err := confirmBatchAction(ctx, f, ioStreams, &action, instances) + if err != nil { + return err } - - confirmed, confirmErr := f.Prompter().Confirm(ctx, fmt.Sprintf("Continue? (%s %d instances)", action.Label, len(instances))) - if confirmErr != nil || !confirmed { + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } @@ -106,7 +101,7 @@ func runBatchAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOS actionCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) defer cancel() - results, err := cmdutil.WithSpinner(actionCtx, f.Status(), fmt.Sprintf("%s %d instances...", action.Label, len(instances)), func() ([]verda.InstanceActionResult, error) { + results, err := cmdutil.WithSpinner(actionCtx, f.Status(), fmt.Sprintf("%s %d instances...", action.Label, len(instances)), func(ctx context.Context) ([]verda.InstanceActionResult, error) { return client.Instances.Action(actionCtx, verda.InstanceActionRequest{ Action: actionNameToAPI(opts.Action), ID: ids, @@ -131,9 +126,13 @@ func runBatchDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOS deleteVolumes := opts.WithVolumes // Interactive mode: prompt for volume deletion and confirm. + // confirmBatchDelete already maps prompt cancel to (false, false, nil). if !f.AgentMode() { confirmed, withVols, err := confirmBatchDelete(ctx, f, ioStreams, instances, opts.WithVolumes) - if err != nil || !confirmed { + if err != nil { + return err + } + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } @@ -179,7 +178,7 @@ func runBatchDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOS deleteCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) defer cancel() - results, err := cmdutil.WithSpinner(deleteCtx, f.Status(), fmt.Sprintf("Deleting %d instances...", len(instances)), func() ([]verda.InstanceActionResult, error) { + results, err := cmdutil.WithSpinner(deleteCtx, f.Status(), fmt.Sprintf("Deleting %d instances...", len(instances)), func(ctx context.Context) ([]verda.InstanceActionResult, error) { return client.Instances.Action(deleteCtx, verda.InstanceActionRequest{ Action: verda.ActionDelete, ID: ids, @@ -201,6 +200,26 @@ func runBatchDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOS return nil } +// confirmBatchAction prints the batch summary and action warning, then asks +// the user to confirm. Returns (false, nil) when the user declines or cancels. +func confirmBatchAction(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, action *instanceAction, instances []verda.Instance) (bool, error) { + _, _ = fmt.Fprint(ioStreams.ErrOut, formatBatchConfirmation(action.Label, instances)) + + if action.WarningMsg != "" { + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) + _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n\n", warnStyle.Render(action.WarningMsg)) + } + + confirmed, err := f.Prompter().Confirm(ctx, fmt.Sprintf("Continue? (%s %d instances)", action.Label, len(instances))) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return false, nil // User pressed Esc/Ctrl+C. + } + return false, err + } + return confirmed, nil +} + // confirmBatchDelete runs the interactive confirmation flow for batch delete. // Returns (confirmed, deleteVolumes, error). func confirmBatchDelete(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, instances []verda.Instance, withVolumesFlag bool) (confirmed, deleteVolumes bool, _ error) { @@ -220,7 +239,10 @@ func confirmBatchDelete(ctx context.Context, f cmdutil.Factory, ioStreams cmduti var volErr error deleteVolumes, volErr = prompter.Confirm(ctx, "Also delete all attached volumes?") if volErr != nil { - return false, false, nil //nolint:nilerr // User pressed Esc/Ctrl+C during prompt. + if cmdutil.IsPromptCancel(volErr) { + return false, false, nil // User pressed Esc/Ctrl+C during prompt. + } + return false, false, volErr } } @@ -234,7 +256,10 @@ func confirmBatchDelete(ctx context.Context, f cmdutil.Factory, ioStreams cmduti var confirmErr error confirmed, confirmErr = prompter.Confirm(ctx, fmt.Sprintf("Delete %d instances?", len(instances))) if confirmErr != nil { - return false, false, nil //nolint:nilerr // User pressed Esc/Ctrl+C during prompt. + if cmdutil.IsPromptCancel(confirmErr) { + return false, false, nil // User pressed Esc/Ctrl+C during prompt. + } + return false, false, confirmErr } return confirmed, deleteVolumes, nil } @@ -343,7 +368,10 @@ func selectInstances(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.I indices, err := f.Prompter().MultiSelect(ctx, "Select instances", labels) if err != nil { - return nil, nil //nolint:nilerr // User pressed Esc/Ctrl+C. + if cmdutil.IsPromptCancel(err) { + return nil, nil // User pressed Esc/Ctrl+C. + } + return nil, err } if len(indices) == 0 { _, _ = fmt.Fprintln(ioStreams.ErrOut, "No instances selected.") @@ -373,14 +401,11 @@ func runBatchWithInstances(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdu } // Confirmation. - _, _ = fmt.Fprint(ioStreams.ErrOut, formatBatchConfirmation(action.Label, instances)) - if action.WarningMsg != "" { - warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) - _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n\n", warnStyle.Render(action.WarningMsg)) + confirmed, confirmErr := confirmBatchAction(ctx, f, ioStreams, &action, instances) + if confirmErr != nil { + return confirmErr } - - confirmed, confirmErr := f.Prompter().Confirm(ctx, fmt.Sprintf("Continue? (%s %d instances)", action.Label, len(instances))) - if confirmErr != nil || !confirmed { + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } @@ -400,7 +425,7 @@ func runBatchWithInstances(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdu actionCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) defer cancel() - results, err := cmdutil.WithSpinner(actionCtx, f.Status(), fmt.Sprintf("%s %d instances...", action.Label, len(instances)), func() ([]verda.InstanceActionResult, error) { + results, err := cmdutil.WithSpinner(actionCtx, f.Status(), fmt.Sprintf("%s %d instances...", action.Label, len(instances)), func(ctx context.Context) ([]verda.InstanceActionResult, error) { return client.Instances.Action(actionCtx, verda.InstanceActionRequest{ Action: actionNameToAPI(opts.Action), ID: ids, diff --git a/internal/verda-cli/cmd/vm/create.go b/internal/verda-cli/cmd/vm/create.go index c19410a..7f10134 100644 --- a/internal/verda-cli/cmd/vm/create.go +++ b/internal/verda-cli/cmd/vm/create.go @@ -45,9 +45,11 @@ var validSpotPolicies = map[string]struct{}{ // // Stage 2 — Template application (applyTemplate): // -// Overwrites empty fields with template values. Sets billingTypeSet, +// Fills fields the user did not pass explicitly (cobra Flags().Changed is +// the authority — flags always beat template values). Sets billingTypeSet, // locationSet, storageSkip, startupScriptSkip coordination flags. -// Expands HostnamePattern into Hostname. +// Expands HostnamePattern into Hostname (the location step re-expands +// {location} against the effective location if the wizard runs). // // Stage 3 — Name resolution (resolveTemplateNames): // @@ -197,7 +199,10 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command _ = flags.MarkHidden("ssh-key-id") _ = flags.MarkHidden("startup-script-id") _ = flags.MarkHidden("spot") - opts.Wait.AddFlags(flags, !f.AgentMode()) // agents should poll with vm describe instead of blocking + // AgentMode is always false at registration: the factory is built during + // command-tree construction, before flags are parsed. The agent no-wait + // default is applied in runCreate instead. + opts.Wait.AddFlags(flags, true) return cmd } @@ -226,10 +231,14 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Request payload:", req) + // Agent mode returns after issuance unless --wait was passed explicitly: the + // flag default is locked in at command construction, before --agent is parsed. + wait := opts.Wait.Wait && (!f.AgentMode() || cmd.Flags().Changed("wait")) + createCtx, createCancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer createCancel() - instance, err := cmdutil.WithSpinner(createCtx, f.Status(), "Creating VM instance...", func() (*verda.Instance, error) { + instance, err := cmdutil.WithSpinner(createCtx, f.Status(), "Creating VM instance...", func(ctx context.Context) (*verda.Instance, error) { return client.Instances.Create(createCtx, req) }) if err != nil { @@ -241,7 +250,7 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream if werr != nil { return werr } - if opts.Wait.Wait { + if wait { _, err = cmdutil.PollInstanceStatus(cmd.Context(), nil, client, instance.ID, opts.Wait) return err } @@ -249,7 +258,7 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream } // Show live status view, polling until the instance reaches a terminal state. - if !opts.Wait.Wait { + if !wait { _, _ = fmt.Fprintf(ioStreams.Out, "Created instance: %s (%s)\n", instance.Hostname, instance.ID) return nil } @@ -283,7 +292,7 @@ func missingCreateFlags(opts *createOptions) []string { func runWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) error { flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeDeploy) - engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) + engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) return engine.Run(ctx, flow) } @@ -390,11 +399,11 @@ func validateKind(kind, instanceType string) error { } switch strings.ToLower(strings.TrimSpace(kind)) { - case "cpu": + case kindCPU: if !strings.HasPrefix(strings.ToUpper(instanceType), "CPU.") { return fmt.Errorf("--kind cpu does not match --instance-type %q", instanceType) } - case "gpu": + case kindGPU: if strings.HasPrefix(strings.ToUpper(instanceType), "CPU.") { return fmt.Errorf("--kind gpu does not match --instance-type %q", instanceType) } diff --git a/internal/verda-cli/cmd/vm/create_test.go b/internal/verda-cli/cmd/vm/create_test.go index d473d2e..87467e3 100644 --- a/internal/verda-cli/cmd/vm/create_test.go +++ b/internal/verda-cli/cmd/vm/create_test.go @@ -21,6 +21,7 @@ import ( "net/http" "os" "path/filepath" + "sync/atomic" "testing" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" @@ -226,7 +227,8 @@ func TestRunCreate_AgentMode_AllFlags(t *testing.T) { // TestRunCreate_AgentMode_WithTemplate verifies that --from loads a template // file and its values appear in the API request. Required flags are still // provided on the CLI because the missing-flags check runs before template -// application (the template can override values via resolveCreateInputs). +// application; the template supplies values only for flags not passed +// (location here — no --location flag, so the template's FIN-03 applies). func TestRunCreate_AgentMode_WithTemplate(t *testing.T) { t.Parallel() @@ -265,7 +267,8 @@ hostname_pattern: from-template cmd := NewCmdCreate(h.Factory, h.IOStreams) // Required flags must be passed because missingCreateFlags is checked - // before the template is applied. The template overrides location. + // before the template is applied. No --location is passed, so the + // template's FIN-03 fills the unset flag. cmd.SetArgs([]string{ "--from", tmplPath, "--kind", "gpu", @@ -301,7 +304,77 @@ hostname_pattern: from-template } } -// TestRunCreate_AgentMode_TemplateMissingFlags verifies that in agent mode, +// TestRunCreate_AgentMode_TemplateFlagOverride verifies the documented +// contract: an explicitly passed flag beats the template value +// (`vm create --from gpu-training --location FIN-03` must land in FIN-03). +func TestRunCreate_AgentMode_TemplateFlagOverride(t *testing.T) { + t.Parallel() + + mux := baseMux() + var capturedReq map[string]any + mux.HandleFunc("POST /instances", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&capturedReq) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "inst-override-001", + "hostname": "from-flag", + "status": "new", + "instance_type": "1V100.6V", + "image": "ubuntu-24.04-cuda-12.8-open-docker", + "location": "FIN-03", + "price_per_hour": 1.50, + }) + }) + + h := newTestHarness(t, mux) + + tmplDir := t.TempDir() + tmplContent := `resource: vm +kind: cpu +instance_type: CPU.4V.16G +location: FIN-01 +image: alpine +hostname_pattern: from-template-ignored +os_volume_size: 25 +` + tmplPath := filepath.Join(tmplDir, "test-template.yaml") + if err := os.WriteFile(tmplPath, []byte(tmplContent), 0o600); err != nil { + t.Fatalf("failed to write template file: %v", err) + } + + cmd := NewCmdCreate(h.Factory, h.IOStreams) + cmd.SetArgs([]string{ + "--from", tmplPath, + "--kind", "gpu", + "--instance-type", "1V100.6V", + "--os", "ubuntu-24.04-cuda-12.8-open-docker", + "--hostname", "from-flag", + "--location", "FIN-03", + "--os-volume-size", "100", + "--wait=false", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() returned error: %v\nStderr: %s", err, h.Stderr.String()) + } + + if capturedReq == nil { + t.Fatal("expected API request to be captured") + } + if got := capturedReq["location_code"]; got != "FIN-03" { + t.Errorf("expected location_code=FIN-03 (flag beats template), got %v", got) + } + if got := capturedReq["instance_type"]; got != "1V100.6V" { + t.Errorf("expected instance_type=1V100.6V (flag beats template), got %v", got) + } + if got := capturedReq["hostname"]; got != "from-flag" { + t.Errorf("expected hostname=from-flag (flag beats template pattern), got %v", got) + } + if osVol, ok := capturedReq["os_volume"].(map[string]any); !ok || osVol["size"] != float64(100) { + t.Errorf("expected os_volume.size=100 (flag beats template), got %v", capturedReq["os_volume"]) + } +} + // --from alone is not sufficient when the template would provide required // values -- the missing-flags check fires before template application. func TestRunCreate_AgentMode_TemplateMissingFlags(t *testing.T) { @@ -341,6 +414,103 @@ hostname_pattern: from-template } } +// TestRunCreate_AgentMode_NoWaitDefaultDoesNotPoll: --wait defaults to true at +// flag registration (AgentMode is not yet known there), so without a runtime +// override an agent-mode create would block polling until timeout. The override +// in runCreate must make the default agent create return after issuance with +// zero status polls. +func TestRunCreate_AgentMode_NoWaitDefaultDoesNotPoll(t *testing.T) { + t.Parallel() + + var pollCalls atomic.Int32 + mux := newCreatePollMux(&pollCalls) + + h := newTestHarness(t, mux) + cmd := NewCmdCreate(h.Factory, h.IOStreams) + cmd.SetArgs([]string{ + "--kind", "gpu", + "--instance-type", "1V100.6V", + "--os", "ubuntu-24.04-cuda-12.8-open-docker", + "--hostname", "gpu-runner", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() returned error: %v\nStderr: %s", err, h.Stderr.String()) + } + + if got := pollCalls.Load(); got != 0 { + t.Fatalf("agent-mode create polled instance status %d times with default --wait; want 0", got) + } + + var result map[string]any + if err := json.Unmarshal(h.Stdout.Bytes(), &result); err != nil { + t.Fatalf("failed to parse JSON output: %v\nOutput: %s", err, h.Stdout.String()) + } + if got := result["id"]; got != "inst-001" { + t.Errorf("expected id=inst-001, got %v", got) + } +} + +// TestRunCreate_AgentMode_ExplicitWaitPolls: an explicit --wait opts the agent +// back into status polling. +func TestRunCreate_AgentMode_ExplicitWaitPolls(t *testing.T) { + t.Parallel() + + var pollCalls atomic.Int32 + mux := newCreatePollMux(&pollCalls) + + h := newTestHarness(t, mux) + cmd := NewCmdCreate(h.Factory, h.IOStreams) + cmd.SetArgs([]string{ + "--kind", "gpu", + "--instance-type", "1V100.6V", + "--os", "ubuntu-24.04-cuda-12.8-open-docker", + "--hostname", "gpu-runner", + "--wait", + "--wait-timeout", "30s", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("cmd.Execute() returned error: %v\nStderr: %s", err, h.Stderr.String()) + } + + if got := pollCalls.Load(); got == 0 { + t.Fatal("explicit --wait in agent mode did not poll instance status") + } +} + +// newCreatePollMux serves instance creation and counts GetByID status polls; +// the instance reads terminal "running" on the first poll. +func newCreatePollMux(pollCalls *atomic.Int32) *http.ServeMux { + mux := baseMux() + mux.HandleFunc("POST /instances", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "inst-001", + "hostname": "gpu-runner", + "status": "new", + "instance_type": "1V100.6V", + "image": "ubuntu-24.04-cuda-12.8-open-docker", + "location": "FIN-01", + "price_per_hour": 1.50, + }) + }) + mux.HandleFunc("GET /instances/{id}", func(w http.ResponseWriter, _ *http.Request) { + pollCalls.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "id": "inst-001", + "hostname": "gpu-runner", + "status": "running", + "instance_type": "1V100.6V", + "image": "ubuntu-24.04-cuda-12.8-open-docker", + "location": "FIN-01", + "price_per_hour": 1.50, + }) + }) + return mux +} + // TestRunCreate_AgentMode_NoClient verifies that runCreate returns an error // when no credentials are configured. func TestRunCreate_AgentMode_NoClient(t *testing.T) { diff --git a/internal/verda-cli/cmd/vm/describe.go b/internal/verda-cli/cmd/vm/describe.go index b14fdf2..4e5b6b4 100644 --- a/internal/verda-cli/cmd/vm/describe.go +++ b/internal/verda-cli/cmd/vm/describe.go @@ -72,7 +72,7 @@ func runDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStre ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() - inst, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading instance...", func() (*verda.Instance, error) { + inst, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading instance...", func(ctx context.Context) (*verda.Instance, error) { return client.Instances.GetByID(ctx, instanceID) }) if err != nil { diff --git a/internal/verda-cli/cmd/vm/instances.go b/internal/verda-cli/cmd/vm/instances.go index c37f53b..3768a38 100644 --- a/internal/verda-cli/cmd/vm/instances.go +++ b/internal/verda-cli/cmd/vm/instances.go @@ -30,7 +30,7 @@ func fetchInstances(ctx context.Context, f cmdutil.Factory, client *verda.Client apiStatus = statusFilter[0] } - instances, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading instances...", func() ([]verda.Instance, error) { + instances, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading instances...", func(ctx context.Context) ([]verda.Instance, error) { return client.Instances.Get(ctx, apiStatus) }) if err != nil { diff --git a/internal/verda-cli/cmd/vm/list.go b/internal/verda-cli/cmd/vm/list.go index 5b132e4..77ea3ad 100644 --- a/internal/verda-cli/cmd/vm/list.go +++ b/internal/verda-cli/cmd/vm/list.go @@ -70,7 +70,7 @@ func runList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() - instances, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading instances...", func() ([]verda.Instance, error) { + instances, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading instances...", func(ctx context.Context) ([]verda.Instance, error) { return client.Instances.Get(ctx, opts.Status) }) if err != nil { diff --git a/internal/verda-cli/cmd/vm/template_apply.go b/internal/verda-cli/cmd/vm/template_apply.go index 78290ae..7d170e1 100644 --- a/internal/verda-cli/cmd/vm/template_apply.go +++ b/internal/verda-cli/cmd/vm/template_apply.go @@ -41,7 +41,7 @@ func resolveCreateInputs( // Load template when --from is used. if cmd.Flags().Changed("from") { ref := strings.TrimSpace(opts.From) - if err := applyTemplateFrom(cmd.Context(), f, ioStreams, client, opts, ref); err != nil { + if err := applyTemplateFrom(cmd.Context(), f, ioStreams, client, opts, ref, cmd.Flags().Changed); err != nil { return true, err } } @@ -49,7 +49,8 @@ func resolveCreateInputs( // Run wizard for any remaining missing fields. // When a template was used but didn't specify a location, prompt the user // so they can pick where to deploy instead of silently defaulting to FIN-01. - templateWithoutLocation := cmd.Flags().Changed("from") && !opts.locationSet + changedLocation := cmd.Flags().Changed("location") + templateWithoutLocation := cmd.Flags().Changed("from") && !opts.locationSet && !changedLocation if opts.InstanceType == "" || opts.Image == "" || opts.Hostname == "" || templateWithoutLocation { if err := runWizard(cmd.Context(), f, ioStreams, opts); err != nil { return true, err @@ -62,6 +63,8 @@ func resolveCreateInputs( // applyTemplateFrom loads a template, applies its values to opts, resolves // SSH key / startup script names to IDs, and prints a summary. // If ref is empty, shows an interactive picker; otherwise loads by name or path. +// changed reports which CLI flags the user passed explicitly — template values +// only fill fields the user did not set. func applyTemplateFrom( ctx context.Context, f cmdutil.Factory, @@ -69,6 +72,7 @@ func applyTemplateFrom( client *verda.Client, opts *createOptions, ref string, + changed func(string) bool, ) error { baseDir, err := cmdutil.TemplatesBaseDir() if err != nil { @@ -83,11 +87,11 @@ func applyTemplateFrom( return nil // user canceled picker } - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, changed) resolveCtx, resolveCancel := context.WithTimeout(ctx, f.Options().Timeout) defer resolveCancel() - resolveTemplateNames(resolveCtx, ioStreams, client, tmpl, opts) + resolveTemplateNames(resolveCtx, ioStreams, client, tmpl, opts, changed) printTemplateSummary(ioStreams, tmpl) @@ -124,35 +128,79 @@ func pickTemplate(ctx context.Context, f cmdutil.Factory, baseDir string) (*temp idx, err := f.Prompter().Select(ctx, "Select a template", labels, tui.WithShowHints(true)) if err != nil { - return nil, nil //nolint:nilerr // user canceled + if cmdutil.IsPromptCancel(err) { + return nil, nil // user canceled + } + return nil, err } return template.LoadFromPath(entries[idx].Path) } -// applyTemplate pre-fills createOptions from a template. -func applyTemplate(tmpl *template.Template, opts *createOptions) { - if tmpl.BillingType != "" { +// applyTemplate pre-fills createOptions from a template. Only fields the +// user did not pass explicitly are filled: changed (cobra's Flags().Changed) +// is the authority, so `--from gpu-training --location FIN-03` keeps FIN-03. +func applyTemplate(tmpl *template.Template, opts *createOptions, changed func(string) bool) { + if tmpl.BillingType != "" && !anyFlagChanged(changed, "is-spot", "spot") { opts.IsSpot = tmpl.BillingType == billingTypeSpot opts.billingTypeSet = true } - if tmpl.Contract != "" { + if tmpl.Contract != "" && !changed("contract") { opts.Contract = tmpl.Contract } - if tmpl.Kind != "" { + if tmpl.Kind != "" && !changed("kind") { opts.Kind = tmpl.Kind } - if tmpl.InstanceType != "" { + if tmpl.InstanceType != "" && !anyFlagChanged(changed, "instance-type", "type") { opts.InstanceType = tmpl.InstanceType } - if tmpl.Location != "" { + // locationDecideLater is wizard-internal and never persisted; the guard + // only protects against hand-written YAML carrying the sentinel. + if tmpl.Location != "" && tmpl.Location != locationDecideLater && !changed("location") { opts.LocationCode = tmpl.Location opts.locationSet = true } // Image name is resolved to ID by resolveTemplateNames, not here. - if tmpl.OSVolumeSize != 0 { + if tmpl.OSVolumeSize != 0 && !changed("os-volume-size") { opts.OSVolumeSize = tmpl.OSVolumeSize } + applyTemplateStorage(tmpl, opts, changed) + if tmpl.StartupScriptSkip && !anyFlagChanged(changed, "startup-script", "startup-script-id") { + opts.startupScriptSkip = true + } + // Hostname pattern: expand {random} against the current location now (so + // flag-complete invocations skip the wizard), keep the pattern so the + // wizard's location step can re-expand {location} against the effective + // deploy location (review H5 family; stepLocation.Setter). + if tmpl.HostnamePattern != "" && !changed("hostname") { + opts.hostnamePattern = tmpl.HostnamePattern + opts.Hostname = template.ExpandHostnamePattern(tmpl.HostnamePattern, opts.LocationCode) + } + if tmpl.Description != "" && !changed("description") { + opts.Description = tmpl.Description + } + // SSH keys and startup script are handled by resolveTemplateNames, not here. +} + +// anyFlagChanged reports whether any of the named flags was explicitly set +// (aliases are separate names on the same flag set). +func anyFlagChanged(changed func(string) bool, names ...string) bool { + for _, n := range names { + if changed(n) { + return true + } + } + return false +} + +// applyTemplateStorage applies the template's storage fields. Any explicit +// storage flag means the user owns storage: the template neither appends its +// volume nor arms the storage-skip coordination flag. +func applyTemplateStorage(tmpl *template.Template, opts *createOptions, changed func(string) bool) { + storageChanged := anyFlagChanged(changed, "volume", "storage-size", "storage-type", "storage-name", "storage-on-spot-discontinue") + if storageChanged { + return + } if len(tmpl.Storage) > 0 { // Only the first storage entry is applied — the wizard's convenience // flags (StorageSize/StorageType) support a single additional volume. @@ -162,26 +210,22 @@ func applyTemplate(tmpl *template.Template, opts *createOptions) { if tmpl.StorageSkip { opts.storageSkip = true } - if tmpl.StartupScriptSkip { - opts.startupScriptSkip = true - } - // Hostname pattern: expand {random} and {location} placeholders. - if tmpl.HostnamePattern != "" && opts.Hostname == "" { - opts.Hostname = template.ExpandHostnamePattern(tmpl.HostnamePattern, opts.LocationCode) - } - if tmpl.Description != "" && opts.Description == "" { - opts.Description = tmpl.Description - } - // SSH keys and startup script are handled by resolveTemplateNames, not here. } // resolveTemplateNames resolves image name, SSH key names, and startup script -// name to IDs. Prints each warning to ioStreams.ErrOut and returns the -// collected warnings. -func resolveTemplateNames(ctx context.Context, ioStreams cmdutil.IOStreams, client *verda.Client, tmpl *template.Template, opts *createOptions) []string { - imageWarnings := resolveImageName(ctx, client, tmpl.Image, opts) - _, sshWarnings := resolveSSHKeyNames(ctx, client, tmpl.SSHKeys, opts) - scriptWarnings := resolveStartupScriptName(ctx, client, tmpl.StartupScript, opts) +// name to IDs — but only for fields the user did not set via flags (changed). +// Prints each warning to ioStreams.ErrOut and returns the collected warnings. +func resolveTemplateNames(ctx context.Context, ioStreams cmdutil.IOStreams, client *verda.Client, tmpl *template.Template, opts *createOptions, changed func(string) bool) []string { + var imageWarnings, sshWarnings, scriptWarnings []string + if !anyFlagChanged(changed, "os", "image") { + imageWarnings = resolveImageName(ctx, client, tmpl.Image, opts) + } + if !anyFlagChanged(changed, "ssh-key", "ssh-key-id") { + _, sshWarnings = resolveSSHKeyNames(ctx, client, tmpl.SSHKeys, opts) + } + if !anyFlagChanged(changed, "startup-script", "startup-script-id") { + scriptWarnings = resolveStartupScriptName(ctx, client, tmpl.StartupScript, opts) + } warnings := make([]string, 0, len(imageWarnings)+len(sshWarnings)+len(scriptWarnings)) warnings = append(warnings, imageWarnings...) warnings = append(warnings, sshWarnings...) diff --git a/internal/verda-cli/cmd/vm/template_apply_test.go b/internal/verda-cli/cmd/vm/template_apply_test.go index dcacd5d..32813ab 100644 --- a/internal/verda-cli/cmd/vm/template_apply_test.go +++ b/internal/verda-cli/cmd/vm/template_apply_test.go @@ -21,6 +21,18 @@ import ( "github.com/verda-cloud/verda-cli/internal/verda-cli/template" ) +// noChanged is the applyTemplate changed-predicate for "user passed no flags". +func noChanged(string) bool { return false } + +// changedFlags builds a changed-predicate reporting exactly the given flags. +func changedFlags(names ...string) func(string) bool { + set := make(map[string]bool, len(names)) + for _, n := range names { + set[n] = true + } + return func(name string) bool { return set[name] } +} + func TestApplyTemplate(t *testing.T) { t.Parallel() @@ -37,7 +49,7 @@ func TestApplyTemplate(t *testing.T) { } opts := &createOptions{} - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) if !opts.IsSpot { t.Error("expected IsSpot=true for billing_type=spot") @@ -78,7 +90,7 @@ func TestApplyTemplate_OnDemand(t *testing.T) { } opts := &createOptions{} - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) if opts.IsSpot { t.Error("expected IsSpot=false for billing_type=on-demand") @@ -98,7 +110,7 @@ func TestApplyTemplate_Partial(t *testing.T) { LocationCode: "FIN-01", // pre-existing default StorageType: "NVMe", // pre-existing default } - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) if opts.InstanceType != "CPU.4V.16G" { t.Errorf("InstanceType = %q, want CPU.4V.16G", opts.InstanceType) @@ -128,7 +140,7 @@ func TestApplyTemplate_SkipFlags(t *testing.T) { } opts := &createOptions{} - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) if !opts.billingTypeSet { t.Error("expected billingTypeSet=true") @@ -155,7 +167,7 @@ func TestApplyTemplate_HostnamePattern(t *testing.T) { } opts := &createOptions{} - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) // Location should be applied first, then hostname pattern expanded. if opts.LocationCode != "FIN-03" { @@ -186,14 +198,17 @@ func TestApplyTemplate_HostnamePatternNoOverwrite(t *testing.T) { } opts := &createOptions{ - Hostname: "my-existing-hostname", + Hostname: "my-existing-hostname", // user passed --hostname } - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, changedFlags("hostname")) - // The pre-existing hostname should NOT be overwritten by the pattern. + // The flag-passed hostname should NOT be overwritten by the pattern. if opts.Hostname != "my-existing-hostname" { t.Errorf("Hostname = %q, want %q (should not overwrite)", opts.Hostname, "my-existing-hostname") } + if opts.hostnamePattern != "" { + t.Errorf("hostnamePattern = %q, want empty (pattern not adopted)", opts.hostnamePattern) + } } func TestApplyTemplate_HostnamePatternStaticName(t *testing.T) { @@ -206,7 +221,7 @@ func TestApplyTemplate_HostnamePatternStaticName(t *testing.T) { } opts := &createOptions{} - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) // A pattern without placeholders should set the hostname exactly. if opts.Hostname != "my-worker" { @@ -226,7 +241,7 @@ func TestApplyTemplate_WithStorage(t *testing.T) { opts := &createOptions{ StorageType: "NVMe", // default } - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) if opts.StorageSize != 500 { t.Errorf("StorageSize = %d, want 500", opts.StorageSize) @@ -250,7 +265,7 @@ func TestApplyTemplate_WithStorageHDD(t *testing.T) { opts := &createOptions{ StorageType: "NVMe", // default should be overwritten } - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) if opts.StorageSize != 2000 { t.Errorf("StorageSize = %d, want 2000", opts.StorageSize) @@ -271,7 +286,7 @@ func TestApplyTemplate_StorageSkipAndStartupSkip(t *testing.T) { } opts := &createOptions{} - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) if !opts.storageSkip { t.Error("expected storageSkip=true") @@ -290,7 +305,7 @@ func TestApplyTemplate_BillingTypeSetFlag(t *testing.T) { } opts := &createOptions{} - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) if !opts.billingTypeSet { t.Error("expected billingTypeSet=true when template has billing_type") @@ -309,7 +324,7 @@ func TestApplyTemplate_LocationSetFlag(t *testing.T) { } opts := &createOptions{} - applyTemplate(tmpl, opts) + applyTemplate(tmpl, opts, noChanged) if !opts.locationSet { t.Error("expected locationSet=true when template has location") @@ -318,3 +333,133 @@ func TestApplyTemplate_LocationSetFlag(t *testing.T) { t.Errorf("LocationCode = %q, want US-EAST-1", opts.LocationCode) } } + +func TestApplyTemplate_FlagsBeatTemplate(t *testing.T) { + t.Parallel() + + // --from gpu-training --location FIN-03 --instance-type CPU.4V.16G --os-volume-size 100 + changed := changedFlags("location", "instance-type", "os-volume-size") + + tmpl := &template.Template{ + Resource: "vm", + BillingType: "on-demand", + Kind: "gpu", + InstanceType: "1V100.6V", + Location: "FIN-01", + OSVolumeSize: 200, + Storage: []template.StorageSpec{{Type: "HDD", Size: 2000}}, + } + + opts := &createOptions{ + InstanceType: "CPU.4V.16G", // from --instance-type + LocationCode: "FIN-03", // from --location + OSVolumeSize: 100, // from --os-volume-size + StorageType: "NVMe", // default + } + applyTemplate(tmpl, opts, changed) + + if opts.LocationCode != "FIN-03" { + t.Errorf("LocationCode = %q, want FIN-03 (flag beats template)", opts.LocationCode) + } + if opts.InstanceType != "CPU.4V.16G" { + t.Errorf("InstanceType = %q, want CPU.4V.16G (flag beats template)", opts.InstanceType) + } + if opts.OSVolumeSize != 100 { + t.Errorf("OSVolumeSize = %d, want 100 (flag beats template)", opts.OSVolumeSize) + } + // Coordination flags must not be armed for user-passed values. + if opts.locationSet { + t.Error("locationSet = true, want false (location came from the flag, not the template)") + } + // Unset fields still take template values. + if opts.Kind != "gpu" { + t.Errorf("Kind = %q, want gpu (unset flag takes template)", opts.Kind) + } + if !opts.billingTypeSet { + t.Error("billingTypeSet = false, want true (billing came from the template)") + } + if opts.StorageSize != 2000 || opts.StorageType != "HDD" { + t.Errorf("Storage = %s/%d, want HDD/2000 (unset storage flags take template)", opts.StorageType, opts.StorageSize) + } +} + +func TestApplyTemplate_BillingFlagBeatsTemplate(t *testing.T) { + t.Parallel() + + // User passed --is-spot explicitly; template says on-demand. + for _, flag := range []string{"is-spot", "spot"} { + t.Run(flag, func(t *testing.T) { + t.Parallel() + tmpl := &template.Template{Resource: "vm", BillingType: "on-demand"} + opts := &createOptions{IsSpot: true} + applyTemplate(tmpl, opts, changedFlags(flag)) + + if !opts.IsSpot { + t.Errorf("IsSpot = false, want true (--%s beats template billing_type)", flag) + } + if opts.billingTypeSet { + t.Error("billingTypeSet = true, want false (billing came from the flag)") + } + }) + } +} + +func TestApplyTemplate_StorageFlagBlocksTemplateStorage(t *testing.T) { + t.Parallel() + + // Any explicit storage flag means the user owns storage; the template + // must neither append its volume nor arm storageSkip. + changed := changedFlags("storage-size") + tmpl := &template.Template{ + Resource: "vm", + Storage: []template.StorageSpec{{Type: "HDD", Size: 2000}}, + StorageSkip: true, + } + opts := &createOptions{StorageSize: 100, StorageType: "NVMe"} + applyTemplate(tmpl, opts, changed) + + if opts.StorageSize != 100 || opts.StorageType != "NVMe" { + t.Errorf("Storage = %s/%d, want NVMe/100 (flag beats template)", opts.StorageType, opts.StorageSize) + } + if opts.storageSkip { + t.Error("storageSkip = true, want false (user passed storage flags)") + } +} + +func TestApplyTemplate_SentinelLocationIsNotApplied(t *testing.T) { + t.Parallel() + + // The decide-later sentinel is wizard-internal; hand-written YAML carrying + // it must not become a garbage location. + tmpl := &template.Template{Resource: "vm", Location: locationDecideLater} + opts := &createOptions{LocationCode: "FIN-01"} + applyTemplate(tmpl, opts, noChanged) + + if opts.LocationCode != "FIN-01" { + t.Errorf("LocationCode = %q, want FIN-01 (sentinel is not a location)", opts.LocationCode) + } + if opts.locationSet { + t.Error("locationSet = true, want false (sentinel means undecided)") + } +} + +func TestApplyTemplate_HostnamePatternKeptForReExpansion(t *testing.T) { + t.Parallel() + + // The pattern must survive apply so the wizard's location step can + // re-expand {location} against the effective deploy location. + tmpl := &template.Template{ + Resource: "vm", + Location: "FIN-03", + HostnamePattern: "worker-{location}", + } + opts := &createOptions{} + applyTemplate(tmpl, opts, noChanged) + + if opts.hostnamePattern != "worker-{location}" { + t.Errorf("hostnamePattern = %q, want %q", opts.hostnamePattern, "worker-{location}") + } + if opts.Hostname != "worker-fin-03" { + t.Errorf("Hostname = %q, want %q", opts.Hostname, "worker-fin-03") + } +} diff --git a/internal/verda-cli/cmd/vm/wizard.go b/internal/verda-cli/cmd/vm/wizard.go index ed981e5..66347d4 100644 --- a/internal/verda-cli/cmd/vm/wizard.go +++ b/internal/verda-cli/cmd/vm/wizard.go @@ -27,6 +27,7 @@ import ( "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + "github.com/verda-cloud/verda-cli/internal/verda-cli/template" ) const ( @@ -42,6 +43,14 @@ const ( unitLabelVCPU = "vCPU" billingTypeOnDemand = "on-demand" + + // locationDecideLater is the template-mode location choice for + // "None (decide at deploy time)". In-memory only: the step Setter + // translates it to an unset location, so a saved template stays + // locationless and the deploy flow prompts for it. An empty Value would + // instead trip the engine's Default substitution for optional steps and + // silently persist the FIN-01 default (review H5). + locationDecideLater = "__decide_later__" ) // clientFunc lazily resolves a Verda API client. This allows the wizard @@ -92,7 +101,7 @@ func RunTemplateWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil func runTemplateWizardWithOpts(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) (*TemplateResult, error) { flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeTemplate) - engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation()) + engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) if err := engine.Run(ctx, flow); err != nil { return nil, err } @@ -231,24 +240,30 @@ func stepContract(getClient clientFunc, opts *createOptions) wizard.Step { if err != nil { return choices, nil //nolint:nilerr // Non-fatal: just offer pay-as-you-go. } - periods, err := cmdutil.WithSpinner(ctx, status, "Loading contract options...", func() ([]verda.LongTermPeriod, error) { + periods, err := cmdutil.WithSpinner(ctx, status, "Loading contract options...", func(ctx context.Context) ([]verda.LongTermPeriod, error) { return client.LongTerm.GetInstancePeriods(ctx) }) if err != nil { return choices, nil //nolint:nilerr // Non-fatal: just offer pay-as-you-go. } for _, p := range periods { - if p.IsEnabled { - desc := "" - if p.DiscountPercentage > 0 { - desc = fmt.Sprintf("%.0f%% discount", p.DiscountPercentage) - } - choices = append(choices, wizard.Choice{ - Label: p.Name, - Value: p.Code, - Description: desc, - }) + if !p.IsEnabled { + continue + } + // POST /v1/instances takes no long-term durations, so drop + // period codes normalizeContract would reject at deploy time. + if _, err := normalizeContract(p.Code); err != nil { + continue + } + desc := "" + if p.DiscountPercentage > 0 { + desc = fmt.Sprintf("%.0f%% discount", p.DiscountPercentage) } + choices = append(choices, wizard.Choice{ + Label: p.Name, + Value: p.Code, + Description: desc, + }) } return choices, nil }, @@ -300,7 +315,7 @@ func stepInstanceType(getClient clientFunc, cache *apiCache, opts *createOptions kind := c["kind"].(string) isSpot := c["billing-type"] == billingTypeSpot - types, err := cmdutil.WithSpinner(ctx, status, "Loading instance types...", func() ([]verda.InstanceTypeInfo, error) { + types, err := cmdutil.WithSpinner(ctx, status, "Loading instance types...", func(ctx context.Context) ([]verda.InstanceTypeInfo, error) { return client.InstanceTypes.Get(ctx, "usd") }) if err != nil { @@ -377,8 +392,20 @@ func stepLocation(getClient clientFunc, cache *apiCache, opts *createOptions, mo return loadAvailableLocations(ctx, cache, getClient, isSpot, instType) }, Setter: func(v any) { - if s := v.(string); s != "" { - opts.LocationCode = s + s := v.(string) + if s == locationDecideLater { + opts.LocationCode = "" // decide at deploy time; keep unset + return + } + if s == "" { + return + } + opts.LocationCode = s + // A template hostname pattern was expanded at apply time against + // the pre-wizard location; re-expand {location} against the + // effective one picked here so the confirm summary stays truthful. + if strings.Contains(opts.hostnamePattern, "{location}") && opts.Hostname != "" { + opts.Hostname = template.ExpandHostnamePattern(opts.hostnamePattern, s) } }, Resetter: func() { opts.LocationCode = verda.LocationFIN01 }, @@ -407,7 +434,7 @@ func stepImage(getClient clientFunc, opts *createOptions) wizard.Step { } // Filter images by instance type when available. instType, _ := store.Collected()["instance-type"].(string) - images, err := cmdutil.WithSpinner(ctx, status, "Loading OS images...", func() ([]verda.Image, error) { + images, err := cmdutil.WithSpinner(ctx, status, "Loading OS images...", func(ctx context.Context) ([]verda.Image, error) { if instType != "" { return client.Images.GetImagesByInstanceType(ctx, instType) } @@ -595,7 +622,7 @@ func stepSSHKeys(getClient clientFunc, opts *createOptions) wizard.Step { if err != nil { return nil, err } - keys, err := cmdutil.WithSpinner(ctx, status, "Loading SSH keys...", func() ([]verda.SSHKey, error) { + keys, err := cmdutil.WithSpinner(ctx, status, "Loading SSH keys...", func(ctx context.Context) ([]verda.SSHKey, error) { return client.SSHKeys.GetAllSSHKeys(ctx) }) if err != nil { @@ -677,7 +704,7 @@ func stepStartupScript(getClient clientFunc, opts *createOptions) wizard.Step { if err != nil { return nil, err } - scripts, err := cmdutil.WithSpinner(ctx, status, "Loading startup scripts...", func() ([]verda.StartupScript, error) { + scripts, err := cmdutil.WithSpinner(ctx, status, "Loading startup scripts...", func(ctx context.Context) ([]verda.StartupScript, error) { return client.StartupScripts.GetAllStartupScripts(ctx) }) if err != nil { diff --git a/internal/verda-cli/cmd/vm/wizard_cache.go b/internal/verda-cli/cmd/vm/wizard_cache.go index ebff57c..468433d 100644 --- a/internal/verda-cli/cmd/vm/wizard_cache.go +++ b/internal/verda-cli/cmd/vm/wizard_cache.go @@ -17,7 +17,6 @@ package vm import ( "context" "fmt" - "math" "slices" "strings" @@ -114,19 +113,11 @@ func ensurePricingCache(ctx context.Context, getClient clientFunc, cache *apiCac } } -// hoursInMonth is 365*24/12 = 730, matching the web frontend. -const hoursInMonth = 730 - -// volumeHourlyPrice calculates hourly price: monthlyPerGB * size / 730, rounded up to 4 decimals. -func volumeHourlyPrice(monthlyPerGB float64, sizeGB int) float64 { - return math.Ceil(monthlyPerGB*float64(sizeGB)/hoursInMonth*10000) / 10000 -} - // --- Location loaders --- // loadAllLocations returns all locations with a skip option (for template mode). func loadAllLocations(ctx context.Context, cache *apiCache, getClient clientFunc) ([]wizard.Choice, error) { - choices := []wizard.Choice{{Label: "None (decide at deploy time)", Value: ""}} + choices := []wizard.Choice{{Label: "None (decide at deploy time)", Value: locationDecideLater}} locMap, err := cache.fetchLocations(ctx, getClient) if err != nil { return nil, err diff --git a/internal/verda-cli/cmd/vm/wizard_subflows.go b/internal/verda-cli/cmd/vm/wizard_subflows.go index f46d6bd..feae5cb 100644 --- a/internal/verda-cli/cmd/vm/wizard_subflows.go +++ b/internal/verda-cli/cmd/vm/wizard_subflows.go @@ -46,8 +46,14 @@ func buildSSHKeyChoices(keys []verda.SSHKey) []wizard.Choice { func promptAddSSHKey(ctx context.Context, prompter tui.Prompter, client *verda.Client) (*verda.SSHKey, error) { name, err := prompter.TextInput(ctx, "SSH key name") - if err != nil || strings.TrimSpace(name) == "" { - return nil, nil //nolint:nilerr // User canceled or left input blank. + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil, nil // User canceled. + } + return nil, err + } + if strings.TrimSpace(name) == "" { + return nil, nil // Blank input — back to menu. } // Ask for source: load from file or paste. @@ -56,15 +62,24 @@ func promptAddSSHKey(ctx context.Context, prompter tui.Prompter, client *verda.C "Paste content", }, tui.WithShowHints(true)) if err != nil { - return nil, nil //nolint:nilerr // User canceled. + if cmdutil.IsPromptCancel(err) { + return nil, nil // User canceled. + } + return nil, err } var pubKey string switch sourceIdx { case 0: // Load from file filePath, err := promptSSHKeyFilePath(ctx, prompter) - if err != nil || filePath == "" { - return nil, nil //nolint:nilerr // User canceled. + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil, nil // User canceled. + } + return nil, err + } + if filePath == "" { + return nil, nil // Blank input — back to menu. } data, err := os.ReadFile(filePath) //nolint:gosec // User-provided path from interactive prompt, validated by validateFilePath. if err != nil { @@ -74,8 +89,14 @@ func promptAddSSHKey(ctx context.Context, prompter tui.Prompter, client *verda.C pubKey = string(data) case 1: // Paste content pubKey, err = prompter.TextInput(ctx, "Public key (paste)") - if err != nil || strings.TrimSpace(pubKey) == "" { - return nil, nil //nolint:nilerr // User canceled or left input blank. + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil, nil // User canceled. + } + return nil, err + } + if strings.TrimSpace(pubKey) == "" { + return nil, nil // Blank input — back to menu. } } @@ -200,8 +221,14 @@ func buildStartupScriptChoices(scripts []verda.StartupScript) []wizard.Choice { func promptAddStartupScript(ctx context.Context, prompter tui.Prompter, client *verda.Client) (*verda.StartupScript, error) { name, err := prompter.TextInput(ctx, "Script name") - if err != nil || strings.TrimSpace(name) == "" { - return nil, nil //nolint:nilerr // User canceled or left input blank. + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil, nil // User canceled. + } + return nil, err + } + if strings.TrimSpace(name) == "" { + return nil, nil // Blank input — back to menu. } // Ask for source: paste or load from file. @@ -210,15 +237,24 @@ func promptAddStartupScript(ctx context.Context, prompter tui.Prompter, client * "Paste content", }, tui.WithShowHints(true)) if err != nil { - return nil, nil //nolint:nilerr // User canceled or left input blank. + if cmdutil.IsPromptCancel(err) { + return nil, nil // User canceled. + } + return nil, err } var content string switch sourceIdx { case 0: // Load from file path, err := prompter.TextInput(ctx, "File path") - if err != nil || strings.TrimSpace(path) == "" { - return nil, nil //nolint:nilerr // User canceled or left input blank. + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil, nil // User canceled. + } + return nil, err + } + if strings.TrimSpace(path) == "" { + return nil, nil // Blank input — back to menu. } data, err := os.ReadFile(strings.TrimSpace(path)) if err != nil { @@ -231,7 +267,10 @@ func promptAddStartupScript(ctx context.Context, prompter tui.Prompter, client * tui.WithEditorDefault("#!/bin/bash\n\n# Your startup script here\n"), tui.WithFileExt(".sh")) if err != nil { - return nil, nil //nolint:nilerr // User canceled the editor; return to menu. + if cmdutil.IsPromptCancel(err) { + return nil, nil // User canceled the editor; return to menu. + } + return nil, err } } @@ -300,14 +339,26 @@ func promptAddVolume(ctx context.Context, prompter tui.Prompter, store *wizard.S defaultName = hostname + "-storage" } name, err := prompter.TextInput(ctx, "Volume name", tui.WithDefault(defaultName)) - if err != nil || strings.TrimSpace(name) == "" { - return nil, nil //nolint:nilerr // User pressed Esc/Ctrl+C or left input blank. + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil, nil // User pressed Esc/Ctrl+C. + } + return nil, err + } + if strings.TrimSpace(name) == "" { + return nil, nil // Blank input — back to menu. } // Size sizeStr, err := prompter.TextInput(ctx, "Size in GiB", tui.WithDefault("100")) - if err != nil || strings.TrimSpace(sizeStr) == "" { - return nil, nil //nolint:nilerr // User pressed Esc/Ctrl+C or left input blank. + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil, nil // User pressed Esc/Ctrl+C. + } + return nil, err + } + if strings.TrimSpace(sizeStr) == "" { + return nil, nil // Blank input — back to menu. } size, parseErr := strconv.Atoi(strings.TrimSpace(sizeStr)) if parseErr != nil || size <= 0 { @@ -323,7 +374,7 @@ func promptAddVolume(ctx context.Context, prompter tui.Prompter, store *wizard.S } func promptAttachExisting(ctx context.Context, prompter tui.Prompter, status tui.Status, client *verda.Client) (string, error) { - volumes, err := cmdutil.WithSpinner(ctx, status, "Loading volumes...", func() ([]verda.Volume, error) { + volumes, err := cmdutil.WithSpinner(ctx, status, "Loading volumes...", func(ctx context.Context) ([]verda.Volume, error) { return client.Volumes.ListVolumes(ctx) }) if err != nil { @@ -351,7 +402,10 @@ func promptAttachExisting(ctx context.Context, prompter tui.Prompter, status tui idx, err := prompter.Select(ctx, "Select volume to attach", labels, tui.WithShowHints(true)) if err != nil { - return "", nil //nolint:nilerr // User canceled or left input blank. + if cmdutil.IsPromptCancel(err) { + return "", nil // User canceled. + } + return "", err } if idx == len(detached) { // "← Back" return "", nil diff --git a/internal/verda-cli/cmd/vm/wizard_summary.go b/internal/verda-cli/cmd/vm/wizard_summary.go index be5f086..929713b 100644 --- a/internal/verda-cli/cmd/vm/wizard_summary.go +++ b/internal/verda-cli/cmd/vm/wizard_summary.go @@ -24,6 +24,8 @@ import ( "charm.land/lipgloss/v2" "github.com/verda-cloud/verda-cli/pkg/tui/wizard" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) // summaryView implements wizard.View and renders the deployment summary @@ -90,7 +92,7 @@ func renderDeploymentSummary(opts *createOptions, cache *apiCache) string { if opts.OSVolumeSize > 0 { if vt, ok := cache.volumeTypes[verda.VolumeTypeNVMe]; ok { osVolUnitPrice = vt.Price.PricePerMonthPerGB - osVolPrice = volumeHourlyPrice(osVolUnitPrice, opts.OSVolumeSize) + osVolPrice = cmdutil.VolumeHourlyPrice(osVolUnitPrice, opts.OSVolumeSize) storageHourly += osVolPrice } } @@ -113,7 +115,7 @@ func renderDeploymentSummary(opts *createOptions, cache *apiCache) string { var hourly, unitP float64 if vt, ok := cache.volumeTypes[vType]; ok { unitP = vt.Price.PricePerMonthPerGB - hourly = volumeHourlyPrice(unitP, size) + hourly = cmdutil.VolumeHourlyPrice(unitP, size) storageHourly += hourly } volDetails = append(volDetails, volDetail{name, vType, size, unitP, hourly}) diff --git a/internal/verda-cli/cmd/vm/wizard_template_test.go b/internal/verda-cli/cmd/vm/wizard_template_test.go new file mode 100644 index 0000000..c430232 --- /dev/null +++ b/internal/verda-cli/cmd/vm/wizard_template_test.go @@ -0,0 +1,236 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package vm + +import ( + "context" + "encoding/json" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/verda-cloud/verda-cli/pkg/tui/wizard" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + "github.com/verda-cloud/verda-cli/internal/verda-cli/template" +) + +// templateWizardMux serves the endpoints the template-mode wizard touches: +// locations for the location step. The long-term periods endpoint is +// deliberately absent — the contract loader degrades to pay-as-you-go on +// errors. +func templateWizardMux() *http.ServeMux { + mux := baseMux() + mux.HandleFunc("GET /locations", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"code": "FIN-01", "name": "Finland 1", "country_code": "FI"}, + {"code": "FIN-03", "name": "Finland 3", "country_code": "FI"}, + }) + }) + return mux +} + +// TestTemplateWizard_DecideLaterLocationStaysEmpty is the H5 regression test: +// picking "None (decide at deploy time)" in the template wizard must leave the +// template locationless. Empty choice values trip the engine's Default +// substitution (opts.LocationCode, FIN-01), so the choice carries a sentinel. +func TestTemplateWizard_DecideLaterLocationStaysEmpty(t *testing.T) { + t.Parallel() + + h := newTestHarness(t, templateWizardMux()) + getClient := func() (*verda.Client, error) { return h.Factory.VerdaClient() } + + opts := &createOptions{ + InstanceType: "1V100.6V", + Image: "ubuntu-24.04-cuda-12.8-open-docker", + LocationCode: verda.LocationFIN01, + StorageType: verda.VolumeTypeNVMe, + SSHKeyIDs: []string{"key-1"}, + storageSkip: true, + startupScriptSkip: true, + } + + ctx := context.Background() + flow := buildCreateFlow(ctx, getClient, opts, WizardModeTemplate) + engine := wizard.NewEngine(nil, nil, + wizard.WithOutput(io.Discard), + wizard.WithTestResults( + wizard.SelectResult(0), // billing-type: On-Demand + wizard.SelectResult(0), // contract: Pay as you go (periods endpoint 404s → fallback) + wizard.SelectResult(0), // kind: GPU + wizard.SelectResult(0), // location: None (decide at deploy time) + wizard.TextResult("50"), // os-volume-size + wizard.TextResult("source-{location}"), // hostname-pattern + wizard.TextResult(""), // template description + ), + ) + + if err := engine.Run(ctx, flow); err != nil { + t.Fatalf("wizard Run failed: %v", err) + } + + if opts.LocationCode != "" { + t.Fatalf("LocationCode = %q, want empty (decide at deploy time)", opts.LocationCode) + } + + result := optsToTemplateResult(opts) + if result.Location != "" { + t.Fatalf("TemplateResult.Location = %q, want empty", result.Location) + } + + // The saved template must not carry a location key at all. + dir := t.TempDir() + tmpl := &template.Template{Resource: "vm", InstanceType: result.InstanceType, Location: result.Location} + if err := template.Save(dir, "vm", "no-location", tmpl); err != nil { + t.Fatalf("Save failed: %v", err) + } + data, err := os.ReadFile(filepath.Join(dir, "vm", "no-location.yaml")) // #nosec G304 -- dir is t.TempDir() + if err != nil { + t.Fatalf("reading saved template: %v", err) + } + if strings.Contains(string(data), "location:") { + t.Errorf("saved template contains a location:\n%s", data) + } +} + +// TestTemplateWizard_PickedLocationPersists covers the other half of the +// sentinel fix: a real location choice is stored normally. +func TestTemplateWizard_PickedLocationPersists(t *testing.T) { + t.Parallel() + + opts := &createOptions{LocationCode: verda.LocationFIN01} + step := stepLocation(nil, &apiCache{}, opts, WizardModeTemplate) + step.Setter("FIN-03") + + if opts.LocationCode != "FIN-03" { + t.Fatalf("LocationCode = %q, want FIN-03", opts.LocationCode) + } +} + +// TestStepLocation_DecideLaterSentinelClearsLocation verifies the Setter +// translates the sentinel to "unset" instead of persisting it. +func TestStepLocation_DecideLaterSentinelClearsLocation(t *testing.T) { + t.Parallel() + + opts := &createOptions{LocationCode: verda.LocationFIN01} + step := stepLocation(nil, nil, opts, WizardModeTemplate) + step.Setter(locationDecideLater) + + if opts.LocationCode != "" { + t.Fatalf("LocationCode = %q, want empty after decide-later", opts.LocationCode) + } +} + +// TestStepLocation_ReexpandsHostnamePattern: a template hostname pattern is +// expanded at apply time against the pre-wizard location; the location step +// must re-expand {location} against the effective location so a FIN-03 deploy +// is not named ...-fin-01 (cc second-review finding). +func TestStepLocation_ReexpandsHostnamePattern(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + InstanceType: "1V100.6V", + HostnamePattern: "worker-{location}", + } + opts := &createOptions{LocationCode: verda.LocationFIN01} + applyTemplate(tmpl, opts, noChanged) // no location from template: expands against FIN-01 default + if opts.Hostname != "worker-fin-01" { + t.Fatalf("Hostname after apply = %q, want worker-fin-01", opts.Hostname) + } + + step := stepLocation(nil, nil, opts, WizardModeDeploy) + step.Setter("FIN-03") + + if opts.Hostname != "worker-fin-03" { + t.Errorf("Hostname = %q, want worker-fin-03 (re-expanded against effective location)", opts.Hostname) + } +} + +// TestStepLocation_StaticHostnameNotReexpanded: patterns without {location} +// survive a location change untouched (no pointless {random} reroll, no +// stomping a manually edited hostname). +func TestStepLocation_StaticHostnameNotReexpanded(t *testing.T) { + t.Parallel() + + opts := &createOptions{LocationCode: verda.LocationFIN01, Hostname: "my-host"} + step := stepLocation(nil, nil, opts, WizardModeDeploy) + step.Setter("FIN-03") + + if opts.Hostname != "my-host" { + t.Errorf("Hostname = %q, want my-host (untouched)", opts.Hostname) + } +} + +// TestStepContract_DropsUndeployablePeriods is the H6 regression test: the +// wizard must not offer long-term periods whose codes normalizeContract +// rejects at request time. +func TestStepContract_DropsUndeployablePeriods(t *testing.T) { + t.Parallel() + + mux := baseMux() + mux.HandleFunc("GET /long-term/periods/instances", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"code": "1_month", "name": "1 month", "is_enabled": true, "discount_percentage": 5}, + {"code": "3_months", "name": "3 months", "is_enabled": true, "discount_percentage": 10}, + {"code": "1_year", "name": "1 year", "is_enabled": true, "discount_percentage": 20}, + {"code": "2_years", "name": "2 years", "is_enabled": false, "discount_percentage": 25}, + }) + }) + h := newTestHarness(t, mux) + getClient := func() (*verda.Client, error) { return h.Factory.VerdaClient() } + + step := stepContract(getClient, &createOptions{}) + choices, err := step.Loader(context.Background(), nil, nil, wizard.NewStore()) + if err != nil { + t.Fatalf("Loader returned error: %v", err) + } + + if len(choices) != 1 || choices[0].Value != contractPayAsYouGo { + t.Fatalf("expected only the pay-as-you-go choice, got %+v", choices) + } +} + +// TestStepContract_KeepsDeployableCodes: if the API ever exposes a period +// code that normalizeContract accepts, the wizard offers it. +func TestStepContract_KeepsDeployableCodes(t *testing.T) { + t.Parallel() + + mux := baseMux() + mux.HandleFunc("GET /long-term/periods/instances", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode([]map[string]any{ + {"code": "long_term", "name": "Long-term", "is_enabled": true, "discount_percentage": 10}, + {"code": "6_months", "name": "6 months", "is_enabled": true, "discount_percentage": 15}, + }) + }) + h := newTestHarness(t, mux) + getClient := func() (*verda.Client, error) { return h.Factory.VerdaClient() } + + step := stepContract(getClient, &createOptions{}) + choices, err := step.Loader(context.Background(), nil, nil, wizard.NewStore()) + if err != nil { + t.Fatalf("Loader returned error: %v", err) + } + + if len(choices) != 2 || choices[1].Value != "long_term" { + t.Fatalf("expected payg + long_term choices, got %+v", choices) + } +} diff --git a/internal/verda-cli/cmd/volume/CLAUDE.md b/internal/verda-cli/cmd/volume/CLAUDE.md index 3ecb377..80972ba 100644 --- a/internal/verda-cli/cmd/volume/CLAUDE.md +++ b/internal/verda-cli/cmd/volume/CLAUDE.md @@ -14,9 +14,9 @@ ### Pricing Calculation - `price_per_month_per_gb` comes from `VolumeType.Price.PricePerMonthPerGB` -- `hoursInMonth = 730` (365*24/12), matching the web frontend -- Hourly = `ceil(monthlyPerGB * size / 730 * 10000) / 10000` -- Monthly = `monthlyPerGB * size` +- Hourly = `cmdutil.VolumeHourlyPrice(monthlyPerGB, size)`; Monthly = `cmdutil.VolumeMonthlyPrice(monthlyPerGB, size)` — canonical shared helpers, do not re-implement +- `cmdutil.HoursInMonth = 730` (365*24/12), matching the web frontend +- Unknown `--type` fails with a usage error listing valid types (was: silent $0 pricing) - Volume types are keyed by `verda.VolumeTypeNVMe` and `verda.VolumeTypeHDD` ### Trash Recovery diff --git a/internal/verda-cli/cmd/volume/README.md b/internal/verda-cli/cmd/volume/README.md index eaaf443..a457c58 100644 --- a/internal/verda-cli/cmd/volume/README.md +++ b/internal/verda-cli/cmd/volume/README.md @@ -5,7 +5,8 @@ | Command | Description | Key Flags | |---------|-------------|-----------| | `verda volume list` | List all block storage volumes | `--status` | -| `verda volume create` | Create a new block storage volume | `--name`, `--size`, `--type`, `--location` | +| `verda volume create` | Create a new block storage volume | `--name`, `--size`, `--type`, `--location`, `--yes` | +| `verda volume delete` | Delete one or more volumes (restorable via trash for 96h) | `[volume-id]`, `--id`, `--all`, `--status`, `--yes` | | `verda volume action` | Perform actions on a volume (detach, rename, resize, clone, delete) | `--id` | | `verda volume trash` | List deleted volumes in trash | (none) | @@ -27,9 +28,11 @@ verda volume list --status attached verda volume create # Non-interactive -verda volume create --name my-vol --size 100 --type NVMe --location FIN-01 +verda volume create --name my-vol --size 100 --type NVMe --location FIN-01 --yes ``` +A final confirmation prompt runs on a TTY unless `--yes` is passed. Agent mode (`--agent`) requires all flags plus `--yes` — without it the command fails with `CONFIRMATION_REQUIRED`; success prints a structured JSON result. + ### action ```bash # Interactive volume picker @@ -39,6 +42,20 @@ verda volume action verda vol action --id abc-123 ``` +### delete +```bash +# Interactive multi-select picker +verda volume delete + +# Delete by ID (asks for confirmation on a TTY) +verda volume delete vol-abc-123 + +# Batch: delete all detached volumes without confirmation +verda volume delete --all --status detached --yes +``` + +Deleted storage can be restored within 96 hours via `verda volume trash`. Agent mode (`--agent`) never prompts: both single (`--id` or positional) and batch (`--all`) deletes require `--yes` — without it the command fails with `CONFIRMATION_REQUIRED`; success prints a structured JSON result. + ### trash ```bash verda volume trash @@ -48,7 +65,7 @@ verda vol trash ## Interactive vs Non-Interactive ### create -All four flags (`--name`, `--size`, `--type`, `--location`) can be provided for fully non-interactive mode. Any missing flag triggers an interactive prompt for that field. Type defaults to NVMe (HDD is deprecated and no longer offered; NVMe pricing is shown in the confirmation summary), size defaults to 100 GiB, location is fetched from the API and offered as a selection. +All four flags (`--name`, `--size`, `--type`, `--location`) can be provided for fully non-interactive mode. Any missing flag triggers an interactive prompt for that field. Type defaults to NVMe (HDD is deprecated and no longer offered; NVMe pricing is shown in the confirmation summary), size defaults to 100 GiB, location is fetched from the API and offered as a selection. The pricing summary confirmation is skipped with `--yes`; agent mode requires `--yes`. ### action If `--id` is omitted, an interactive volume picker is shown. The action itself is always selected interactively. Destructive actions (detach, delete) require confirmation. Rename, resize, and clone prompt for additional input via a `Prepare` callback before execution. diff --git a/internal/verda-cli/cmd/volume/action.go b/internal/verda-cli/cmd/volume/action.go index 838c6bb..4c4f343 100644 --- a/internal/verda-cli/cmd/volume/action.go +++ b/internal/verda-cli/cmd/volume/action.go @@ -108,7 +108,10 @@ func runVolumeAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO idx, err := prompter.Select(ctx, "Select action", labels, tui.WithShowHints(true)) if err != nil { - return nil + if cmdutil.IsPromptCancel(err) { + return nil // User pressed Esc/Ctrl+C. + } + return err } if idx == len(actions) { return nil @@ -126,7 +129,14 @@ func runVolumeAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO if action.ConfirmMsg != "" || action.WarningMsg != "" { _, _ = fmt.Fprintln(ioStreams.ErrOut) confirmed, err := prompter.Confirm(ctx, fmt.Sprintf("Would you like to continue? (%s on %s)", action.Label, vol.Name)) - if err != nil || !confirmed { + if err != nil { + if cmdutil.IsPromptCancel(err) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return err + } + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } @@ -191,7 +201,13 @@ func buildVolumeActions(ctx context.Context, prompter tui.Prompter, client *verd Label: "Rename", Prepare: func(ctx context.Context) error { n, err := prompter.TextInput(ctx, "New name", tui.WithDefault(vol.Name)) - if err != nil || strings.TrimSpace(n) == "" { + if err != nil { + if cmdutil.IsPromptCancel(err) { + return errors.New("canceled") + } + return err + } + if strings.TrimSpace(n) == "" { return errors.New("canceled") } newName = strings.TrimSpace(n) @@ -207,7 +223,13 @@ func buildVolumeActions(ctx context.Context, prompter tui.Prompter, client *verd Label: "Resize (grow only)", Prepare: func(ctx context.Context) error { sizeStr, err := prompter.TextInput(ctx, fmt.Sprintf("New size in GiB (current: %d)", vol.Size)) - if err != nil || strings.TrimSpace(sizeStr) == "" { + if err != nil { + if cmdutil.IsPromptCancel(err) { + return errors.New("canceled") + } + return err + } + if strings.TrimSpace(sizeStr) == "" { return errors.New("canceled") } s, err := strconv.Atoi(strings.TrimSpace(sizeStr)) @@ -227,7 +249,13 @@ func buildVolumeActions(ctx context.Context, prompter tui.Prompter, client *verd Label: "Clone", Prepare: func(ctx context.Context) error { n, err := prompter.TextInput(ctx, "Clone name", tui.WithDefault(vol.Name+"-clone")) - if err != nil || strings.TrimSpace(n) == "" { + if err != nil { + if cmdutil.IsPromptCancel(err) { + return errors.New("canceled") + } + return err + } + if strings.TrimSpace(n) == "" { return errors.New("canceled") } cloneName = strings.TrimSpace(n) @@ -279,7 +307,10 @@ func selectVolume(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt idx, err := f.Prompter().Select(ctx, "Select volume (type to filter)", labels, tui.WithShowHints(true)) if err != nil { - return "", nil //nolint:nilerr // User pressed Esc/Ctrl+C during prompt. + if cmdutil.IsPromptCancel(err) { + return "", nil // User pressed Esc/Ctrl+C during prompt. + } + return "", err } if idx == len(volumes) { return "", nil diff --git a/internal/verda-cli/cmd/volume/create.go b/internal/verda-cli/cmd/volume/create.go index e0e397e..b77f94a 100644 --- a/internal/verda-cli/cmd/volume/create.go +++ b/internal/verda-cli/cmd/volume/create.go @@ -18,7 +18,6 @@ import ( "context" "errors" "fmt" - "math" "strconv" "strings" @@ -35,6 +34,7 @@ type createOptions struct { Size int Type string Location string + Yes bool Wait cmdutil.WaitOptions } @@ -48,13 +48,14 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command Long: cmdutil.LongDesc(` Create a new block storage volume. If flags are omitted, an interactive prompt guides you through the options. + Agent mode requires all flags and --yes. `), Example: cmdutil.Examples(` # Interactive verda volume create # Non-interactive - verda volume create --name my-vol --size 100 --type NVMe --location FIN-01 + verda volume create --name my-vol --size 100 --type NVMe --location FIN-01 --yes `), Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -67,6 +68,7 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command flags.IntVar(&opts.Size, "size", 0, "Volume size in GiB") flags.StringVar(&opts.Type, "type", "", "Volume type (default: NVMe)") flags.StringVar(&opts.Location, "location", "", "Location code, e.g. FIN-01") + flags.BoolVar(&opts.Yes, "yes", false, "Skip confirmation (required in agent mode)") opts.Wait.AddFlags(flags, false) // --wait defaults to false for volume create return cmd @@ -74,6 +76,11 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command //nolint:gocyclo // Interactive CLI command with multiple prompt steps — inherently complex. func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) error { + // Agent mode never prompts: creating a billable volume without --yes is an explicit error. + if f.AgentMode() && !opts.Yes { + return cmdutil.NewConfirmationRequiredError("create volume") + } + client, err := f.VerdaClient() if err != nil { return err @@ -102,55 +109,52 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream // Volume type: NVMe is the only provisionable type (HDD deprecated), so we // default it rather than prompt. Pricing is still shown in the summary below. + // An unknown type must fail loudly here — it would otherwise price at $0. if opts.Type == "" { opts.Type = verda.VolumeTypeNVMe } + if _, ok := vtMap[opts.Type]; !ok { + return cmdutil.UsageErrorf(cmd, "invalid --type %q (valid types: %s)", + opts.Type, strings.Join(cmdutil.ValidVolumeTypeNames(vtMap), ", ")) + } // Name. if opts.Name == "" { name, err := prompter.TextInput(ctx, "Volume name") - if err != nil || strings.TrimSpace(name) == "" { - return nil + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil // User pressed Esc/Ctrl+C. + } + return err + } + if strings.TrimSpace(name) == "" { + return nil // Blank input cancels. } opts.Name = strings.TrimSpace(name) } // Size. if opts.Size == 0 { - sizeStr, err := prompter.TextInput(ctx, "Size in GiB", tui.WithDefault("100")) - if err != nil || strings.TrimSpace(sizeStr) == "" { - return nil + size, err := promptSize(ctx, prompter) + if err != nil { + return err } - size, err := strconv.Atoi(strings.TrimSpace(sizeStr)) - if err != nil || size <= 0 { - return errors.New("size must be a positive integer") + if size == 0 { + return nil // Canceled or blank input. } opts.Size = size } // Location. if opts.Location == "" { - var sp interface{ Stop(string) } - if status := f.Status(); status != nil { - sp, _ = status.Spinner(ctx, "Loading locations...") - } - locations, err := client.Locations.Get(ctx) - if sp != nil { - sp.Stop("") - } + location, err := promptLocation(ctx, f, prompter, client) if err != nil { - return fmt.Errorf("fetching locations: %w", err) + return err } - - labels := make([]string, len(locations)) - for i, loc := range locations { - labels[i] = fmt.Sprintf("%s (%s)", loc.Code, loc.Name) + if location == "" { + return nil // Canceled. } - idx, err := prompter.Select(ctx, "Location", labels, tui.WithShowHints(true)) - if err != nil { - return nil - } - opts.Location = locations[idx].Code + opts.Location = location } // Summary with pricing. @@ -158,13 +162,9 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) priceStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2")) - var monthlyPerGB float64 - if vt, ok := vtMap[opts.Type]; ok { - monthlyPerGB = vt.Price.PricePerMonthPerGB - } - const hoursInMonth = 730 // 365*24/12, matching web frontend - hourly := math.Ceil(monthlyPerGB*float64(opts.Size)/hoursInMonth*10000) / 10000 - monthly := monthlyPerGB * float64(opts.Size) + monthlyPerGB := vtMap[opts.Type].Price.PricePerMonthPerGB + hourly := cmdutil.VolumeHourlyPrice(monthlyPerGB, opts.Size) + monthly := cmdutil.VolumeMonthlyPrice(monthlyPerGB, opts.Size) _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n", bold.Render("Volume Summary")) _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n\n", dim.Render(strings.Repeat("─", 45))) @@ -178,10 +178,19 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s %s\n", bold.Render(fmt.Sprintf("%-30s", "Hourly")), bold.Render(priceStyle.Render(fmt.Sprintf("$%.4f/hr", hourly)))) _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n\n", dim.Render(strings.Repeat("─", 45))) - confirmed, err := prompter.Confirm(ctx, "Create volume?", tui.WithConfirmDefault(true)) - if err != nil || !confirmed { - _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") - return nil + if !opts.Yes { + confirmed, err := prompter.Confirm(ctx, "Create volume?", tui.WithConfirmDefault(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return err + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } } // Create. @@ -208,7 +217,24 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream return err } - _, _ = fmt.Fprintf(ioStreams.Out, "Created volume: %s (%s)\n", opts.Name, volID) + if f.AgentMode() { + // Structured result. With --wait, the polled volume document below + // is the single agent-mode payload instead. + if !opts.Wait.Wait { + result := map[string]any{ + "action": "create", + "id": volID, + "name": opts.Name, + "size_gb": opts.Size, + "type": opts.Type, + "location": opts.Location, + "status": "created", + } + _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), result) + } + } else { + _, _ = fmt.Fprintf(ioStreams.Out, "Created volume: %s (%s)\n", opts.Name, volID) + } if opts.Wait.Wait { vol, err := cmdutil.PollVolumeStatus(ctx, ioStreams.ErrOut, client, volID, opts.Wait, "detached") @@ -223,3 +249,52 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream } return nil } + +// promptSize asks for the volume size in GiB. Returns (0, nil) when the user +// cancels or submits blank input — both abort the create flow quietly. +func promptSize(ctx context.Context, prompter tui.Prompter) (int, error) { + sizeStr, err := prompter.TextInput(ctx, "Size in GiB", tui.WithDefault("100")) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return 0, nil // User pressed Esc/Ctrl+C. + } + return 0, err + } + if strings.TrimSpace(sizeStr) == "" { + return 0, nil // Blank input cancels. + } + size, err := strconv.Atoi(strings.TrimSpace(sizeStr)) + if err != nil || size <= 0 { + return 0, errors.New("size must be a positive integer") + } + return size, nil +} + +// promptLocation fetches available locations and asks the user to pick one. +// Returns "" when the user cancels. +func promptLocation(ctx context.Context, f cmdutil.Factory, prompter tui.Prompter, client *verda.Client) (string, error) { + var sp interface{ Stop(string) } + if status := f.Status(); status != nil { + sp, _ = status.Spinner(ctx, "Loading locations...") + } + locations, err := client.Locations.Get(ctx) + if sp != nil { + sp.Stop("") + } + if err != nil { + return "", fmt.Errorf("fetching locations: %w", err) + } + + labels := make([]string, len(locations)) + for i, loc := range locations { + labels[i] = fmt.Sprintf("%s (%s)", loc.Code, loc.Name) + } + idx, err := prompter.Select(ctx, "Location", labels, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", nil // User pressed Esc/Ctrl+C. + } + return "", err + } + return locations[idx].Code, nil +} diff --git a/internal/verda-cli/cmd/volume/create_test.go b/internal/verda-cli/cmd/volume/create_test.go new file mode 100644 index 0000000..4187b33 --- /dev/null +++ b/internal/verda-cli/cmd/volume/create_test.go @@ -0,0 +1,76 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package volume + +import ( + "bytes" + "strings" + "testing" + + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// Volume create must require --yes in agent mode (the gate fires before any +// API client is needed). Without it, piped/agent runs used to print +// "Canceled." and exit 0 without creating anything. +func TestCreateAgentModeRequiresYes(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf} + f := &cmdutil.TestFactory{AgentModeOverride: true} + + root := &cobra.Command{Use: "verda", SilenceUsage: true, SilenceErrors: true} + root.AddCommand(NewCmdVolume(f, ioStreams)) + root.SetArgs([]string{"volume", "create", "--name", "my-vol", "--size", "100", "--location", "FIN-01"}) + + err := root.Execute() + if err == nil { + t.Fatal("expected error: agent mode create requires --yes") + } + ae := cmdutil.ClassifyError(err) + if ae.Code != "CONFIRMATION_REQUIRED" { + t.Fatalf("code = %q, want CONFIRMATION_REQUIRED (err: %v)", ae.Code, err) + } + if !strings.Contains(err.Error(), "requires --yes in agent mode") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestCreateHasYesFlag(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf} + f := cmdutil.NewTestFactory(nil) + + volCmd := NewCmdVolume(f, ioStreams) + + var createCmd *cobra.Command + for _, sub := range volCmd.Commands() { + if sub.Name() == "create" { + createCmd = sub + break + } + } + if createCmd == nil { + t.Fatal("create subcommand not found") + } + if createCmd.Flags().Lookup("yes") == nil { + t.Error("create missing --yes flag") + } +} diff --git a/internal/verda-cli/cmd/volume/delete.go b/internal/verda-cli/cmd/volume/delete.go index 4c35b59..6336070 100644 --- a/internal/verda-cli/cmd/volume/delete.go +++ b/internal/verda-cli/cmd/volume/delete.go @@ -16,7 +16,6 @@ package volume import ( "context" - "errors" "fmt" "charm.land/lipgloss/v2" @@ -79,12 +78,12 @@ func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *deleteOptions) error { // Validate: --status is a filter that requires --all. if !opts.All && opts.Status != "" { - return errors.New("--status can only be used with --all") + return cmdutil.UsageErrorf(cmd, "--status can only be used with --all") } // Validate: --all cannot combine with --id or positional arg. if opts.All && opts.VolumeID != "" { - return errors.New("cannot combine --all with --id or positional volume ID") + return cmdutil.UsageErrorf(cmd, "cannot combine --all with --id or positional volume ID") } // Agent mode: --all requires --yes. @@ -92,6 +91,11 @@ func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream return cmdutil.NewConfirmationRequiredError("delete --all") } + // Agent mode: single-volume delete requires --yes too (never prompts). + if opts.VolumeID != "" && f.AgentMode() && !opts.Yes { + return cmdutil.NewConfirmationRequiredError("delete") + } + client, err := f.VerdaClient() if err != nil { return err @@ -121,12 +125,19 @@ func runSingleVolumeDelete(ctx context.Context, f cmdutil.Factory, ioStreams cmd warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) - if !skipConfirm && !f.AgentMode() { + if !skipConfirm { _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n Deleted storage can be restored within 96 hours.\n") _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n\n", warnStyle.Render("This action cannot be undone after the recovery period.")) confirmed, confirmErr := f.Prompter().Confirm(ctx, fmt.Sprintf("Delete %s (%dGB %s)?", vol.Name, vol.Size, vol.Type)) - if confirmErr != nil || !confirmed { + if confirmErr != nil { + if cmdutil.IsPromptCancel(confirmErr) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return confirmErr + } + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } @@ -152,6 +163,17 @@ func runSingleVolumeDelete(ctx context.Context, f cmdutil.Factory, ioStreams cmd return err } + if f.AgentMode() { + result := map[string]string{ + "id": vol.ID, + "name": vol.Name, + "action": "delete", + "status": "completed", + } + _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), result) + return nil + } + _, _ = fmt.Fprintf(ioStreams.Out, "Deleted: %s (%s)\n", vol.Name, vol.ID) return nil } @@ -187,7 +209,10 @@ func runInteractiveVolumeDelete(ctx context.Context, f cmdutil.Factory, ioStream indices, err := f.Prompter().MultiSelect(ctx, "Select volumes to delete", labels) if err != nil { - return nil //nolint:nilerr // User pressed Esc/Ctrl+C. + if cmdutil.IsPromptCancel(err) { + return nil // User pressed Esc/Ctrl+C. + } + return err } if len(indices) == 0 { _, _ = fmt.Fprintln(ioStreams.ErrOut, "No volumes selected.") @@ -242,7 +267,7 @@ func executeBatchVolumeDelete(ctx context.Context, f cmdutil.Factory, ioStreams redStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")) // Show confirmation. - if !skipConfirm && !f.AgentMode() { + if !skipConfirm { _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n About to delete %d volumes:\n", len(volumes)) for i := range volumes { v := &volumes[i] @@ -253,9 +278,16 @@ func executeBatchVolumeDelete(ctx context.Context, f cmdutil.Factory, ioStreams _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n\n", warnStyle.Render("This action cannot be undone after the recovery period.")) confirmed, confirmErr := f.Prompter().Confirm(ctx, fmt.Sprintf("Delete %d volumes?", len(volumes))) - if confirmErr != nil || !confirmed { + if confirmErr != nil { + if cmdutil.IsPromptCancel(confirmErr) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil // User pressed Esc/Ctrl+C during prompt. + } + return confirmErr + } + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") - return nil //nolint:nilerr // User pressed Esc/Ctrl+C during prompt. + return nil } } diff --git a/internal/verda-cli/cmd/volume/delete_test.go b/internal/verda-cli/cmd/volume/delete_test.go index 8aca3d7..ed1a7a6 100644 --- a/internal/verda-cli/cmd/volume/delete_test.go +++ b/internal/verda-cli/cmd/volume/delete_test.go @@ -75,6 +75,37 @@ func TestDeleteAgentModeRequiresYes(t *testing.T) { } } +// Single-volume delete must also require --yes in agent mode — no silent +// confirmation bypass (the gate must fire without any API client). +func TestDeleteAgentModeSingleRequiresYes(t *testing.T) { + t.Parallel() + + for _, args := range [][]string{ + {"volume", "delete", "--id", "vol-123"}, + {"volume", "delete", "vol-123"}, + } { + var buf bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf} + f := &cmdutil.TestFactory{AgentModeOverride: true} + + root := &cobra.Command{Use: "verda", SilenceUsage: true, SilenceErrors: true} + root.AddCommand(NewCmdVolume(f, ioStreams)) + root.SetArgs(args) + + err := root.Execute() + if err == nil { + t.Fatalf("%v: expected error: agent mode delete requires --yes", args) + } + ae := cmdutil.ClassifyError(err) + if ae.Code != "CONFIRMATION_REQUIRED" { + t.Fatalf("%v: code = %q, want CONFIRMATION_REQUIRED (err: %v)", args, ae.Code, err) + } + if !strings.Contains(err.Error(), "requires --yes in agent mode") { + t.Fatalf("%v: unexpected error: %v", args, err) + } + } +} + func TestDeleteStatusRequiresAll(t *testing.T) { t.Parallel() @@ -142,3 +173,40 @@ func TestDeleteHasRmAlias(t *testing.T) { t.Fatal("expected 'rm' alias for delete command") } } + +// The review sites: usage/flag-misuse errors must classify as VALIDATION_ERROR +// (exit 2) in agent mode — distinct from server-side failures. +func TestDeleteUsageErrorsClassifyAsValidation(t *testing.T) { + t.Parallel() + + newRoot := func(args ...string) *cobra.Command { + var buf bytes.Buffer + ioStreams := cmdutil.IOStreams{Out: &buf, ErrOut: &buf} + f := &cmdutil.TestFactory{AgentModeOverride: true, OutputFormatOverride: "json"} + root := &cobra.Command{Use: "verda", SilenceUsage: true, SilenceErrors: true} + root.AddCommand(NewCmdVolume(f, ioStreams)) + root.SetArgs(args) + return root + } + + cases := map[string][]string{ + "status without all": {"volume", "delete", "--status", "detached"}, + "all combined with id": {"volume", "delete", "--all", "--id", "vol-1", "--yes"}, + } + for name, args := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + err := newRoot(args...).Execute() + if err == nil { + t.Fatal("expected a usage error") + } + ae := cmdutil.ClassifyError(err) + if ae.Code != "VALIDATION_ERROR" { + t.Fatalf("code = %q, want VALIDATION_ERROR (err: %v)", ae.Code, err) + } + if ae.ExitCode != cmdutil.ExitBadArgs { + t.Fatalf("exit code = %d, want %d", ae.ExitCode, cmdutil.ExitBadArgs) + } + }) + } +} diff --git a/internal/verda-cli/options/options.go b/internal/verda-cli/options/options.go index 37326f7..9d4b27d 100644 --- a/internal/verda-cli/options/options.go +++ b/internal/verda-cli/options/options.go @@ -172,30 +172,28 @@ func (o *Options) Complete() { a.Profile = resolveDefaultProfile(a.CredentialsFile) } - // --- 3. Resolve inline credentials (flags / env). --- - // Track which fields were set explicitly so they win over profile values. - flagClientID := a.ClientID != "" + // --- 3. Resolve inline credentials (flag > config file > env). --- if a.ClientID == "" { a.ClientID = viper.GetString("auth.client-id") } if a.ClientID == "" { a.ClientID = os.Getenv("VERDA_CLIENT_ID") } - flagClientSecret := a.ClientSecret != "" if a.ClientSecret == "" { a.ClientSecret = viper.GetString("auth.client-secret") } if a.ClientSecret == "" { a.ClientSecret = os.Getenv("VERDA_CLIENT_SECRET") } - flagToken := a.BearerToken != "" if a.BearerToken == "" { a.BearerToken = viper.GetString("auth.token") } // --- 4. Load from credentials file. --- - // When the profile was explicitly chosen, its credentials override any - // auto-resolved values — but explicit flags/env always win. + // Inline sources (flag, config file, env — incl. VERDA_AUTH_* spellings via + // viper's env binding) always win over the stored profile: the profile only + // fills fields still empty. Base URL is the exception — an explicitly + // selected profile pins its own verda_base_url. missingRequired := a.ClientID == "" || a.ClientSecret == "" if explicitProfile || missingRequired || a.BearerToken == "" { shared, err := loadSharedCredentials(a.CredentialsFile, a.Profile) @@ -204,13 +202,13 @@ func (o *Options) Complete() { if shared.BaseURL != "" && (explicitProfile || o.Server == defaultBaseURL) { o.Server = shared.BaseURL } - if shared.ClientID != "" && (explicitProfile && !flagClientID || a.ClientID == "") { + if a.ClientID == "" { a.ClientID = shared.ClientID } - if shared.ClientSecret != "" && (explicitProfile && !flagClientSecret || a.ClientSecret == "") { + if a.ClientSecret == "" { a.ClientSecret = shared.ClientSecret } - if shared.BearerToken != "" && (explicitProfile && !flagToken || a.BearerToken == "") { + if a.BearerToken == "" { a.BearerToken = shared.BearerToken } case (explicitProfile || missingRequired) && !os.IsNotExist(err): diff --git a/internal/verda-cli/options/options_test.go b/internal/verda-cli/options/options_test.go index f147909..a4bd3d2 100644 --- a/internal/verda-cli/options/options_test.go +++ b/internal/verda-cli/options/options_test.go @@ -146,15 +146,7 @@ func writeCredentialsFile(t *testing.T, content string) string { func makeLocalTempDir(t *testing.T) string { t.Helper() - - dir, err := os.MkdirTemp(".", "tmp-test-") - if err != nil { - t.Fatalf("os.MkdirTemp() returned error: %v", err) - } - t.Cleanup(func() { - _ = os.RemoveAll(dir) - }) - return dir + return t.TempDir() } func TestOptionsValidateOutputFormat(t *testing.T) { @@ -247,17 +239,18 @@ verda_client_secret = secret // --------------------------------------------------------------------------- // Credential resolution priority tests // -// Auto-resolved profile (no explicit --auth.profile): -// 1. CLI flags (--auth.client-id=xxx) -// 2. Config file (viper: auth.client-id in config.yaml) +// Per-field precedence is uniform, whether the profile is auto-resolved or +// explicitly selected (--auth.profile / VERDA_PROFILE): +// 1. CLI flag (--auth.client-id=xxx) +// 2. Config file/viper (auth.client-id; also VERDA_AUTH_CLIENT_ID via +// viper's AutomaticEnv binding) // 3. Environment vars (VERDA_CLIENT_ID) -// 4. Credentials file ([default] section in ~/.verda/credentials) +// 4. Credentials file (selected profile section fills only unset fields) // -// Explicit profile (--auth.profile=staging): -// 1. CLI flags — always wins -// 2. Credentials file — explicit profile promotes its creds -// 3. Config file — viper values -// 4. Environment vars — lowest +// An explicit profile selects WHICH credentials-file section supplies missing +// values (and pins its verda_base_url); it does NOT promote stored values over +// inline sources — that silently sent env-credentialed CI jobs to the wrong +// account (review MEDIUM: env vs profile precedence). // // These tests are NOT parallel because they mutate global viper state. // --------------------------------------------------------------------------- @@ -323,14 +316,22 @@ func TestCredentialPriority(t *testing.T) { wantSec: "cred-secret", }, - // --- Explicit profile: flag > creds > viper > env --- + // --- Explicit profile: same cascade — the profile fills only gaps --- { - name: "explicit profile: creds override viper and env", + name: "explicit profile: viper and env beat creds", profile: "staging", id: credSource{viper: "viper-id", env: "env-id", cred: "staging-id"}, secret: credSource{viper: "viper-secret", env: "env-secret", cred: "staging-secret"}, - wantID: "staging-id", - wantSec: "staging-secret", + wantID: "viper-id", + wantSec: "viper-secret", + }, + { + name: "explicit profile: env beats creds when no config", + profile: "staging", + id: credSource{env: "env-id", cred: "staging-id"}, + secret: credSource{env: "env-secret", cred: "staging-secret"}, + wantID: "env-id", + wantSec: "env-secret", }, { name: "explicit profile: flag still wins over creds", @@ -346,7 +347,15 @@ func TestCredentialPriority(t *testing.T) { id: credSource{flag: "flag-id", env: "env-id", cred: "staging-id"}, secret: credSource{env: "env-secret", cred: "staging-secret"}, wantID: "flag-id", - wantSec: "staging-secret", // explicit profile creds beat env + wantSec: "env-secret", // env creds beat the stored profile + }, + { + name: "explicit profile: creds fill only unset fields", + profile: "staging", + id: credSource{cred: "staging-id"}, + secret: credSource{cred: "staging-secret"}, + wantID: "staging-id", + wantSec: "staging-secret", }, } @@ -410,6 +419,42 @@ func TestCredentialPriority(t *testing.T) { } } +// TestCredentialPriority_ViperEnvBinding: the real CLI enables viper +// AutomaticEnv with prefix VERDA (cmd/helper.go initConfig), so the +// VERDA_AUTH_CLIENT_ID/VERDA_AUTH_CLIENT_SECRET spellings land in the viper +// layer. They must beat an explicitly selected stored profile exactly like +// the documented VERDA_CLIENT_ID spelling does. +func TestCredentialPriority_ViperEnvBinding(t *testing.T) { + // no t.Parallel — mutates global viper + env + viper.Reset() // clear earlier tests' viper.Set("", "") overrides, which mask env + viper.SetEnvPrefix("VERDA") + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) + viper.AutomaticEnv() + t.Cleanup(viper.Reset) + + t.Setenv("VERDA_AUTH_CLIENT_ID", "viper-env-id") + t.Setenv("VERDA_AUTH_CLIENT_SECRET", "viper-env-secret") + + path := writeCredentialsFile(t, "[staging]\nverda_client_id = staging-id\nverda_client_secret = staging-secret\n") + + opts := &Options{ + Server: defaultBaseURL, + Timeout: 30, + AuthOptions: &AuthOptions{ + Profile: "staging", // explicit profile selection + CredentialsFile: path, + }, + } + opts.Complete() + + if got := opts.AuthOptions.ClientID; got != "viper-env-id" { + t.Errorf("ClientID = %q, want viper-env-id (VERDA_AUTH_* beats stored profile)", got) + } + if got := opts.AuthOptions.ClientSecret; got != "viper-env-secret" { + t.Errorf("ClientSecret = %q, want viper-env-secret (VERDA_AUTH_* beats stored profile)", got) + } +} + func TestActiveProfile(t *testing.T) { // no t.Parallel — mutates global viper + env diff --git a/pkg/tui/bubbletea/editor.go b/pkg/tui/bubbletea/editor.go index e51a1f5..d837f51 100644 --- a/pkg/tui/bubbletea/editor.go +++ b/pkg/tui/bubbletea/editor.go @@ -26,13 +26,14 @@ import ( ) type editorModel struct { - prompt string - textarea textarea.Model - hint string // resolved: caller override or library default - showHint bool // false = suppress the affordance line (WithEditorNoHint) - summary func(lines int) string // resolved: caller override or library default - submitted bool - aborted bool + prompt string + textarea textarea.Model + hint string // resolved: caller override or library default + showHint bool // false = suppress the affordance line (WithEditorNoHint) + summary func(lines int) string // resolved: caller override or library default + submitted bool + aborted bool + interrupted bool // true for Ctrl+C (hard cancel), false for Esc (soft cancel) } func newEditorModel(prompt string, cfg tui.EditorConfig) editorModel { @@ -68,7 +69,10 @@ func (m editorModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "ctrl+d": m.submitted = true return m, tea.Quit - case keyCtrlC, keyEsc: + case keyCtrlC: + m.interrupted = true + return m, tea.Quit + case keyEsc: m.aborted = true return m, tea.Quit } @@ -107,6 +111,9 @@ func (p *Prompter) Editor(ctx context.Context, prompt string, opts ...tui.Editor } m := result.(editorModel) + if m.interrupted { + return "", tui.ErrInterrupted + } if m.aborted { return "", context.Canceled } diff --git a/pkg/tui/bubbletea/pager.go b/pkg/tui/bubbletea/pager.go index 552e962..1842381 100644 --- a/pkg/tui/bubbletea/pager.go +++ b/pkg/tui/bubbletea/pager.go @@ -42,10 +42,11 @@ func terminalHeight(w io.Writer) int { } type pagerModel struct { - viewport viewport.Model - title string - ready bool - quitting bool + viewport viewport.Model + title string + ready bool + quitting bool + interrupted bool // true for Ctrl+C (hard cancel), false for q/Esc } func newPagerModel(content string, cfg tui.PagerConfig) pagerModel { @@ -85,7 +86,11 @@ func (m pagerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ready = true case tea.KeyPressMsg: switch msg.String() { - case "q", keyEsc, keyCtrlC: + case keyCtrlC: + m.quitting = true + m.interrupted = true + return m, tea.Quit + case "q", keyEsc: m.quitting = true return m, tea.Quit } @@ -126,11 +131,12 @@ func (m pagerModel) View() tea.View { func (p *Prompter) Pager(ctx context.Context, content string, opts ...tui.PagerOption) error { cfg := tui.ResolvePagerConfig(opts) - // Auto-detect: if content fits in terminal, just print it. + // Auto-detect: if content fits in terminal, just print it. The + // print-through path is data, so it goes to dataOut (house rule). lines := strings.Count(content, "\n") + 1 termHeight := terminalHeight(p.out) if lines <= termHeight-2 { // leave room for prompt - _, err := fmt.Fprint(p.out, content) + _, err := fmt.Fprint(p.dataOut, content) return err } @@ -142,6 +148,12 @@ func (p *Prompter) Pager(ctx context.Context, content string, opts ...tui.PagerO tea.WithContext(ctx), ) - _, err := program.Run() - return err + result, err := program.Run() + if err != nil { + return err + } + if m, ok := result.(pagerModel); ok && m.interrupted { + return tui.ErrInterrupted + } + return nil } diff --git a/pkg/tui/bubbletea/progress.go b/pkg/tui/bubbletea/progress.go index f13e29d..fc34f32 100644 --- a/pkg/tui/bubbletea/progress.go +++ b/pkg/tui/bubbletea/progress.go @@ -41,6 +41,7 @@ type progressModel struct { done bool finalMessage string autoStop bool + interrupted bool // true if the user ended the bar with Ctrl+C } func newProgressModel(message string, cfg tui.ProgressConfig) progressModel { @@ -105,6 +106,7 @@ func (m progressModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: if msg.String() == keyCtrlC { m.done = true + m.interrupted = true m.finalMessage = m.message return m, tea.Quit } @@ -122,9 +124,10 @@ func (m progressModel) View() tea.View { // --- Handle --- type progressHandle struct { - program *tea.Program - once sync.Once - done chan struct{} + program *tea.Program + once sync.Once + done chan struct{} + interrupted bool // written before done closes; safe to read after <-done } func (h *progressHandle) SetPercent(p float64) { @@ -142,8 +145,18 @@ func (h *progressHandle) Stop(finalMessage string) { }) } +// Interrupted blocks until the progress program exits and reports whether +// the user ended it with Ctrl+C. +func (h *progressHandle) Interrupted() bool { + <-h.done + return h.interrupted +} + // Progress implements tui.Status. func (p *Prompter) Progress(ctx context.Context, message string, opts ...tui.ProgressOption) (tui.ProgressHandle, error) { + if !rendersToTerminal(p.out) { + return silentProgress{}, nil + } cfg := tui.ResolveProgressConfig(opts) model := newProgressModel(message, cfg) @@ -153,14 +166,17 @@ func (p *Prompter) Progress(ctx context.Context, message string, opts ...tui.Pro tea.WithContext(ctx), ) - done := make(chan struct{}) + h := &progressHandle{ + program: program, + done: make(chan struct{}), + } go func() { - defer close(done) - _, _ = program.Run() + defer close(h.done) + final, _ := program.Run() + if m, ok := final.(progressModel); ok { + h.interrupted = m.interrupted + } }() - return &progressHandle{ - program: program, - done: done, - }, nil + return h, nil } diff --git a/pkg/tui/bubbletea/prompter.go b/pkg/tui/bubbletea/prompter.go index b147cda..0ccd857 100644 --- a/pkg/tui/bubbletea/prompter.go +++ b/pkg/tui/bubbletea/prompter.go @@ -21,6 +21,7 @@ import ( "os" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/term" "github.com/verda-cloud/verda-cli/pkg/tui" ) @@ -40,6 +41,12 @@ type runResult struct { // returns as tea.ErrInterrupted from program.Run(). The model never sees // the key event. This method detects that and sets the interrupted flag. func (p *Prompter) runProgram(ctx context.Context, model tea.Model) runResult { + // Without terminal stdin (pipe, redirect, /dev/null) a prompt can never + // receive keys and bubbletea would redraw forever — fail fast instead. + if f, ok := p.in.(*os.File); ok && !term.IsTerminal(f.Fd()) { + return runResult{err: tui.ErrNoTerminal} + } + program := tea.NewProgram(model, tea.WithInput(p.in), tea.WithOutput(p.out), @@ -78,17 +85,19 @@ func (p *Prompter) runProgram(ctx context.Context, model tea.Model) runResult { // Prompter implements tui.Prompter using Bubbletea. type Prompter struct { - in io.Reader - out io.Writer - errOut io.Writer + in io.Reader + out io.Writer // interactive UI: prompts, spinner, progress, pager scroller + errOut io.Writer + dataOut io.Writer // data output: Table, pager print-through } // New creates a Bubbletea-backed Prompter. func New(ioOpts ...func(*Prompter)) *Prompter { p := &Prompter{ - in: os.Stdin, - out: os.Stdout, - errOut: os.Stderr, + in: os.Stdin, + out: os.Stdout, + errOut: os.Stderr, + dataOut: os.Stdout, } for _, o := range ioOpts { o(p) @@ -96,31 +105,45 @@ func New(ioOpts ...func(*Prompter)) *Prompter { return p } -// WithIO configures the prompter with custom IO streams. +// WithIO configures the prompter with custom IO streams. The split follows the +// house rule "prompts → ErrOut, data → Out": interactive UI (Select, Confirm, +// TextInput, spinner, progress, the pager scroller) renders on ErrOut, while +// data (Table, pager print-through) is written to Out. func WithIO(io tui.IO) func(*Prompter) { return func(p *Prompter) { if io.In != nil { p.in = io.In } if io.Out != nil { - p.out = io.Out + p.dataOut = io.Out } if io.ErrOut != nil { + p.out = io.ErrOut p.errOut = io.ErrOut } } } +// NewFromIO adapts tui.IO modifiers into a Prompter; used by the registered +// Default/DefaultStatus builders. +func NewFromIO(ioOpts ...func(*tui.IO)) *Prompter { + var io tui.IO + for _, o := range ioOpts { + o(&io) + } + return New(WithIO(io)) +} + // Compile-time interface checks. var _ tui.Prompter = (*Prompter)(nil) var _ tui.Status = (*Prompter)(nil) var _ tui.LiveLister = (*Prompter)(nil) func init() { - tui.RegisterBuilder(func(_ ...func(*tui.IO)) tui.Prompter { - return New() + tui.RegisterBuilder(func(ioOpts ...func(*tui.IO)) tui.Prompter { + return NewFromIO(ioOpts...) }) - tui.RegisterStatusBuilder(func(_ ...func(*tui.IO)) tui.Status { - return New() + tui.RegisterStatusBuilder(func(ioOpts ...func(*tui.IO)) tui.Status { + return NewFromIO(ioOpts...) }) } diff --git a/pkg/tui/bubbletea/prompter_io_test.go b/pkg/tui/bubbletea/prompter_io_test.go new file mode 100644 index 0000000..20affe7 --- /dev/null +++ b/pkg/tui/bubbletea/prompter_io_test.go @@ -0,0 +1,96 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package bubbletea + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// TestWithIO_SplitsPromptUIAndData is the factory-wiring contract at the +// backend level: interactive UI renders on ErrOut, data on Out. +func TestWithIO_SplitsPromptUIAndData(t *testing.T) { + t.Parallel() + + var uiOut, dataOut bytes.Buffer + p := New(WithIO(tui.IO{ + In: bytes.NewBufferString("\r"), // Enter: pick the first choice + ErrOut: &uiOut, + Out: &dataOut, + })) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + idx, err := p.Select(ctx, "Pick one", []string{"alpha", "beta"}) + if err != nil { + t.Fatalf("Select: %v", err) + } + if idx != 0 { + t.Errorf("Select returned %d, want 0 (Enter picks the first choice)", idx) + } + if dataOut.Len() != 0 { + t.Errorf("prompt UI leaked into the data stream: %q", dataOut.String()) + } + if !strings.Contains(uiOut.String(), "\x1b[") { + t.Errorf("prompt UI frames did not render on ErrOut: %q", uiOut.String()) + } + + if err := p.Table(ctx, []string{"NAME"}, [][]string{{"row-1"}}); err != nil { + t.Fatalf("Table: %v", err) + } + if !strings.Contains(dataOut.String(), "NAME") { + t.Errorf("table data missing from Out: %q", dataOut.String()) + } +} + +// TestWithIO_NilStreamsKeepDefaults: partial IO only overrides the given +// streams. +func TestWithIO_NilStreamsKeepDefaults(t *testing.T) { + t.Parallel() + + var dataOut bytes.Buffer + p := New(WithIO(tui.IO{Out: &dataOut})) + if p.in == nil || p.out == nil || p.errOut == nil { + t.Fatal("nil streams left the prompter unwired") + } + + if err := p.Table(context.Background(), []string{"A"}, [][]string{{"b"}}); err != nil { + t.Fatalf("Table: %v", err) + } + if !strings.Contains(dataOut.String(), "A") { + t.Errorf("table data missing from the configured Out: %q", dataOut.String()) + } +} + +// TestNewFromIO_AdaptsTUIModifiers guards the registered Default/DefaultStatus +// builders, which silently dropped their ioOpts before. +func TestNewFromIO_AdaptsTUIModifiers(t *testing.T) { + t.Parallel() + + var dataOut bytes.Buffer + p := NewFromIO(func(io *tui.IO) { io.Out = &dataOut }) + if err := p.Table(context.Background(), []string{"A"}, [][]string{{"x"}}); err != nil { + t.Fatalf("Table: %v", err) + } + if !strings.Contains(dataOut.String(), "A") { + t.Errorf("NewFromIO dropped the Out modifier: %q", dataOut.String()) + } +} diff --git a/pkg/tui/bubbletea/spinner.go b/pkg/tui/bubbletea/spinner.go index 1a6044a..bb9be18 100644 --- a/pkg/tui/bubbletea/spinner.go +++ b/pkg/tui/bubbletea/spinner.go @@ -17,11 +17,13 @@ package bubbletea import ( "context" "fmt" + "io" "os" "sync" "charm.land/bubbles/v2/spinner" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/term" "github.com/verda-cloud/verda-cli/pkg/tui" ) @@ -39,6 +41,7 @@ type spinnerModel struct { done bool finalMessage string doneSymbol string + interrupted bool // true if the user ended the spinner with Ctrl+C } func newSpinnerModel(message string, cfg tui.SpinnerConfig) spinnerModel { @@ -72,14 +75,11 @@ func (m spinnerModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: if msg.String() == keyCtrlC { m.done = true + m.interrupted = true m.finalMessage = m.message - // Re-raise SIGINT so whoever installed a signal handler - // (typically the wizard engine) can cancel the outer context - // and abort the underlying work — tea.Quit alone only tears - // down the spinner, leaving fn() running. - if p, err := os.FindProcess(os.Getpid()); err == nil { - _ = p.Signal(os.Interrupt) - } + // No re-raise of SIGINT here: whether whoever wrapped the call + // aborts the guarded operation is decided via Interrupted(), + // typically wired by cmdutil.WithSpinner into the op's context. return m, tea.Quit } } @@ -121,9 +121,10 @@ func mapSpinnerStyle(s tui.SpinnerStyle) spinner.Spinner { // --- Handle --- type spinnerHandle struct { - program *tea.Program - once sync.Once - done chan struct{} + program *tea.Program + once sync.Once + done chan struct{} + interrupted bool // written before done closes; safe to read after <-done } func (h *spinnerHandle) UpdateMessage(msg string) { @@ -137,8 +138,44 @@ func (h *spinnerHandle) Stop(finalMessage string) { }) } +// Interrupted blocks until the spinner program exits and reports whether the +// user ended it with Ctrl+C. +func (h *spinnerHandle) Interrupted() bool { + <-h.done + return h.interrupted +} + +// --- Silent handle --- + +// silentSpinner is a no-op handle used when the output is not a terminal — +// spinners are transient UI; rendering frames into a pipe or capture buffer +// would corrupt machine-consumed stdout/stderr. +type silentSpinner struct{} + +func (silentSpinner) UpdateMessage(string) {} +func (silentSpinner) Stop(string) {} +func (silentSpinner) Interrupted() bool { return false } + +// silentProgress is the Progress counterpart of silentSpinner. +type silentProgress struct{} + +func (silentProgress) SetPercent(float64) {} +func (silentProgress) Increment(float64) {} +func (silentProgress) Stop(string) {} +func (silentProgress) Interrupted() bool { return false } + +// rendersToTerminal reports whether w is a terminal — the precondition for +// animated UI (spinner/progress) to be visible instead of polluting a pipe. +func rendersToTerminal(w io.Writer) bool { + f, ok := w.(*os.File) + return ok && term.IsTerminal(f.Fd()) +} + // Spinner implements tui.Status. func (p *Prompter) Spinner(ctx context.Context, message string, opts ...tui.SpinnerOption) (tui.SpinnerHandle, error) { + if !rendersToTerminal(p.out) { + return silentSpinner{}, nil + } cfg := tui.ResolveSpinnerConfig(opts) model := newSpinnerModel(message, cfg) @@ -148,14 +185,17 @@ func (p *Prompter) Spinner(ctx context.Context, message string, opts ...tui.Spin tea.WithContext(ctx), ) - done := make(chan struct{}) + h := &spinnerHandle{ + program: program, + done: make(chan struct{}), + } go func() { - defer close(done) - _, _ = program.Run() + defer close(h.done) + final, _ := program.Run() + if m, ok := final.(spinnerModel); ok { + h.interrupted = m.interrupted + } }() - return &spinnerHandle{ - program: program, - done: done, - }, nil + return h, nil } diff --git a/pkg/tui/bubbletea/table.go b/pkg/tui/bubbletea/table.go index 72f425e..2fe3e92 100644 --- a/pkg/tui/bubbletea/table.go +++ b/pkg/tui/bubbletea/table.go @@ -58,6 +58,6 @@ func (p *Prompter) Table(_ context.Context, columns []string, rows [][]string, o s.Selected = lipgloss.NewStyle() t.SetStyles(s) - _, err := fmt.Fprintln(p.out, t.View()) + _, err := fmt.Fprintln(p.dataOut, t.View()) return err } diff --git a/pkg/tui/default.go b/pkg/tui/default.go index f7b6a85..65065b1 100644 --- a/pkg/tui/default.go +++ b/pkg/tui/default.go @@ -33,13 +33,13 @@ func RegisterBuilder(fn func(ioOpts ...func(*IO)) Prompter) { // Default returns a Prompter created by the registered builder. // Panics if no builder has been registered — import a backend package // (e.g., _ "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea") to register one. -func Default() Prompter { +func Default(ioOpts ...func(*IO)) Prompter { mu.Lock() defer mu.Unlock() if builder == nil { panic("tui: no backend registered — import a backend package") } - return builder() + return builder(ioOpts...) } // RegisterStatusBuilder sets the factory used by DefaultStatus(). @@ -51,11 +51,11 @@ func RegisterStatusBuilder(fn func(ioOpts ...func(*IO)) Status) { // DefaultStatus returns a Status created by the registered builder. // Panics if no builder has been registered. -func DefaultStatus() Status { +func DefaultStatus(ioOpts ...func(*IO)) Status { mu.Lock() defer mu.Unlock() if statusBuilder == nil { panic("tui: no status backend registered — import a backend package") } - return statusBuilder() + return statusBuilder(ioOpts...) } diff --git a/pkg/tui/errors.go b/pkg/tui/errors.go index 11a0003..7234e54 100644 --- a/pkg/tui/errors.go +++ b/pkg/tui/errors.go @@ -19,3 +19,8 @@ import "errors" // ErrInterrupted is returned when the user presses Ctrl+C (hard cancel). // Distinct from context.Canceled which indicates Esc (soft cancel / go back). var ErrInterrupted = errors.New("interrupted") + +// ErrNoTerminal is returned when a prompt is attempted without a terminal on +// stdin (pipe, redirect, /dev/null). A Bubble Tea prompt can never receive +// key input there and would redraw forever, so prompters fail fast instead. +var ErrNoTerminal = errors.New("interactive prompt requires a terminal: stdin is not a TTY (pass flags to run non-interactively)") diff --git a/pkg/tui/status.go b/pkg/tui/status.go index 51f268f..3a0d77e 100644 --- a/pkg/tui/status.go +++ b/pkg/tui/status.go @@ -47,6 +47,13 @@ type SpinnerHandle interface { // Stop stops the spinner and shows a final message. // If finalMessage is empty, the last message is shown. Stop(finalMessage string) + + // Interrupted blocks until the spinner program exits and reports whether + // the user ended it with Ctrl+C. Call it from a watcher goroutine (or + // after Stop) — the intent is that whoever owns the operation the spinner + // guards cancels it immediately instead of quitting only the UI while the + // work runs to completion unseen. + Interrupted() bool } // ProgressHandle controls a running progress bar. @@ -59,4 +66,9 @@ type ProgressHandle interface { // Stop stops the progress bar and shows a final message. Stop(finalMessage string) + + // Interrupted blocks until the progress program exits and reports whether + // the user ended it with Ctrl+C. Same watcher-goroutine contract as + // SpinnerHandle.Interrupted. + Interrupted() bool } diff --git a/pkg/tui/testing/prompter.go b/pkg/tui/testing/prompter.go index 5d6526b..cf9d5c7 100644 --- a/pkg/tui/testing/prompter.go +++ b/pkg/tui/testing/prompter.go @@ -183,6 +183,10 @@ func (h *SpinnerHandle) Stop(finalMessage string) { h.Stopped = true } +// Interrupted always reports false: the test double never starts a program, +// so there is nothing a user could have Ctrl+C'd. +func (h *SpinnerHandle) Interrupted() bool { return false } + // ProgressHandle is a no-op handle for testing. type ProgressHandle struct { Percent float64 @@ -206,6 +210,9 @@ func (h *ProgressHandle) Stop(finalMessage string) { h.Stopped = true } +// Interrupted always reports false: the test double never starts a program. +func (h *ProgressHandle) Interrupted() bool { return false } + func (p *Prompter) Spinner(_ context.Context, _ string, _ ...tui.SpinnerOption) (tui.SpinnerHandle, error) { return &SpinnerHandle{}, nil } diff --git a/pkg/tui/tui_test.go b/pkg/tui/tui_test.go new file mode 100644 index 0000000..5e297da --- /dev/null +++ b/pkg/tui/tui_test.go @@ -0,0 +1,190 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tui + +import ( + "context" + "errors" + "testing" +) + +// The Resolve*Config defaults are UX contracts: every prompt in the CLI +// inherits them, so a silent change here alters paging and wrap-around +// behavior everywhere at once with nothing to catch it. +func TestResolveConfigs_Defaults(t *testing.T) { + t.Run("select paginates at 10 and wraps", func(t *testing.T) { + cfg := ResolveSelectConfig(nil) + if cfg.PageSize != 10 { + t.Errorf("PageSize = %d, want 10", cfg.PageSize) + } + if !cfg.Loop { + t.Error("Loop = false, want true") + } + if cfg.ShowHints { + t.Error("ShowHints defaulted to true; the hint bar must stay opt-in per call site") + } + }) + + t.Run("multiselect matches select", func(t *testing.T) { + cfg := ResolveMultiSelectConfig(nil) + if cfg.PageSize != 10 || !cfg.Loop { + t.Errorf("PageSize/Loop = %d/%v, want 10/true", cfg.PageSize, cfg.Loop) + } + }) + + t.Run("livelist matches select", func(t *testing.T) { + cfg := ResolveLiveListConfig(nil) + if cfg.PageSize != 10 || !cfg.Loop { + t.Errorf("PageSize/Loop = %d/%v, want 10/true", cfg.PageSize, cfg.Loop) + } + }) + + t.Run("editor defaults to .txt with help", func(t *testing.T) { + cfg := ResolveEditorConfig(nil) + if cfg.FileExt != ".txt" { + t.Errorf("FileExt = %q, want .txt", cfg.FileExt) + } + if !cfg.ShowHelp { + t.Error("ShowHelp = false, want true") + } + }) + + t.Run("confirm defaults to no", func(t *testing.T) { + if ResolveConfirmConfig(nil).Default { + t.Error("Default = true; a confirm must never pre-answer yes") + } + }) +} + +// Options are applied in slice order, so a later option wins. Call sites rely +// on this to layer a caller override on top of a shared base option set. +func TestResolveSelectConfig_LastOptionWins(t *testing.T) { + cfg := ResolveSelectConfig([]SelectOption{ + WithPageSize(5), + WithPageSize(20), + }) + if cfg.PageSize != 20 { + t.Errorf("PageSize = %d, want 20 (later option must win)", cfg.PageSize) + } +} + +func TestWithShowHints_TogglesHintBar(t *testing.T) { + if !ResolveSelectConfig([]SelectOption{WithShowHints(true)}).ShowHints { + t.Error("WithShowHints(true) did not set ShowHints") + } + if !ResolveMultiSelectConfig([]MultiSelectOption{WithMultiSelectShowHints(true)}).ShowHints { + t.Error("WithMultiSelectShowHints(true) did not set ShowHints") + } +} + +// Relabel lazily allocates its map; the nil-map path is the common one since +// configs start zero-valued. +func TestRelabel_AllocatesAndAccumulates(t *testing.T) { + cfg := ResolveSelectConfig([]SelectOption{ + WithSelectRelabel("up-down", "move"), + WithSelectRelabel("enter", "choose"), + WithSelectRelabel("up-down", "navigate"), + }) + if got := len(cfg.RelabelByID); got != 2 { + t.Fatalf("len(RelabelByID) = %d, want 2", got) + } + if got := cfg.RelabelByID["up-down"]; got != "navigate" { + t.Errorf("RelabelByID[up-down] = %q, want navigate (later relabel wins)", got) + } + if got := cfg.RelabelByID["enter"]; got != "choose" { + t.Errorf("RelabelByID[enter] = %q, want choose", got) + } +} + +func TestHide_AccumulatesAcrossCalls(t *testing.T) { + cfg := ResolveSelectConfig([]SelectOption{ + WithSelectHide("filter"), + WithSelectHide("esc", "ctrl+c"), + }) + want := []string{"filter", "esc", "ctrl+c"} + if len(cfg.HiddenByID) != len(want) { + t.Fatalf("HiddenByID = %v, want %v", cfg.HiddenByID, want) + } + for i, id := range want { + if cfg.HiddenByID[i] != id { + t.Errorf("HiddenByID[%d] = %q, want %q", i, cfg.HiddenByID[i], id) + } + } +} + +// The CLI maps cancel to a clean exit and everything else to a failure. If +// ErrNoTerminal were ever confused with a cancel sentinel, a piped/redirected +// invocation would exit 0 having silently done nothing. +func TestErrorSentinels_StayDistinct(t *testing.T) { + if errors.Is(ErrNoTerminal, ErrInterrupted) { + t.Error("ErrNoTerminal matches ErrInterrupted; a non-TTY failure would look like a user cancel") + } + if errors.Is(ErrNoTerminal, context.Canceled) { + t.Error("ErrNoTerminal matches context.Canceled; a non-TTY failure would look like Esc") + } + if errors.Is(ErrInterrupted, context.Canceled) { + t.Error("ErrInterrupted matches context.Canceled; Ctrl+C and Esc must stay distinguishable") + } +} + +// nil fields are the documented handoff to the backend ("nil means os.Stdin at +// runtime"), despite the function name suggesting concrete streams. Pinned so a +// well-meaning change to real os.* handles doesn't silently bypass the +// IOStreams a command passed in. +func TestDefaultIO_LeavesStreamsNilForBackend(t *testing.T) { + io := DefaultIO() + if io.In != nil || io.Out != nil || io.ErrOut != nil { + t.Errorf("DefaultIO() = %+v, want all-nil so the backend supplies streams", io) + } +} + +func TestDefault_PanicsWithoutRegisteredBackend(t *testing.T) { + mu.Lock() + saved := builder + builder = nil + mu.Unlock() + t.Cleanup(func() { + mu.Lock() + builder = saved + mu.Unlock() + }) + + defer func() { + if recover() == nil { + t.Error("Default() did not panic with no backend registered") + } + }() + _ = Default() +} + +func TestRegisterBuilder_DefaultUsesRegisteredFactory(t *testing.T) { + mu.Lock() + saved := builder + mu.Unlock() + t.Cleanup(func() { + mu.Lock() + builder = saved + mu.Unlock() + }) + + sentinel := &stubPrompter{} + RegisterBuilder(func(_ ...func(*IO)) Prompter { return sentinel }) + + if got := Default(); got != sentinel { + t.Errorf("Default() = %v, want the registered builder's Prompter", got) + } +} + +type stubPrompter struct{ Prompter } diff --git a/pkg/tui/wizard/bus.go b/pkg/tui/wizard/bus.go index 45ceb7b..24c35ac 100644 --- a/pkg/tui/wizard/bus.go +++ b/pkg/tui/wizard/bus.go @@ -14,7 +14,10 @@ package wizard -import "reflect" +import ( + "reflect" + "sync" +) type viewSlot struct { id string @@ -25,7 +28,12 @@ type viewSlot struct { } // MessageBus routes messages between the engine and views. +// +// It is safe for concurrent use: the engine's stepLoop goroutine writes +// (Broadcast / store-change updates) while the composite tea program reads +// (RenderAll) once per frame on its own goroutine. type MessageBus struct { + mu sync.RWMutex slots []viewSlot } @@ -34,22 +42,27 @@ func NewMessageBus() *MessageBus { return &MessageBus{} } -// Register adds a view to the bus. +// Register adds a view to the bus. Not safe to call concurrently with +// Broadcast/Publish/RenderAll — register all views before Run starts. func (b *MessageBus) Register(id string, v View) { subs := make(map[reflect.Type]bool) for _, t := range v.Subscribe() { subs[t] = true } + b.mu.Lock() b.slots = append(b.slots, viewSlot{ id: id, view: v, subs: subs, }) + b.mu.Unlock() } // Broadcast sends a message to ALL views (engine-level events). // Processes any published messages from views (chained delivery). func (b *MessageBus) Broadcast(msg any) { + b.mu.Lock() + defer b.mu.Unlock() var pending []any for i := range b.slots { render, published := b.slots[i].view.Update(msg) @@ -62,6 +75,8 @@ func (b *MessageBus) Broadcast(msg any) { // Publish sends messages only to views that subscribed to those types. // Processes any published messages from views (chained delivery). func (b *MessageBus) Publish(from string, msgs []any) { + b.mu.Lock() + defer b.mu.Unlock() var pending []any for _, msg := range msgs { msgType := reflect.TypeOf(msg) @@ -80,6 +95,7 @@ func (b *MessageBus) Publish(from string, msgs []any) { } // deliverPending processes chained messages (published by views during Update). +// Callers must hold b.mu. func (b *MessageBus) deliverPending(msgs []any) { for len(msgs) > 0 { var next []any @@ -99,16 +115,19 @@ func (b *MessageBus) deliverPending(msgs []any) { // RenderAll returns the last rendered output of each view in order. func (b *MessageBus) RenderAll() []string { + b.mu.RLock() renders := make([]string, len(b.slots)) for i, s := range b.slots { renders[i] = s.last } + b.mu.RUnlock() return renders } // RenderChanged returns outputs only for views whose render changed // since the last call to RenderChanged. Unchanged views return "". func (b *MessageBus) RenderChanged() []string { + b.mu.Lock() renders := make([]string, len(b.slots)) for i := range b.slots { if b.slots[i].last != b.slots[i].printed { @@ -116,5 +135,6 @@ func (b *MessageBus) RenderChanged() []string { b.slots[i].printed = b.slots[i].last } } + b.mu.Unlock() return renders } diff --git a/pkg/tui/wizard/cancel_test.go b/pkg/tui/wizard/cancel_test.go new file mode 100644 index 0000000..bb1f20f --- /dev/null +++ b/pkg/tui/wizard/cancel_test.go @@ -0,0 +1,72 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package wizard + +import ( + "context" + "errors" + "testing" + + "github.com/verda-cloud/verda-cli/pkg/tui" +) + +// Regression: a bare errors.New sentinel wraps nothing, so the CLI's cancel +// predicates (which key on tui.ErrInterrupted / context.Canceled) classified a +// clean wizard abort as a real failure — stderr noise and exit 1 on Ctrl+C. +// Every value Run returns for a user abort must carry both the umbrella +// sentinel and its low-level cause. +func TestCancelSentinels_CarryUmbrellaAndCause(t *testing.T) { + tests := []struct { + name string + err error + wantCause error + notCause error + }{ + { + name: "Ctrl+C is a hard interrupt", + err: errCancelledInterrupt, + wantCause: tui.ErrInterrupted, + notCause: context.Canceled, + }, + { + name: "Esc with nowhere back is a soft cancel", + err: errCancelledBack, + wantCause: context.Canceled, + notCause: tui.ErrInterrupted, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !errors.Is(tt.err, ErrCancelled) { + t.Errorf("errors.Is(%v, ErrCancelled) = false; callers matching the umbrella sentinel break", tt.err) + } + if !errors.Is(tt.err, tt.wantCause) { + t.Errorf("errors.Is(%v, %v) = false; cancel predicates would treat a clean abort as a failure", tt.err, tt.wantCause) + } + if errors.Is(tt.err, tt.notCause) { + t.Errorf("errors.Is(%v, %v) = true; Esc and Ctrl+C must stay distinguishable", tt.err, tt.notCause) + } + }) + } +} + +// The bare sentinel is an errors.Is target only. If it ever gains a cause it +// would make both abort kinds indistinguishable through the umbrella. +func TestErrCancelled_IsBareTarget(t *testing.T) { + if errors.Unwrap(ErrCancelled) != nil { + t.Error("ErrCancelled wraps something; it must stay a bare matching target") + } +} diff --git a/pkg/tui/wizard/doc.go b/pkg/tui/wizard/doc.go index df18f2b..5e0ba04 100644 --- a/pkg/tui/wizard/doc.go +++ b/pkg/tui/wizard/doc.go @@ -392,10 +392,6 @@ // Messages chain: if a view publishes messages in response to a received // message, those are delivered to their subscribers in the same cycle. // -// See the wizard-views example for a complete working demonstration: -// -// go run ./pkg/tui/examples/wizard-views -// // # Pager // // For displaying long content (lists, logs, details), use [tui.Status.Pager]: diff --git a/pkg/tui/wizard/engine.go b/pkg/tui/wizard/engine.go index 4656590..56c3cc4 100644 --- a/pkg/tui/wizard/engine.go +++ b/pkg/tui/wizard/engine.go @@ -16,6 +16,7 @@ package wizard import ( "context" + "errors" "fmt" "io" "os" @@ -28,6 +29,24 @@ import ( "github.com/verda-cloud/verda-cli/pkg/tui/bubbletea" ) +// ErrCancelled matches any user abort of the wizard (Ctrl+C, or Esc with no +// step to go back to). Callers use errors.Is to map it to a clean exit while +// propagating real engine/loader failures. +// +// It is never returned bare: Run returns one of the two values below, each +// wrapping both ErrCancelled and the low-level cause the CLI's cancel +// predicates key on. Keeping the cause attached is what lets Esc and Ctrl+C +// stay distinguishable (cmdutil.IsPromptBack vs IsPromptInterrupt) while +// IsPromptCancel matches either. +var ErrCancelled = errors.New("wizard cancelled") + +var ( + // errCancelledInterrupt: Ctrl+C — a deliberate hard exit. + errCancelledInterrupt = fmt.Errorf("%w: %w", ErrCancelled, tui.ErrInterrupted) + // errCancelledBack: Esc pressed with no earlier step to return to. + errCancelledBack = fmt.Errorf("%w: %w", ErrCancelled, context.Canceled) +) + // stepState represents the lifecycle state of a step during execution. type stepState int @@ -67,14 +86,13 @@ type Engine struct { writer io.Writer reader io.Reader keyBindings []KeyBinding - exitConfirm bool // when true, Ctrl+C prompts "Exit wizard?" before exiting resultOverride chan promptResult // test-only: bypasses composite model program *tea.Program // the running composite program (nil in test mode) resultCh chan promptResult // channel for receiving prompt results - // interruptCancel lets a second Ctrl+C on the "Exit wizard?" confirm - // abort any in-flight loader ctx, not just the wizard itself. - interruptCancel context.CancelFunc + // validationMsg is set when a step fails Validate; printed above the + // re-drawn prompt so a rejected answer isn't a silent redraw. + validationMsg string } // EngineOption configures the Engine. @@ -95,12 +113,6 @@ func WithInput(r io.Reader) EngineOption { return func(e *Engine) { e.reader = r } } -// WithExitConfirmation enables a "Exit wizard?" confirmation prompt when the -// user presses Ctrl+C. Without this option, Ctrl+C exits immediately. -func WithExitConfirmation() EngineOption { - return func(e *Engine) { e.exitConfirm = true } -} - // TestResult represents a prompt result for testing. type TestResult struct { Value any @@ -208,7 +220,6 @@ func (e *Engine) Run(ctx context.Context, flow *Flow) error { sigCtx, sigCancel := context.WithCancel(ctx) defer sigCancel() ctx = sigCtx - e.interruptCancel = sigCancel sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt) @@ -347,7 +358,6 @@ func (e *Engine) stepLoop(ctx context.Context) error { } // Wait for result from composite and process it. - // handlePromptResult may call confirmExit which reuses the program. result := <-e.resultCh done, err := e.handlePromptResult(result, step, choices, canGoBack) @@ -360,6 +370,11 @@ func (e *Engine) stepLoop(ctx context.Context) error { } if done { e.current++ + } else if e.validationMsg != "" { + // Printed after the prompt program stops so the line lands above + // the re-drawn prompt instead of interleaving with its renderer. + _, _ = fmt.Fprintf(e.out(), " ✗ %s\n", e.validationMsg) + e.validationMsg = "" } } return nil @@ -407,21 +422,17 @@ func (e *Engine) stopProgram(done chan struct{}) { // Returns (false, nil) when the engine should re-prompt or rewind (current adjusted internally). // Returns (false, err) on fatal error. func (e *Engine) handlePromptResult(result promptResult, step Step, choices []Choice, canGoBack bool) (bool, error) { + e.validationMsg = "" switch result.action { case ActionExit: - if e.exitConfirm && e.program != nil { - if stayed := e.confirmExit(); stayed { - return false, nil // re-prompt current step - } - } _, _ = fmt.Fprintln(e.out()) - return false, fmt.Errorf("wizard cancelled") + return false, errCancelledInterrupt case ActionBack: if canGoBack { e.rewindOne() } else { _, _ = fmt.Fprintln(e.out()) - return false, fmt.Errorf("wizard cancelled") + return false, errCancelledBack } return false, nil } @@ -463,9 +474,13 @@ func (e *Engine) handlePromptResult(result promptResult, step Step, choices []Ch return false, nil // re-prompt } - // Validate. + // Validate. On failure the step re-prompts; the error is printed above the + // re-drawn prompt instead of being silently dropped. TextInput steps + // usually never get here — the prompt model validates inline and blocks + // submission (see buildPromptModel). if step.Validate != nil { if err := step.Validate(value); err != nil { + e.validationMsg = err.Error() return false, nil // re-prompt } } @@ -548,6 +563,17 @@ func (e *Engine) buildPromptModel(step Step, choices []Choice, canGoBack bool) b opts = append(opts, tui.WithDefault(d)) } } + if step.Validate != nil { + validate := step.Validate + opts = append(opts, tui.WithValidation(func(s string) error { + // The engine substitutes Default for empty input on optional + // steps; validating "" here would wrongly reject that path. + if !step.Required && s == "" { + return nil + } + return validate(s) + })) + } cfg := tui.ResolveTextInputConfig(opts) return bubbletea.NewTextInputPrompt(promptLabel(step), cfg) case ConfirmPrompt: @@ -641,7 +667,13 @@ func (e *Engine) transition(idx int, newState stepState, value any) { rt.value = value rt.choices = nil // invalidate cached choices rt.loaded = false - rt.rewindCount = 0 // reset guard so revisits get fresh attempts + // Only forward progress clears the auto-rewind guard. Resetting it on + // every transition was self-defeating: the rewind that follows an empty + // loader resets the very step that counted the attempt, so the guard + // never fired and a failing loader bounced the user back forever. + if newState == stateCompleted { + rt.rewindCount = 0 + } // Keep store in sync. if value != nil { @@ -799,41 +831,6 @@ func (e *Engine) invalidateDownstream(changedIdx int) { } } -// --- Exit confirmation --- - -// confirmExit swaps the active prompt with a "Exit wizard?" confirm prompt. -// Returns true if the user chose to stay (declined or pressed Esc). -// -// A second Ctrl+C on the confirm force-exits rather than bouncing back -// into the flow — the user has already asked to leave once, and double -// Ctrl+C is the universal escape hatch. -func (e *Engine) confirmExit() (stayed bool) { - cfg := tui.ResolveConfirmConfig([]tui.ConfirmOption{tui.WithConfirmDefault(true)}) - confirmModel := bubbletea.NewConfirmPrompt("Exit wizard?", cfg) - - e.program.Send(showPromptMsg{ - model: confirmModel, - stepMsg: StepChangedMsg{PromptType: ConfirmPrompt}, - }) - - result := <-e.resultCh - switch result.action { - case ActionExit: - // Second Ctrl+C — also cancel any in-flight loader ctx so no - // cleanup work sneaks past the exit. - if e.interruptCancel != nil { - e.interruptCancel() - } - return false - case ActionBack: - return true - } - if confirmed, ok := result.value.(bool); ok && confirmed { - return false - } - return true -} - // --- Utilities --- // promptLabel returns the display text for a step, falling back to Name if Description is empty. diff --git a/pkg/tui/wizard/engine_test.go b/pkg/tui/wizard/engine_test.go index cca1572..2b11a64 100644 --- a/pkg/tui/wizard/engine_test.go +++ b/pkg/tui/wizard/engine_test.go @@ -16,6 +16,7 @@ package wizard import ( "context" + "errors" "fmt" "io" "strings" @@ -353,6 +354,66 @@ func TestEngine_EmptyRequired_AtFirstStep_ReturnsError(t *testing.T) { } } +// Regression: transition() used to zero rewindCount on every reset, so the +// auto-rewind guard never fired — a step whose loader always returns empty +// choices bounced the user back to the prior step forever (only Ctrl+C +// escaped). The guard must trip after maxRewindsPerStep attempts whether the +// step rewinds via a declared DependsOn or the rewindOne fallback. +func TestEngine_EmptyChoices_RewindGuardTerminates(t *testing.T) { + for _, tc := range []struct { + name string + dependsOn []string + }{ + {name: "declared dependency", dependsOn: []string{"region"}}, + {name: "no dependency (rewindOne fallback)"}, + } { + t.Run(tc.name, func(t *testing.T) { + loaderCalls := 0 + flow := &Flow{ + Name: "test", + Steps: []Step{ + { + Name: "region", + Prompt: SelectPrompt, + Required: true, + Loader: StaticChoices(Choice{Label: "Finland", Value: "FIN-01"}), + Setter: func(v any) {}, + }, + { + Name: "gpu", + Prompt: SelectPrompt, + Required: true, + DependsOn: tc.dependsOn, + Loader: func(_ context.Context, _ tui.Prompter, _ tui.Status, _ *Store) ([]Choice, error) { + loaderCalls++ + if loaderCalls > maxRewindsPerStep+2 { + // Breaks the loop if the guard regresses, so the + // test fails on the error instead of hanging. + return nil, fmt.Errorf("loader called %d times: rewind guard did not trip", loaderCalls) + } + return []Choice{}, nil + }, + Setter: func(v any) {}, + }, + }, + } + + // One selectResult per re-ask of the region step; the guard must + // trip before the loader safety break kicks in. + engine := newTestEngine([]promptResult{ + selectResult(0), selectResult(0), selectResult(0), selectResult(0), selectResult(0), + }, WithOutput(io.Discard)) + err := engine.Run(context.Background(), flow) + if err == nil || !strings.Contains(err.Error(), "no options available after") { + t.Fatalf("expected rewind guard error, got %v", err) + } + if loaderCalls != maxRewindsPerStep+1 { + t.Errorf("expected %d loader calls before guard trips, got %d", maxRewindsPerStep+1, loaderCalls) + } + }) + } +} + func TestEngine_ValidationError_RepromptsUntilValid(t *testing.T) { var size string @@ -670,8 +731,13 @@ func TestEngine_EscOnFirstStep_Cancels(t *testing.T) { engine := newTestEngine([]promptResult{backResult()}) err := engine.Run(context.Background(), flow) - if err == nil || !strings.Contains(err.Error(), "wizard cancelled") { - t.Fatalf("expected 'wizard cancelled', got %v", err) + // Esc on the first step must classify as a soft cancel (context.Canceled) + // under the ErrCancelled umbrella — cmdutil.IsPromptBack/Cancel key on it. + if !errors.Is(err, ErrCancelled) || !errors.Is(err, context.Canceled) { + t.Fatalf("expected Esc cancel (ErrCancelled wrapping context.Canceled), got %v", err) + } + if errors.Is(err, tui.ErrInterrupted) { + t.Fatal("Esc cancel must not classify as Ctrl+C (tui.ErrInterrupted)") } } @@ -691,8 +757,13 @@ func TestEngine_CtrlC_Exits(t *testing.T) { engine := newTestEngine([]promptResult{exitResult()}) err := engine.Run(context.Background(), flow) - if err == nil || !strings.Contains(err.Error(), "wizard cancelled") { - t.Fatalf("expected 'wizard cancelled', got %v", err) + // Ctrl+C must classify as a hard interrupt (tui.ErrInterrupted) under the + // ErrCancelled umbrella — cmdutil.IsPromptInterrupt/Cancel key on it. + if !errors.Is(err, ErrCancelled) || !errors.Is(err, tui.ErrInterrupted) { + t.Fatalf("expected Ctrl+C cancel (ErrCancelled wrapping tui.ErrInterrupted), got %v", err) + } + if errors.Is(err, context.Canceled) { + t.Fatal("Ctrl+C cancel must not classify as soft back (context.Canceled)") } } diff --git a/pkg/tui/wizard/integration_test.go b/pkg/tui/wizard/integration_test.go index 000309e..5b0daf6 100644 --- a/pkg/tui/wizard/integration_test.go +++ b/pkg/tui/wizard/integration_test.go @@ -17,11 +17,12 @@ package wizard import ( "bytes" "context" + "errors" "io" - "strings" "testing" "time" + "github.com/verda-cloud/verda-cli/pkg/tui" tuitesting "github.com/verda-cloud/verda-cli/pkg/tui/testing" ) @@ -144,8 +145,8 @@ func TestIntegration_CtrlC_Exits(t *testing.T) { defer cancel() err := engine.Run(ctx, flow) - if err == nil || !strings.Contains(err.Error(), "wizard cancelled") { - t.Fatalf("expected 'wizard cancelled', got %v", err) + if !errors.Is(err, ErrCancelled) || !errors.Is(err, tui.ErrInterrupted) { + t.Fatalf("expected Ctrl+C cancel (ErrCancelled wrapping tui.ErrInterrupted), got %v", err) } } @@ -183,69 +184,3 @@ func TestIntegration_ArrowDownAndSelect(t *testing.T) { t.Errorf("expected env 'prod', got %q", env) } } - -// TestIntegration_CtrlC_ConfirmExit_SingleY exercises the -// WithExitConfirmation() happy path: Ctrl+C shows "Exit wizard?", user types -// 'y', wizard exits cleanly. -func TestIntegration_CtrlC_ConfirmExit_SingleY(t *testing.T) { - flow := &Flow{ - Name: "test", - Steps: []Step{ - { - Name: "env", - Prompt: SelectPrompt, - Required: true, - Loader: StaticChoices(Choice{Label: "Dev", Value: "dev"}), - Setter: func(v any) {}, - }, - }, - } - - input := keySequence(keyCtrlC, "y", keyEnter) - engine := NewEngine(tuitesting.New(), nil, - WithInput(input), - WithOutput(io.Discard), - WithExitConfirmation(), - ) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - err := engine.Run(ctx, flow) - if err == nil || !strings.Contains(err.Error(), "wizard cancelled") { - t.Fatalf("expected 'wizard cancelled', got %v", err) - } -} - -// TestIntegration_CtrlC_ConfirmExit_DoubleCtrlC verifies the -// "double-tap Ctrl+C force exits" behavior: hitting Ctrl+C twice must get -// the user out of the wizard even with WithExitConfirmation() enabled. -func TestIntegration_CtrlC_ConfirmExit_DoubleCtrlC(t *testing.T) { - flow := &Flow{ - Name: "test", - Steps: []Step{ - { - Name: "env", - Prompt: SelectPrompt, - Required: true, - Loader: StaticChoices(Choice{Label: "Dev", Value: "dev"}), - Setter: func(v any) {}, - }, - }, - } - - input := keySequence(keyCtrlC, keyCtrlC) - engine := NewEngine(tuitesting.New(), nil, - WithInput(input), - WithOutput(io.Discard), - WithExitConfirmation(), - ) - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - err := engine.Run(ctx, flow) - if err == nil || !strings.Contains(err.Error(), "wizard cancelled") { - t.Fatalf("expected 'wizard cancelled' on double Ctrl+C, got %v", err) - } -} diff --git a/pkg/version/flag.go b/pkg/version/flag.go index 041bbd3..91e1d18 100644 --- a/pkg/version/flag.go +++ b/pkg/version/flag.go @@ -65,15 +65,22 @@ func (v *versionValue) String() string { func (v *versionValue) Type() string { return "version" } +// versionFlags holds the --version definition. Deliberately NOT +// pflag.CommandLine: cobra's updateParentsPflags merges that process-global +// set into every executed command tree (AddFlagSet → VisitAll), and its lazy +// sort cache is written on first read — so any two parallel test executions +// race on it. A private set has no hidden concurrent readers. +var versionFlags = pflag.NewFlagSet("version", pflag.ContinueOnError) + func init() { - pflag.CommandLine.Var(&versionFlag, versionFlagName, `Print version information and quit. + versionFlags.Var(&versionFlag, versionFlagName, `Print version information and quit. Accepts "true", "false", or "raw" for full details.`) - pflag.CommandLine.Lookup(versionFlagName).NoOptDefVal = "true" + versionFlags.Lookup(versionFlagName).NoOptDefVal = "true" } // AddFlags adds the --version flag to the given FlagSet. func AddFlags(fs *pflag.FlagSet) { - if f := pflag.CommandLine.Lookup(versionFlagName); f != nil { + if f := versionFlags.Lookup(versionFlagName); f != nil { fs.AddFlag(f) } } diff --git a/scripts/install.sh b/scripts/install.sh index e5b3127..df08733 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -3,14 +3,17 @@ # Usage: curl -sSL https://raw.githubusercontent.com/verda-cloud/verda-cli/main/scripts/install.sh | sh # # Environment variables: -# VERDA_INSTALL_DIR - Installation directory (default: ~/.verda/bin) -# VERDA_VERSION - Specific version to install (default: latest) +# VERDA_INSTALL_DIR - Installation directory (default: ~/.verda/bin) +# VERDA_VERSION - Specific version to install (default: latest) +# VERDA_INSTALL_SKIP_VERIFY - Set to 1 to skip archive checksum verification (NOT recommended) +# VERDA_INSTALL_BASE_URL - Override release asset base URL (testing only) set -e REPO="verda-cloud/verda-cli" BINARY="verda" INSTALL_DIR="${VERDA_INSTALL_DIR:-$HOME/.verda/bin}" +BASE_URL="${VERDA_INSTALL_BASE_URL:-https://github.com/${REPO}/releases/download}" # Detect OS OS="$(uname -s)" @@ -53,7 +56,9 @@ if [ "$OS" = "windows" ]; then fi FILENAME="${BINARY}_${VERSION_NUM}_${OS}_${ARCH}.${EXT}" -URL="https://github.com/${REPO}/releases/download/${VERDA_VERSION}/${FILENAME}" +URL="${BASE_URL}/${VERDA_VERSION}/${FILENAME}" +SUMS_FILENAME="${BINARY}_${VERSION_NUM}_SHA256SUMS" +SUMS_URL="${BASE_URL}/${VERDA_VERSION}/${SUMS_FILENAME}" echo "Installing Verda CLI ${VERDA_VERSION} (${OS}/${ARCH})..." echo " From: ${URL}" @@ -63,6 +68,13 @@ echo " To: ${INSTALL_DIR}/${BINARY}" TMP_DIR=$(mktemp -d) trap 'rm -rf "$TMP_DIR"' EXIT +fail_verify() { + echo "Error: $1" + echo "Installation aborted; nothing was installed." + echo "To bypass checksum verification (NOT recommended), re-run with VERDA_INSTALL_SKIP_VERIFY=1" + exit 1 +} + # Download echo "Downloading..." if command -v curl >/dev/null 2>&1; then @@ -74,6 +86,32 @@ else exit 1 fi +# Verify the archive against the release's SHA256SUMS (fail closed). +if [ "${VERDA_INSTALL_SKIP_VERIFY:-}" != "1" ]; then + echo "Verifying checksum..." + if command -v curl >/dev/null 2>&1; then + curl -sSfL "$SUMS_URL" -o "${TMP_DIR}/${SUMS_FILENAME}" || fail_verify "could not download checksum file from ${SUMS_URL}" + else + wget -q "$SUMS_URL" -O "${TMP_DIR}/${SUMS_FILENAME}" || fail_verify "could not download checksum file from ${SUMS_URL}" + fi + + # The sums file lists every platform asset; check only this archive's line. + awk -v name="$FILENAME" '$2 == name' "${TMP_DIR}/${SUMS_FILENAME}" > "${TMP_DIR}/CHECKSUM" + if [ ! -s "${TMP_DIR}/CHECKSUM" ]; then + fail_verify "no checksum entry for ${FILENAME} in ${SUMS_FILENAME}" + fi + + cd "$TMP_DIR" + if command -v sha256sum >/dev/null 2>&1; then + sha256sum -c CHECKSUM > /dev/null || fail_verify "checksum mismatch for ${FILENAME}" + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 -c CHECKSUM > /dev/null || fail_verify "checksum mismatch for ${FILENAME}" + else + fail_verify "no SHA-256 checksum tool available (need sha256sum or shasum)" + fi + echo " Checksum OK." +fi + # Extract echo "Extracting..." cd "$TMP_DIR" diff --git a/tests/contract/agent_mode_test.go b/tests/contract/agent_mode_test.go new file mode 100644 index 0000000..582ee52 --- /dev/null +++ b/tests/contract/agent_mode_test.go @@ -0,0 +1,186 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package contract + +import ( + "testing" + "time" + + "github.com/verda-cloud/verda-cli/tests/contract/mockapi" +) + +// TestAgentVMListJSONPurity: in --agent mode stdout is machine-consumable +// JSON only — no spinner frames, no hint text, no ANSI — and stderr silent. +func TestAgentVMListJSONPurity(t *testing.T) { + t.Parallel() + srv := newServer(t) + srv.SeedInstance("contract-alpha", mockapi.TypeCPU, mockapi.CPUOnDemandTotal) + srv.SeedInstance("contract-beta", mockapi.TypeGPU1, mockapi.GPU1OnDemandTotal) + + r := runCLI(t, srv, "--agent", "vm", "list", "-o", "json") + requireExit(t, r, 0) + if r.Stderr != "" { + t.Fatalf("stderr not empty in agent mode: %q", r.Stderr) + } + + var instances []map[string]any + requireCleanJSON(t, r, &instances) + if len(instances) != 2 { + t.Fatalf("got %d instances, want 2:\n%s", len(instances), r.Stdout) + } +} + +// TestAgentPromptBlocked: an interactive prompt in agent mode must fail fast +// with INTERACTIVE_PROMPT_BLOCKED, never block on stdin (stdin is /dev/null; +// if the safety net regresses this hangs and hits the command timeout). +func TestAgentPromptBlocked(t *testing.T) { + t.Parallel() + srv := newServer(t) + + r := runCLI(t, srv, "--agent", "settings", "theme") + requireExit(t, r, 2) + if r.Stdout != "" { + t.Fatalf("stdout not empty on prompt-blocked path: %q", r.Stdout) + } + + env := parseAgentError(t, r) + if env.Error.Code != "INTERACTIVE_PROMPT_BLOCKED" { + t.Fatalf("code = %q, want INTERACTIVE_PROMPT_BLOCKED\nstderr: %s", env.Error.Code, r.Stderr) + } + if got := env.Error.Details["prompt_type"]; got != "select" { + t.Fatalf("details.prompt_type = %v, want select", got) + } + choices, ok := env.Error.Details["choices"].([]any) + if !ok || len(choices) == 0 { + t.Fatalf("details.choices = %v — agents need the option list to pick a flag value", env.Error.Details["choices"]) + } + if r.Duration >= 5*time.Second { + t.Fatalf("prompt-blocked path took %s — regression toward blocking on stdin", r.Duration) + } +} + +// TestAgentVolumeDeleteGate: destructive actions in agent mode require --yes +// (CONFIRMATION_REQUIRED, exit 2) and must not touch state; with --yes the +// delete executes and reports a structured result. +func TestAgentVolumeDeleteGate(t *testing.T) { + t.Parallel() + srv := newServer(t) + vol := srv.SeedVolume("contract-vol", 100) + + r := runCLI(t, srv, "--agent", "volume", "delete", "--id", vol.ID) + requireExit(t, r, 2) + env := parseAgentError(t, r) + if env.Error.Code != "CONFIRMATION_REQUIRED" { + t.Fatalf("code = %q, want CONFIRMATION_REQUIRED\nstderr: %s", env.Error.Code, r.Stderr) + } + if got := env.Error.Details["action"]; got != "delete" { + t.Fatalf("details.action = %v, want delete", got) + } + if !srv.HasVolume(vol.ID) { + t.Fatal("volume deleted despite missing --yes; mock count:", srv.VolumeCount()) + } + + r2 := runCLI(t, srv, "--agent", "volume", "delete", "--id", vol.ID, "--yes") + requireExit(t, r2, 0) + var result map[string]string + requireCleanJSON(t, r2, &result) + if result["action"] != "delete" || result["status"] != "completed" || result["id"] != vol.ID { + t.Fatalf("unexpected delete result: %v", result) + } + if srv.HasVolume(vol.ID) { + t.Fatal("volume still present after --yes delete") + } +} + +// TestAgentVMCreateReturnsAfterIssuance: --agent vm create must not block on +// the default --wait (the flag default is locked in before --agent is parsed). +// The mock flips instances to running on first read, so a poll would be fast — +// the real assertion is wire-level: zero GET /instances/{id} unless --wait was +// passed explicitly. +func TestAgentVMCreateReturnsAfterIssuance(t *testing.T) { + t.Parallel() + srv := newServer(t) + + r := runCLI(t, srv, "--agent", "vm", "create", + "--kind", "cpu", + "--instance-type", mockapi.TypeCPU, + "--os", "ubuntu-24.04", + "--hostname", "contract-nowait", + ) + requireExit(t, r, 0) + var inst struct { + ID string `json:"id"` + } + requireCleanJSON(t, r, &inst) + if inst.ID == "" { + t.Fatalf("create returned empty instance id:\n%s", r.Stdout) + } + if n := srv.InstanceGetCount(); n != 0 { + t.Fatalf("default --wait polled instance status %d times in agent mode; want 0 (agents poll via vm describe)", n) + } + if r.Duration >= 5*time.Second { + t.Fatalf("issuance-only create took %s — regression toward blocking", r.Duration) + } + + r2 := runCLI(t, srv, "--agent", "vm", "create", + "--kind", "cpu", + "--instance-type", mockapi.TypeCPU, + "--os", "ubuntu-24.04", + "--hostname", "contract-wait", + "--wait", + ) + requireExit(t, r2, 0) + if n := srv.InstanceGetCount(); n == 0 { + t.Fatal("explicit --wait in agent mode did not poll instance status") + } +} + +// TestAgentErrorClassification: HTTP status codes map to the documented +// error codes and exit codes (docs/agent-errors.md). +func TestAgentErrorClassification(t *testing.T) { + t.Parallel() + + t.Run("401 maps to AUTH_ERROR exit 3", func(t *testing.T) { + t.Parallel() + srv := newServer(t) + srv.FailRoute("/instances", 401) + + r := runCLI(t, srv, "--agent", "vm", "list") + requireExit(t, r, 3) + env := parseAgentError(t, r) + if env.Error.Code != "AUTH_ERROR" { + t.Fatalf("code = %q, want AUTH_ERROR\nstderr: %s", env.Error.Code, r.Stderr) + } + if got := env.Error.Details["status"]; got != float64(401) { + t.Fatalf("details.status = %v, want 401", got) + } + }) + + t.Run("500 maps to API_ERROR exit 4", func(t *testing.T) { + t.Parallel() + srv := newServer(t) + srv.FailRoute("/instances", 500) + + r := runCLI(t, srv, "--agent", "vm", "list") + requireExit(t, r, 4) + env := parseAgentError(t, r) + if env.Error.Code != "API_ERROR" { + t.Fatalf("code = %q, want API_ERROR\nstderr: %s", env.Error.Code, r.Stderr) + } + if got := env.Error.Details["status"]; got != float64(500) { + t.Fatalf("details.status = %v, want 500", got) + } + }) +} diff --git a/tests/contract/debug_test.go b/tests/contract/debug_test.go new file mode 100644 index 0000000..a9c19ba --- /dev/null +++ b/tests/contract/debug_test.go @@ -0,0 +1,64 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package contract + +import ( + "strings" + "testing" +) + +// TestDebugRedaction: --debug dumps every request/response body to stderr, so +// the OAuth token exchange must never leak credential values (review H1). +func TestDebugRedaction(t *testing.T) { + t.Parallel() + + assertNoSecrets := func(t *testing.T, r cliResult) { + t.Helper() + if strings.Contains(r.Stderr, testClientSecret) { + t.Fatalf("stderr leaks client_secret value:\n%s", r.Stderr) + } + if strings.Contains(r.Stderr, "mock-access-token") { + t.Fatalf("stderr leaks issued access token:\n%s", r.Stderr) + } + if !strings.Contains(r.Stderr, "DEBUG:") { + t.Fatalf("expected debug output on stderr, got none:\nstdout: %s\nstderr: %s", r.Stdout, r.Stderr) + } + } + + // JSON token request: redactSensitiveJSON handles "client_secret": "...". + t.Run("json token request", func(t *testing.T) { + t.Parallel() + srv := newServer(t) + r := runCLI(t, srv, "--debug", "-o", "json", "locations") + requireExit(t, r, 0) + assertNoSecrets(t, r) + }) + + // The SDK retries the token request form-encoded when the API answers the + // JSON attempt with 400. Review H1: that body (incl. client_secret=...) + // must be redacted too, and the redacted marker proves the redactor ran + // (not merely that the body vanished). + t.Run("form-encoded token fallback", func(t *testing.T) { + t.Parallel() + srv := newServer(t) + srv.ForceFormTokenFallback(true) + r := runCLI(t, srv, "--debug", "-o", "json", "locations") + requireExit(t, r, 0) + assertNoSecrets(t, r) + if !strings.Contains(r.Stderr, "client_secret=") { + t.Fatalf("expected form-body redaction marker in stderr, got none:\n%s", r.Stderr) + } + }) +} diff --git a/tests/contract/main_test.go b/tests/contract/main_test.go new file mode 100644 index 0000000..f10b1c1 --- /dev/null +++ b/tests/contract/main_test.go @@ -0,0 +1,231 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package contract contains hermetic black-box contract tests: they drive the +// real verda binary against an in-process mock Verda API (see mockapi) and +// assert the machine-facing guarantees documented in docs/agent-errors.md +// (stdout/stderr separation, structured errors, exit codes, --yes gates). +package contract + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/verda-cloud/verda-cli/tests/contract/mockapi" +) + +// verdaBin is the path to the binary built once by TestMain. Empty when the +// suite runs under -short (tests skip). +var verdaBin string + +// testClientSecret must be unique enough that finding it in output is never +// incidental — debug redaction tests grep stderr for it. +const ( + testClientID = "contract-test-client-id" + testClientSecret = "contract-test-secret-DO-NOT-LEAK" +) + +const cliTimeout = 30 * time.Second + +func TestMain(m *testing.M) { + flag.Parse() + if testing.Short() { + os.Exit(m.Run()) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + root, err := repoRoot(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "contract: locate repo root: %v\n", err) + os.Exit(1) + } + dir, err := os.MkdirTemp("", "verda-contract-bin-") + if err != nil { + fmt.Fprintf(os.Stderr, "contract: mktemp: %v\n", err) + os.Exit(1) + } + defer func() { _ = os.RemoveAll(dir) }() + + verdaBin = filepath.Join(dir, "verda") + build := exec.CommandContext(ctx, "go", "build", "-C", root, "-o", verdaBin, "./cmd/verda/") // #nosec G204 -- args are fixed literals; output path is a t.TempDir-managed dir + if out, err := build.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "contract: go build ./cmd/verda: %v\n%s\n", err, out) + os.Exit(1) + } + // Pay the first-launch cost up front: macOS holds a freshly written unsigned + // binary in dyld for ~60s (provenance/Gatekeeper check) while every parallel + // test would otherwise blow its per-command timeout waiting on the loader. + warm := exec.CommandContext(ctx, verdaBin, "--version") // #nosec G204 -- verdaBin is the harness-built binary under t.TempDir + if out, err := warm.CombinedOutput(); err != nil { + fmt.Fprintf(os.Stderr, "contract: warm-up run failed: %v\n%s\n", err, out) + os.Exit(1) + } + os.Exit(m.Run()) +} + +// repoRoot resolves the module root so the build works regardless of the +// package directory go test runs from. +func repoRoot(ctx context.Context) (string, error) { + out, err := exec.CommandContext(ctx, "go", "env", "GOMOD").Output() + if err != nil { + return "", err + } + return filepath.Dir(strings.TrimSpace(string(out))), nil +} + +// cliResult holds one CLI invocation's captured output and wall-clock time. +type cliResult struct { + Stdout string + Stderr string + ExitCode int + Duration time.Duration +} + +// newServer returns a fresh mock API registered for cleanup. +func newServer(t *testing.T) *mockapi.Server { + t.Helper() + srv := mockapi.New() + t.Cleanup(srv.Close) + return srv +} + +// runCLI executes the built binary against srv with hermetic env: +// VERDA_HOME and cwd point at fresh temp dirs (no user config, no cwd +// config.yaml poisoning), inherited VERDA_* vars are stripped, and mock +// credentials are injected. Stdin is /dev/null so a regression that blocks +// on interactive input hangs at the per-command timeout instead of passing. +func runCLI(t *testing.T, srv *mockapi.Server, args ...string) cliResult { + t.Helper() + return runCLIEnv(t, srv, nil, args...) +} + +// runCLIEnv is runCLI plus extra env entries appended after the hermetic +// baseline (so they win over the stripped inherited vars — e.g. +// VERDA_REGISTRY_CREDENTIALS_FILE for registry commands). +func runCLIEnv(t *testing.T, srv *mockapi.Server, extraEnv []string, args ...string) cliResult { + t.Helper() + if verdaBin == "" { + t.Skip("contract suite requires building the binary (disabled with -short)") + } + + devNull, err := os.Open(os.DevNull) + if err != nil { + t.Fatalf("open %s: %v", os.DevNull, err) + } + defer func() { _ = devNull.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), cliTimeout) + defer cancel() + + fullArgs := append([]string{"--base-url", srv.URL()}, args...) + cmd := exec.CommandContext(ctx, verdaBin, fullArgs...) // #nosec G204 -- verdaBin is the harness-built binary under t.TempDir + cmd.Env = append(cliEnv(t), extraEnv...) + cmd.Dir = t.TempDir() + cmd.Stdin = devNull + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + start := time.Now() + runErr := cmd.Run() // #nosec G204 -- launches the harness-built CLI binary; see exec.CommandContext above + duration := time.Since(start) + + if ctx.Err() == context.DeadlineExceeded { + t.Fatalf("verda %s timed out after %s\nstdout: %s\nstderr: %s", + strings.Join(fullArgs, " "), cliTimeout, stdout.String(), stderr.String()) + } + + res := cliResult{Stdout: stdout.String(), Stderr: stderr.String(), Duration: duration} + if runErr == nil { + return res + } + var exitErr *exec.ExitError + if !errors.As(runErr, &exitErr) { + t.Fatalf("verda %s failed to run: %v", strings.Join(fullArgs, " "), runErr) + } + res.ExitCode = exitErr.ExitCode() + return res +} + +// cliEnv strips inherited VERDA_* variables, then sets an isolated config +// home and mock credentials. +func cliEnv(t *testing.T) []string { + t.Helper() + env := make([]string, 0, len(os.Environ())) + for _, kv := range os.Environ() { + if strings.HasPrefix(kv, "VERDA_") { + continue + } + env = append(env, kv) + } + return append(env, + "VERDA_HOME="+t.TempDir(), + "VERDA_CLIENT_ID="+testClientID, + "VERDA_CLIENT_SECRET="+testClientSecret, + ) +} + +// agentErrorEnvelope mirrors docs/agent-errors.md: {"error": {...}} on stderr. +type agentErrorEnvelope struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + Details map[string]any `json:"details,omitempty"` + } `json:"error"` +} + +// requireExit fails unless the result has the wanted exit code, dumping both +// streams for debugging. +func requireExit(t *testing.T, r cliResult, want int) { + t.Helper() + if r.ExitCode != want { + t.Fatalf("exit code = %d, want %d\nstdout: %s\nstderr: %s", r.ExitCode, want, r.Stdout, r.Stderr) + } +} + +// parseAgentError validates and decodes the structured error envelope. +func parseAgentError(t *testing.T, r cliResult) agentErrorEnvelope { + t.Helper() + var env agentErrorEnvelope + if err := json.Unmarshal([]byte(r.Stderr), &env); err != nil { + t.Fatalf("stderr is not the agent error envelope: %v\nstderr: %s\nstdout: %s", err, r.Stderr, r.Stdout) + } + if env.Error.Code == "" { + t.Fatalf("agent error envelope has empty code\nstderr: %s", r.Stderr) + } + return env +} + +// requireCleanJSON asserts stdout parses as JSON into target and carries no +// terminal escape bytes. +func requireCleanJSON(t *testing.T, r cliResult, target any) { + t.Helper() + if i := strings.IndexByte(r.Stdout, 0x1b); i >= 0 { + t.Fatalf("stdout contains ANSI escape at byte %d: %q", i, r.Stdout[max(0, i-20):i]) + } + if err := json.Unmarshal([]byte(r.Stdout), target); err != nil { + t.Fatalf("stdout is not valid JSON: %v\nstdout: %s\nstderr: %s", err, r.Stdout, r.Stderr) + } +} diff --git a/tests/contract/mockapi/mockapi.go b/tests/contract/mockapi/mockapi.go new file mode 100644 index 0000000..30d7e8d --- /dev/null +++ b/tests/contract/mockapi/mockapi.go @@ -0,0 +1,717 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package mockapi provides an in-process mock of the Verda Cloud API for the +// hermetic contract test suite. State lives entirely inside each Server, so +// parallel tests never observe one another. Wire shapes mirror +// verdacloud-sdk-go request/response types. +package mockapi + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync" + "time" + + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" +) + +// Instance-type catalog used by the mock. Prices replicate the documented +// TOTAL semantics (temp/docs/c1-ondemand-instance.json): price_per_hour / +// spot_price are the whole-instance totals, never per-unit. The 8-GPU type is +// deliberately priced at exactly 8x the 1-GPU sibling so a CLI-side +// re-multiplication regression (review C1) breaks exact equality. +const ( + TypeCPU = "CPU.4V.16G" + TypeGPU1 = "1V100.6V" + TypeGPU8 = "8V100.48V" + CPUOnDemandTotal = 0.0279 + CPUSpotTotal = 0.0098 + GPU1OnDemandTotal = 0.5 + GPU1SpotTotal = 0.2 + GPU8OnDemandTotal = 8 * GPU1OnDemandTotal + GPU8SpotTotal = 8 * GPU1SpotTotal + defaultOSVolumeGiB = 50 +) + +type catalogEntry struct { + typ verda.InstanceTypeInfo + onDemandTotal, spotTotal float64 +} + +const ( + statusProvisioning = "provisioning" + statusRunning = "running" + statusAttached = "attached" + statusDetached = "detached" +) + +// Server is a per-test mock Verda API. Each Server owns its fixture state; +// tests seed and inspect it through the Seed*/Has*/Count helpers. +type Server struct { + srv *httptest.Server + + mu sync.Mutex + instances map[string]*verda.Instance + volumes map[string]*verda.Volume + sshKeys map[string]*verda.SSHKey + failures map[string]int // exact path -> HTTP status override + hangs map[string]bool + forceFormToken bool + instanceGets int // GET /instances/{id} count — status polling signal + idSeq int +} + +// New starts a mock API server. The caller is responsible for Close. +func New() *Server { + s := &Server{ + instances: map[string]*verda.Instance{}, + volumes: map[string]*verda.Volume{}, + sshKeys: map[string]*verda.SSHKey{}, + failures: map[string]int{}, + hangs: map[string]bool{}, + } + + mux := http.NewServeMux() + mux.HandleFunc("POST /oauth2/token", s.handleToken) + mux.HandleFunc("GET /instance-types", s.handleInstanceTypes) + mux.HandleFunc("GET /instances", s.handleListInstances) + mux.HandleFunc("POST /instances", s.handleCreateInstance) + mux.HandleFunc("PUT /instances", s.handleInstanceAction) + mux.HandleFunc("GET /instances/{id}", s.handleGetInstance) + mux.HandleFunc("GET /volumes", s.handleListVolumes) + mux.HandleFunc("POST /volumes", s.handleCreateVolume) + mux.HandleFunc("GET /volumes/{id}", s.handleGetVolume) + mux.HandleFunc("DELETE /volumes/{id}", s.handleDeleteVolume) + mux.HandleFunc("GET /volume-types", s.handleVolumeTypes) + mux.HandleFunc("GET /ssh-keys", s.handleListSSHKeys) + mux.HandleFunc("POST /ssh-keys", s.handleCreateSSHKey) + mux.HandleFunc("DELETE /ssh-keys/{id}", s.handleDeleteSSHKey) + mux.HandleFunc("GET /scripts", s.handleListScripts) + mux.HandleFunc("GET /locations", s.handleListLocations) + mux.HandleFunc("GET /instance-availability", s.handleAvailability) + mux.HandleFunc("GET /instance-availability/{type}", s.handleTypeAvailability) + mux.HandleFunc("GET /balance", s.handleBalance) + mux.HandleFunc("/", s.handleNotFound) + + s.srv = httptest.NewServer(s.guard(mux)) + return s +} + +// Close shuts down the underlying HTTP server. +func (s *Server) Close() { s.srv.Close() } + +// URL returns the base URL to pass to the CLI via --base-url. +func (s *Server) URL() string { return s.srv.URL } + +// FailRoute makes requests to exact path (e.g. "/instances") fail with the +// given HTTP status and a JSON error body until ClearFailures is called. +func (s *Server) FailRoute(path string, status int) { + s.mu.Lock() + defer s.mu.Unlock() + s.failures[path] = status +} + +// HangRoute makes requests to exact path block until the client cancels +// (deadline, Ctrl+C, process exit) — never a timed sleep, so the suite stays +// fast. Server-side it then answers 503; the client that gave up orderly will +// already be gone. Regression pin for review H2: a control-plane call under +// --timeout must fail fast instead of hanging on a wedged endpoint. +func (s *Server) HangRoute(path string) { + s.mu.Lock() + defer s.mu.Unlock() + s.hangs[path] = true +} + +// ClearFailures removes all route failure overrides. +func (s *Server) ClearFailures() { + s.mu.Lock() + defer s.mu.Unlock() + s.failures = map[string]int{} +} + +// ForceFormTokenFallback makes the token endpoint reject JSON bodies with +// 400 "grant_type not specified" so the SDK retries form-encoded — the +// review-H1 redaction edge. +func (s *Server) ForceFormTokenFallback(on bool) { + s.mu.Lock() + defer s.mu.Unlock() + s.forceFormToken = on +} + +// SeedInstance stores an instance fixture and returns it. +func (s *Server) SeedInstance(hostname, instanceType string, pricePerHour float64) verda.Instance { + s.mu.Lock() + defer s.mu.Unlock() + inst := s.newInstanceLocked(&verda.CreateInstanceRequest{ + InstanceType: instanceType, + Hostname: hostname, + Image: "ubuntu-24.04", + LocationCode: verda.LocationFIN01, + }) + inst.Status = statusRunning + inst.PricePerHour = verda.FlexibleFloat(pricePerHour) + s.instances[inst.ID] = &inst + return inst +} + +// SeedVolume stores a detached volume fixture and returns it. +func (s *Server) SeedVolume(name string, sizeGiB int) verda.Volume { + s.mu.Lock() + defer s.mu.Unlock() + vol := s.newVolumeLocked(name, sizeGiB) + s.volumes[vol.ID] = &vol + return vol +} + +// SeedSSHKey stores an SSH key fixture and returns it. +func (s *Server) SeedSSHKey(name string) verda.SSHKey { + s.mu.Lock() + defer s.mu.Unlock() + key := verda.SSHKey{ + ID: s.newIDLocked(), + Name: name, + PublicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMockKeyForContractTestsOnly " + name, + Fingerprint: "SHA256:mockfingerprint", + CreatedAt: time.Date(2026, 8, 9, 0, 0, 0, 0, time.UTC), + } + s.sshKeys[key.ID] = &key + return key +} + +// HasVolume reports whether a volume with the given ID exists. +func (s *Server) HasVolume(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + _, ok := s.volumes[id] + return ok +} + +// VolumeCount returns the number of volumes currently known to the mock. +func (s *Server) VolumeCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.volumes) +} + +// InstanceGetCount returns how many GET /instances/{id} reads the mock has +// served — the wire-level signal of --wait status polling. +func (s *Server) InstanceGetCount() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.instanceGets +} + +// guard enforces failure + hang overrides before routing. +func (s *Server) guard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + status, fail := s.failures[r.URL.Path] + hang := s.hangs[r.URL.Path] + s.mu.Unlock() + if hang { + <-r.Context().Done() + writeError(w, http.StatusServiceUnavailable, "mock hung route: client context canceled") + return + } + if fail { + writeError(w, status, http.StatusText(status)) + return + } + next.ServeHTTP(w, r) + }) +} + +// --- token --- + +func (s *Server) handleToken(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + forceForm := s.forceFormToken + s.mu.Unlock() + + if forceForm && r.Header.Get("Content-Type") != "application/x-www-form-urlencoded" { + writeError(w, http.StatusBadRequest, "grant_type not specified") + return + } + writeJSON(w, http.StatusOK, verda.TokenResponse{ + AccessToken: "mock-access-token", + TokenType: "Bearer", + ExpiresIn: 3600, + }) +} + +// --- instance types / pricing catalog --- + +func catalog() []catalogEntry { + cpu := verda.InstanceTypeInfo{ + ID: "it-cpu-4v-16g", + InstanceType: TypeCPU, + Name: TypeCPU, + CPU: verda.InstanceCPU{Description: "4 vCPU", NumberOfCores: 4}, + Memory: verda.InstanceMemory{Description: "16GB", SizeInGigabytes: 16}, + PricePerHour: CPUOnDemandTotal, + SpotPrice: CPUSpotTotal, + Currency: "usd", + } + gpu1 := verda.InstanceTypeInfo{ + ID: "it-1v100-6v", + InstanceType: TypeGPU1, + Name: TypeGPU1, + CPU: verda.InstanceCPU{Description: "6 vCPU", NumberOfCores: 6}, + GPU: verda.InstanceGPU{Description: "1x V100", NumberOfGPUs: 1}, + Memory: verda.InstanceMemory{Description: "48GB", SizeInGigabytes: 48}, + PricePerHour: GPU1OnDemandTotal, + SpotPrice: GPU1SpotTotal, + Currency: "usd", + } + gpu8 := verda.InstanceTypeInfo{ + ID: "it-8v100-48v", + InstanceType: TypeGPU8, + Name: TypeGPU8, + CPU: verda.InstanceCPU{Description: "48 vCPU", NumberOfCores: 48}, + GPU: verda.InstanceGPU{Description: "8x V100", NumberOfGPUs: 8}, + Memory: verda.InstanceMemory{Description: "384GB", SizeInGigabytes: 384}, + PricePerHour: GPU8OnDemandTotal, + SpotPrice: GPU8SpotTotal, + Currency: "usd", + } + return []catalogEntry{ + {typ: cpu, onDemandTotal: CPUOnDemandTotal, spotTotal: CPUSpotTotal}, + {typ: gpu1, onDemandTotal: GPU1OnDemandTotal, spotTotal: GPU1SpotTotal}, + {typ: gpu8, onDemandTotal: GPU8OnDemandTotal, spotTotal: GPU8SpotTotal}, + } +} + +func catalogEntryFor(instanceType string) (catalogEntry, bool) { + entries := catalog() + for i := range entries { + if entries[i].typ.InstanceType == instanceType { + return entries[i], true + } + } + return catalogEntry{}, false +} + +func (s *Server) handleInstanceTypes(w http.ResponseWriter, _ *http.Request) { + entries := catalog() + types := make([]verda.InstanceTypeInfo, 0, len(entries)) + for i := range entries { + types = append(types, entries[i].typ) + } + writeJSON(w, http.StatusOK, types) +} + +// --- instances --- + +func (s *Server) handleListInstances(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + statusFilter := r.URL.Query().Get("status") + out := make([]verda.Instance, 0, len(s.instances)) + for _, inst := range s.instances { + advanceLocked(inst) + if statusFilter != "" && !strings.EqualFold(inst.Status, statusFilter) { + continue + } + out = append(out, *inst) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleGetInstance(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + s.instanceGets++ + inst, ok := s.instances[r.PathValue("id")] + if !ok { + writeError(w, http.StatusNotFound, "instance not found") + return + } + advanceLocked(inst) + writeJSON(w, http.StatusOK, inst) +} + +// advanceLocked mimics the real API completing provisioning: a created +// instance reports "provisioning" once, then "running" from its first read +// on. Lets polling callers (--wait) converge without sleeps. +func advanceLocked(inst *verda.Instance) { + if inst.Status == statusProvisioning { + inst.Status = statusRunning + if inst.IP == nil { + inst.IP = ptrOf("203.0.113.10") + } + } +} + +func (s *Server) handleCreateInstance(w http.ResponseWriter, r *http.Request) { + var req verda.CreateInstanceRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "not valid json") + return + } + + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := catalogEntryFor(req.InstanceType); !ok { + writeError(w, http.StatusBadRequest, "unknown instance type "+req.InstanceType) + return + } + for i := range req.ExistingVolumes { + volID := req.ExistingVolumes[i] + if _, ok := s.volumes[volID]; !ok { + writeError(w, http.StatusNotFound, "volume "+volID+" not found") + return + } + } + + inst := s.newInstanceLocked(&req) + s.instances[inst.ID] = &inst + writeJSON(w, http.StatusOK, inst) +} + +func (s *Server) handleInstanceAction(w http.ResponseWriter, r *http.Request) { + var req verda.InstanceActionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "not valid json") + return + } + + s.mu.Lock() + defer s.mu.Unlock() + results := make([]verda.InstanceActionResult, 0, len(req.ID)) + for i := range req.ID { + results = append(results, s.applyActionLocked(req, req.ID[i])) + } + allOK := true + for i := range results { + if results[i].Error != "" { + allOK = false + } + } + status := http.StatusAccepted + if !allOK { + status = http.StatusMultiStatus + } + writeJSON(w, status, results) +} + +// applyActionLocked mutates instance state for one action target. For delete, +// an explicit volume_ids list scopes which volumes die with the instance; +// a nil list follows the API default of deleting the OS volume. +func (s *Server) applyActionLocked(req verda.InstanceActionRequest, id string) verda.InstanceActionResult { + result := verda.InstanceActionResult{Action: req.Action, InstanceID: id} + inst, ok := s.instances[id] + if !ok { + result.Status = "failed" + result.Error = "instance not found" + result.StatusCode = http.StatusNotFound + return result + } + + switch req.Action { + case verda.ActionDelete: + deleteIDs := req.VolumeIDs + if deleteIDs == nil && inst.OSVolumeID != nil { + deleteIDs = []string{*inst.OSVolumeID} + } + for i := range deleteIDs { + delete(s.volumes, deleteIDs[i]) + } + delete(s.instances, id) + case verda.ActionShutdown, verda.ActionForceShutdown: + inst.Status = "offline" + case verda.ActionBoot, verda.ActionStart: + inst.Status = statusRunning + } + result.Status = "completed" + return result +} + +// newInstanceLocked builds an instance from a create request, allocating its +// OS volume and pricing it at the catalog TOTAL (review C1 semantics). +func (s *Server) newInstanceLocked(req *verda.CreateInstanceRequest) verda.Instance { + entry, _ := catalogEntryFor(req.InstanceType) + + osVolName := req.Hostname + "-os" + osVolSize := defaultOSVolumeGiB + if req.OSVolume != nil { + if req.OSVolume.Name != "" { + osVolName = req.OSVolume.Name + } + if req.OSVolume.Size > 0 { + osVolSize = req.OSVolume.Size + } + } + osVol := s.newVolumeLocked(osVolName, osVolSize) + osVol.IsOSVolume = true + osVol.Status = statusAttached + s.volumes[osVol.ID] = &osVol + + volumeIDs := make([]string, 0, len(req.Volumes)+len(req.ExistingVolumes)) + for i := range req.Volumes { + v := s.newVolumeSizedLocked(&req.Volumes[i], req.Hostname) + s.volumes[v.ID] = &v + volumeIDs = append(volumeIDs, v.ID) + } + volumeIDs = append(volumeIDs, req.ExistingVolumes...) + + price := entry.onDemandTotal + if req.IsSpot { + price = entry.spotTotal + } + + location := req.LocationCode + if location == "" { + location = verda.LocationFIN01 + } + description := req.Description + if description == "" { + description = req.Hostname + } + contract := req.Contract + if contract == "" { + contract = "PAY_AS_YOU_GO" + if req.IsSpot { + contract = "SPOT" + } + } + + inst := verda.Instance{ + ID: s.newIDLocked(), + Status: statusProvisioning, + CreatedAt: time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC), + CPU: entry.typ.CPU, + GPU: entry.typ.GPU, + Memory: entry.typ.Memory, + Hostname: req.Hostname, + Description: description, + Location: location, + PricePerHour: verda.FlexibleFloat(price), + IsSpot: req.IsSpot, + InstanceType: req.InstanceType, + Image: req.Image, + OSName: req.Image, + SSHKeyIDs: append([]string{}, req.SSHKeyIDs...), + OSVolumeID: ptrOf(osVol.ID), + VolumeIDs: volumeIDs, + Contract: contract, + } + if req.StartupScriptID != nil { + inst.StartupScriptID = req.StartupScriptID + } + return inst +} + +// --- volumes --- + +func (s *Server) handleListVolumes(w http.ResponseWriter, _ *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]verda.Volume, 0, len(s.volumes)) + for _, vol := range s.volumes { + out = append(out, *vol) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleGetVolume(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + vol, ok := s.volumes[r.PathValue("id")] + if !ok { + writeError(w, http.StatusNotFound, "volume not found") + return + } + writeJSON(w, http.StatusOK, vol) +} + +func (s *Server) handleCreateVolume(w http.ResponseWriter, r *http.Request) { + var req verda.VolumeCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "not valid json") + return + } + + s.mu.Lock() + defer s.mu.Unlock() + vol := s.newVolumeLocked(req.Name, req.Size) + vol.Type = req.Type + s.volumes[vol.ID] = &vol + // The API answers volume creates with the bare ID, not JSON. + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(vol.ID)) +} + +func (s *Server) handleDeleteVolume(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + id := r.PathValue("id") + if _, ok := s.volumes[id]; !ok { + writeError(w, http.StatusNotFound, "volume not found") + return + } + delete(s.volumes, id) + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) newVolumeLocked(name string, sizeGiB int) verda.Volume { + return verda.Volume{ + ID: s.newIDLocked(), + Name: name, + Size: sizeGiB, + Type: verda.VolumeTypeNVMe, + Status: statusDetached, + CreatedAt: time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC), + Location: verda.LocationFIN01, + SSHKeyIDs: []string{}, + Currency: "usd", + Contract: "PAY_AS_YOU_GO", + } +} + +func (s *Server) newVolumeSizedLocked(req *verda.VolumeCreateRequest, hostname string) verda.Volume { + name := req.Name + if name == "" { + name = hostname + "-storage" + } + vol := s.newVolumeLocked(name, req.Size) + if req.Type != "" { + vol.Type = req.Type + } + return vol +} + +// --- volume types --- + +// NVMeMonthlyPerGB / HDDMonthlyPerGB are the mock volume-type catalog prices +// (monthly per GiB), referenced by pricing/estimate tests. +const ( + NVMeMonthlyPerGB = 0.12 + HDDMonthlyPerGB = 0.035 +) + +func (s *Server) handleVolumeTypes(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, []verda.VolumeType{ + {Type: verda.VolumeTypeNVMe, Price: verda.VolumeTypePrice{PricePerMonthPerGB: NVMeMonthlyPerGB}}, + {Type: verda.VolumeTypeHDD, Price: verda.VolumeTypePrice{PricePerMonthPerGB: HDDMonthlyPerGB}}, + }) +} + +// --- ssh keys --- + +func (s *Server) handleListSSHKeys(w http.ResponseWriter, _ *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]verda.SSHKey, 0, len(s.sshKeys)) + for _, key := range s.sshKeys { + out = append(out, *key) + } + writeJSON(w, http.StatusOK, out) +} + +func (s *Server) handleCreateSSHKey(w http.ResponseWriter, r *http.Request) { + var req verda.CreateSSHKeyRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "not valid json") + return + } + + s.mu.Lock() + defer s.mu.Unlock() + key := verda.SSHKey{ + ID: s.newIDLocked(), + Name: req.Name, + PublicKey: req.PublicKey, + CreatedAt: time.Date(2026, 8, 9, 12, 0, 0, 0, time.UTC), + } + s.sshKeys[key.ID] = &key + writeJSON(w, http.StatusOK, key) +} + +func (s *Server) handleDeleteSSHKey(w http.ResponseWriter, r *http.Request) { + s.mu.Lock() + defer s.mu.Unlock() + id := r.PathValue("id") + if _, ok := s.sshKeys[id]; !ok { + writeError(w, http.StatusNotFound, "ssh key not found") + return + } + delete(s.sshKeys, id) + w.WriteHeader(http.StatusNoContent) +} + +// --- misc read-only sets --- + +func (s *Server) handleListScripts(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, []verda.StartupScript{}) +} + +func (s *Server) handleListLocations(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, []verda.Location{ + {Code: verda.LocationFIN01, Name: "Helsinki 1", CountryCode: "FI"}, + {Code: verda.LocationFIN03, Name: "Helsinki 3", CountryCode: "FI"}, + }) +} + +func (s *Server) handleAvailability(w http.ResponseWriter, _ *http.Request) { + entries := catalog() + types := make([]string, 0, len(entries)) + for i := range entries { + types = append(types, entries[i].typ.InstanceType) + } + writeJSON(w, http.StatusOK, []verda.LocationAvailability{ + {LocationCode: verda.LocationFIN01, Availabilities: types}, + {LocationCode: verda.LocationFIN03, Availabilities: types}, + }) +} + +func (s *Server) handleTypeAvailability(w http.ResponseWriter, _ *http.Request) { + // The real API answers the JSON string "true"/"false", not a boolean. + writeJSON(w, http.StatusOK, "true") +} + +func (s *Server) handleBalance(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, verda.Balance{Amount: 500, Currency: "usd"}) +} + +func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { + writeError(w, http.StatusNotFound, "unknown route: "+r.Method+" "+r.URL.Path) +} + +// --- helpers --- + +func (s *Server) newIDLocked() string { + s.idSeq++ + return fmt.Sprintf("00000000-0000-4000-8000-%012d", s.idSeq) +} + +func ptrOf(v string) *string { return &v } + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, verda.APIError{ + StatusCode: status, + Code: strings.ToUpper(strings.ReplaceAll(http.StatusText(status), " ", "_")), + Message: message, + }) +} diff --git a/tests/contract/pipe_purity_test.go b/tests/contract/pipe_purity_test.go new file mode 100644 index 0000000..549a255 --- /dev/null +++ b/tests/contract/pipe_purity_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package contract + +import ( + "strings" + "testing" + "time" +) + +// TestInteractivePromptFailsCleanOnPipedStdin: a non-agent interactive path +// with piped stdin must fail fast with a clean error — the prompt UI (and any +// spinner frames) render on stderr, never stdout. Before the stream wiring, +// standalone prompts wrote ANSI to os.Stdout and polluted `verda ... | jq`. +func TestInteractivePromptFailsCleanOnPipedStdin(t *testing.T) { + t.Parallel() + srv := newServer(t) + srv.SeedVolume("contract-vol", 100) + + r := runCLI(t, srv, "volume", "delete") // no --id: interactive picker path + requireExit(t, r, 1) + if r.Stdout != "" { + t.Fatalf("stdout not clean on interactive path: %q", r.Stdout) + } + if !strings.Contains(r.Stderr, "not a TTY") { + t.Fatalf("expected the not-a-TTY error on stderr, got: %q", r.Stderr) + } + if r.Duration >= 10*time.Second { + t.Fatalf("interactive path took %s — regression toward blocking on stdin", r.Duration) + } +} + +// TestAgentUsageErrorExit2: flag misuse in agent mode is VALIDATION_ERROR with +// exit 2 (bad input), distinct from server-side failures (exit 4/1). +func TestAgentUsageErrorExit2(t *testing.T) { + t.Parallel() + srv := newServer(t) + + r := runCLI(t, srv, "--agent", "volume", "delete", "--status", "detached") + requireExit(t, r, 2) + env := parseAgentError(t, r) + if env.Error.Code != "VALIDATION_ERROR" { + t.Fatalf("code = %q, want VALIDATION_ERROR\nstderr: %s", env.Error.Code, r.Stderr) + } + if !strings.Contains(env.Error.Message, "--status can only be used with --all") { + t.Fatalf("message = %q, want the missing --all hint", env.Error.Message) + } + if strings.Contains(env.Error.Message, "--help") { + t.Fatalf("message %q carries the human-facing --help hint", env.Error.Message) + } +} + +// TestAgentVMActionUsageErrorExit2: the vm action flag-combination validations +// share the same contract. +func TestAgentVMActionUsageErrorExit2(t *testing.T) { + t.Parallel() + srv := newServer(t) + + r := runCLI(t, srv, "--agent", "vm", "shutdown", "--status", "running") + requireExit(t, r, 2) + env := parseAgentError(t, r) + if env.Error.Code != "VALIDATION_ERROR" { + t.Fatalf("code = %q, want VALIDATION_ERROR\nstderr: %s", env.Error.Code, r.Stderr) + } +} diff --git a/tests/contract/streams_pricing_test.go b/tests/contract/streams_pricing_test.go new file mode 100644 index 0000000..f88eca2 --- /dev/null +++ b/tests/contract/streams_pricing_test.go @@ -0,0 +1,143 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package contract + +import ( + "strings" + "testing" + + "github.com/verda-cloud/verda-cli/tests/contract/mockapi" +) + +// TestTableStreamSeparation: table mode writes data to stdout and never +// interleaves diagnostics on stderr (stdout is a pipe here — the piped case). +func TestTableStreamSeparation(t *testing.T) { + t.Parallel() + srv := newServer(t) + srv.SeedInstance("contract-table", mockapi.TypeCPU, mockapi.CPUOnDemandTotal) + + r := runCLI(t, srv, "vm", "list") + requireExit(t, r, 0) + if r.Stderr != "" { + t.Fatalf("stderr not empty in table mode with piped stdout: %q", r.Stderr) + } + if !strings.Contains(r.Stdout, "HOSTNAME") || !strings.Contains(r.Stdout, "contract-table") { + t.Fatalf("stdout does not contain the instance table:\n%s", r.Stdout) + } +} + +// TestPricePerHourIsTotal (review C1): the API's instance price_per_hour is +// the TOTAL hourly price for the type. The mock catalog prices the 8-GPU type +// at exactly 8x its 1-GPU sibling; if any CLI layer multiplies the wire value +// by unit count again, these exact-equality checks break. +func TestPricePerHourIsTotal(t *testing.T) { + t.Parallel() + + create := func(t *testing.T, srv *mockapi.Server, args ...string) (id string, price float64) { + t.Helper() + r := runCLI(t, srv, args...) + requireExit(t, r, 0) + var inst struct { + ID string `json:"id"` + PricePerHour float64 `json:"price_per_hour"` + } + requireCleanJSON(t, r, &inst) + if inst.ID == "" { + t.Fatalf("create returned empty instance id:\n%s", r.Stdout) + } + return inst.ID, inst.PricePerHour + } + + describe := func(t *testing.T, srv *mockapi.Server, id string) float64 { + t.Helper() + r := runCLI(t, srv, "--agent", "vm", "describe", id) + requireExit(t, r, 0) + var inst struct { + PricePerHour float64 `json:"price_per_hour"` + } + requireCleanJSON(t, r, &inst) + return inst.PricePerHour + } + + t.Run("8-GPU create/describe/list carry the catalog total untouched", func(t *testing.T) { + t.Parallel() + srv := newServer(t) + + id, createPrice := create(t, srv, + "--agent", "vm", "create", + "--kind", "gpu", + "--instance-type", mockapi.TypeGPU8, + "--os", "ubuntu-24.04", + "--hostname", "c1-gpu-rig", + ) + if createPrice != mockapi.GPU8OnDemandTotal { + t.Fatalf("create price_per_hour = %v, want catalog total %v (8x %v; a re-multiplied wire value would read %v)", + createPrice, mockapi.GPU8OnDemandTotal, mockapi.GPU1OnDemandTotal, mockapi.GPU8OnDemandTotal*8) + } + if got := describe(t, srv, id); got != mockapi.GPU8OnDemandTotal { + t.Fatalf("describe price_per_hour = %v, want catalog total %v", got, mockapi.GPU8OnDemandTotal) + } + + r := runCLI(t, srv, "--agent", "vm", "list") + requireExit(t, r, 0) + var insts []struct { + InstanceType string `json:"instance_type"` + PricePerHour float64 `json:"price_per_hour"` + } + requireCleanJSON(t, r, &insts) + found := false + for i := range insts { + if insts[i].InstanceType == mockapi.TypeGPU8 { + found = true + if insts[i].PricePerHour != mockapi.GPU8OnDemandTotal { + t.Fatalf("list price_per_hour = %v, want catalog total %v", insts[i].PricePerHour, mockapi.GPU8OnDemandTotal) + } + } + } + if !found { + t.Fatalf("created instance missing from list:\n%s", r.Stdout) + } + }) + + // On-demand and spot totals pinned to the staging ground-truth values in + // temp/docs/c1-ondemand-instance.json (CPU.4V.16G: 0.0279 / 0.0098). + t.Run("CPU on-demand and spot totals match staging ground truth", func(t *testing.T) { + t.Parallel() + srv := newServer(t) + + _, onDemand := create(t, srv, + "--agent", "vm", "create", + "--kind", "cpu", + "--instance-type", mockapi.TypeCPU, + "--os", "ubuntu-24.04", + "--hostname", "c1-cpu-ondemand", + ) + if onDemand != mockapi.CPUOnDemandTotal { + t.Fatalf("on-demand price_per_hour = %v, want %v", onDemand, mockapi.CPUOnDemandTotal) + } + + _, spot := create(t, srv, + "--agent", "vm", "create", + "--kind", "cpu", + "--instance-type", mockapi.TypeCPU, + "--os", "ubuntu-24.04", + "--hostname", "c1-cpu-spot", + "--is-spot", + ) + if spot != mockapi.CPUSpotTotal { + t.Fatalf("spot price_per_hour = %v, want %v", spot, mockapi.CPUSpotTotal) + } + }) +} diff --git a/tests/contract/timeout_test.go b/tests/contract/timeout_test.go new file mode 100644 index 0000000..13e4a12 --- /dev/null +++ b/tests/contract/timeout_test.go @@ -0,0 +1,135 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package contract + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/google/go-containerregistry/pkg/name" + ggcrregistry "github.com/google/go-containerregistry/pkg/registry" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// TestTimeoutControlPlaneFailsFast pins review H2's control-plane half: with +// the client-level http.Client.Timeout gone, a wedged API endpoint must still +// be bounded by --timeout (per-call WithTimeout), not hang. The mock blocks +// /locations until the client cancels, so a missing bound fails the suite's +// own safety net (cliTimeout) rather than this quick assert. +func TestTimeoutControlPlaneFailsFast(t *testing.T) { + t.Parallel() + + srv := newServer(t) + srv.HangRoute("/locations") + + r := runCLI(t, srv, "--timeout", "1s", "locations") + if r.ExitCode == 0 { + t.Fatalf("exit code = 0 against a hung endpoint\nstdout: %s\nstderr: %s", r.Stdout, r.Stderr) + } + if r.Duration > 10*time.Second { + t.Fatalf("--timeout 1s did not bound the call: took %s\nstdout: %s\nstderr: %s", + r.Duration, r.Stdout, r.Stderr) + } +} + +// TestTimeoutTransferNotClamped pins review H2's data-plane half: a transfer +// that outlives --timeout must succeed (bounded ctx is for control-plane +// calls only). The source registry delays every layer-blob GET by blobDelay, +// well past the 900ms --timeout; pre-fix the copy died mid-transfer with +// context deadline exceeded. +func TestTimeoutTransferNotClamped(t *testing.T) { + t.Parallel() + + const blobDelay = 1500 * time.Millisecond + + // Source: in-memory docker v2 registry with delayed blob pulls. + inner := ggcrregistry.New() + srcSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/blobs/") { + timer := time.NewTimer(blobDelay) + select { + case <-timer.C: + case <-r.Context().Done(): + timer.Stop() + return + } + } + inner.ServeHTTP(w, r) + })) + t.Cleanup(srcSrv.Close) + + // Destination: same registry, no delay. + dstSrv := httptest.NewServer(ggcrregistry.New()) + t.Cleanup(dstSrv.Close) + + srcHost := hostOf(t, srcSrv) + dstHost := hostOf(t, dstSrv) + + // Prime the source with a small image (writes are not delayed). + srcRef, err := name.ParseReference(srcHost + "/lib/app:v1") + if err != nil { + t.Fatalf("parse src ref: %v", err) + } + img, err := random.Image(2048, 1) + if err != nil { + t.Fatalf("random.Image: %v", err) + } + if err := remote.Write(srcRef, img); err != nil { + t.Fatalf("prime source image: %v", err) + } + + credsFile := filepath.Join(t.TempDir(), "credentials") + credsBody := fmt.Sprintf("[default]\nverda_registry_username = u\nverda_registry_secret = p\nverda_registry_endpoint = %s\nverda_registry_project_id = proj\n", dstHost) + if err := os.WriteFile(credsFile, []byte(credsBody), 0o600); err != nil { + t.Fatalf("write registry creds: %v", err) + } + + srv := newServer(t) + r := runCLIEnv(t, srv, []string{"VERDA_REGISTRY_CREDENTIALS_FILE=" + credsFile}, + "--timeout", "900ms", "registry", "copy", + srcHost+"/lib/app:v1", dstHost+"/proj/app:v1", + "--src-auth", "anonymous", + ) + requireExit(t, r, 0) + if r.Duration < blobDelay { + t.Fatalf("copy finished in %s, under the blob delay %s — transfer never hit the slow path\nstdout: %s\nstderr: %s", + r.Duration, blobDelay, r.Stdout, r.Stderr) + } + + dstRef, err := name.ParseReference(dstHost + "/proj/app:v1") + if err != nil { + t.Fatalf("parse dst ref: %v", err) + } + if _, err := remote.Head(dstRef); err != nil { + t.Fatalf("image missing at destination: %v\nstdout: %s\nstderr: %v", err, r.Stdout, r.Stderr) + } +} + +func hostOf(t *testing.T, srv *httptest.Server) string { + t.Helper() + u, err := url.Parse(srv.URL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + return u.Host +}