diff --git a/.ai/skills/new-command.md b/.ai/skills/new-command.md index e971a6a..d2b897b 100644 --- a/.ai/skills/new-command.md +++ b/.ai/skills/new-command.md @@ -112,20 +112,70 @@ if err != nil { ### 6. Interactive + Non-Interactive -Commands should support both modes: +Commands MUST support both modes: - **Flags** for scripting/CI (non-interactive) -- **Prompter** for interactive use when flags are missing +- **Prompter** TUI for interactive use when the target is omitted on a terminal + +**Implicit trigger.** Omitting the positional target on a TTY launches the +interactive flow; everything else stays one-shot. Gate it exactly like this: + +```go +if len(args) >= wantArgs { // explicit args -> run directly + return run(cmd, f, ioStreams, opts, args...) +} +if f.AgentMode() { // agents get a structured error, never a prompt + return cmdutil.NewMissingFlagsError([]string{"s3://bucket/key"}) +} +if !interactiveTTY(f) { // non-TTY or json/yaml output -> help, no silent prompt + return cmd.Help() +} +return runInteractive(cmd, f, ioStreams, opts, args) +``` + +`interactiveTTY(f)` == `IsStdoutTerminal() && !AgentMode() && OutputFormat() == "table"`. + +**The hint bar is mandatory on every direct `Select` / `MultiSelect`.** Pass +`tui.WithShowHints(true)` (or `tui.WithMultiSelectShowHints(true)`) so the user +always sees the standard control legend: + +``` +↑/↓ navigate · type to filter · enter select · esc back · ctrl+c exit +``` + +(Wizard-engine step Loaders are the only exception — the composite renders its +own hint bar, so double-rendering is a bug.) + +**Esc = soft back, Ctrl+C = hard exit — always. Never a confirmation dialog on +either.** Classify the prompter error; never bare-`return nil`: ```go -name := opts.Name -if name == "" { - name, err = prompter.TextInput(ctx, "Item name") - if err != nil { - return nil // User cancelled (Esc/Ctrl+C) +idx, err := f.Prompter().Select(ctx, "Pick one", labels, tui.WithShowHints(true)) +if err != nil { + if cmdutil.IsPromptCancel(err) { // Esc OR Ctrl+C — flow doesn't care which + return nil } + return err // a real I/O failure MUST propagate } ``` +Use `cmdutil.IsPromptInterrupt(err)` (Ctrl+C) and `cmdutil.IsPromptBack(err)` +(Esc) when the two must differ — e.g. a "Back to list / Exit" gate, or a wizard +where Esc steps back one prompt while Ctrl+C exits the whole flow. + +**Multi-step wizards.** Model each prompt as its own step walked by an index +into a steps slice: Esc decrements the index (–1 on the first step ends the +flow = exit), Ctrl+C exits, success advances. Print a `Step N of M · Title` +header and a one-time intro banner so the user knows the plan. See +`cmd/s3/move_wizard.go` (`runMoveWizard` + `classifyNav`/`navIdx`) for the +reference pattern. + +> **Pitfall that breaks Esc=back:** a shared picker that swallows cancellation +> into `("", nil)` (i.e. `if IsPromptCancel(err) { return "", nil }`) is fine for +> a top-level command, but inside a wizard it destroys back-navigation — the +> wizard can no longer tell Esc (go back) from Ctrl+C (exit) and Esc ends up +> exiting the whole flow. Wizard-facing pickers MUST return the **raw** prompter +> error so the step loop can classify it. + ### 7. Output Conventions - Normal output goes to `ioStreams.Out` @@ -144,12 +194,22 @@ Delete and dangerous actions MUST confirm: ```go confirmed, err := prompter.Confirm(ctx, fmt.Sprintf("Delete %s?", name)) -if err != nil || !confirmed { - _, _ = fmt.Fprintln(ioStreams.ErrOut, "Cancelled.") +if err != nil { + if cmdutil.IsPromptCancel(err) { // Esc/Ctrl+C = clean cancel + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return err // real I/O failure must propagate +} +if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } ``` +(Note American spelling: `Canceled`. In `--agent` mode require `--yes` and never +prompt; without it, return `cmdutil.NewConfirmationRequiredError()`.) + For dangerous actions, add warning styling: ```go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 026978d..d8217da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25.9" + go-version-file: go.mod cache: true - name: Add Go bin to PATH @@ -47,7 +47,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25.9" + go-version-file: go.mod cache: true - name: Run unit tests @@ -63,7 +63,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25.9" + go-version-file: go.mod cache: true - name: Build @@ -79,7 +79,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25.9" + go-version-file: go.mod cache: true - name: Add Go bin to PATH @@ -107,7 +107,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25.9" + go-version-file: go.mod cache: true - name: Check go mod tidy @@ -129,7 +129,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25.9" + go-version-file: go.mod cache: true - name: Add Go bin to PATH diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ff92309..e2db0c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25.9" + go-version-file: go.mod cache: true - name: Install git-cliff diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 6c8bc7f..1f5b1ef 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25.9" + go-version-file: go.mod cache: true - name: Add Go bin to PATH @@ -60,7 +60,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v6 with: - go-version: "1.25.9" + go-version-file: go.mod cache: false - name: Install gitleaks diff --git a/.gitignore b/.gitignore index e8ec74d..33082de 100644 --- a/.gitignore +++ b/.gitignore @@ -385,3 +385,5 @@ docs/plans/ # AI agent project-level configs (installed by users, not shipped) .cursor/ +.claude/skills/ +.gitnexus diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c1a6d97..53c02cb 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,9 +25,6 @@ repos: name: Organize Go imports (goimports) - id: go-mod-tidy name: Tidy Go modules (go mod tidy) - - id: go-unit-tests - name: Run Go unit tests - args: [-short] - repo: local hooks: @@ -37,3 +34,11 @@ repos: language: system types: [go] pass_filenames: false + # Replaces dnephin/pre-commit-golang's go-unit-tests, which hardcodes + # -timeout=30s and trips on legitimately slow tests (cmd/auth, options). + - id: go-unit-tests + name: Run Go unit tests + entry: make test + language: system + types: [go] + pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 69c8944..bcaf21c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,10 @@ Skipping these steps leads to pattern violations, broken dual-mode, and pricing ## Execution Rules - **Follow existing patterns** — find the nearest similar command, match its structure +- **Senior comment style** — default to no comments. Add one only when the WHY is non-obvious (invariant, workaround, gotcha, evolution point). One line, identifier-first. Never narrate WHAT — well-named identifiers do that. Delete comments when the reason expires. See CLAUDE.md "Comment style — write like a senior" - **Preserve dual mode** — every command must work interactive AND non-interactive. Never build one without the other +- **Interactive hint bar** — every direct `prompter.Select(...)` outside the wizard engine must pass `tui.WithShowHints(true)` (and the equivalent option on `MultiSelect`) so the prompt renders its key hints below the choices. Wizard steps are exempt — the composite already renders the hint bar +- **Ctrl+C exits immediately, no confirmation** — use `cmdutil.IsPromptCancel(err)` to detect either Esc or Ctrl+C and return cleanly. When a flow needs different behavior per key (e.g. a "Back to list / Exit" gate where Esc means back), split with `IsPromptInterrupt(err)` (Ctrl+C) and `IsPromptBack(err)` (Esc). Never show an "Exit?" confirmation dialog — Unix users expect Ctrl+C to be terminal - **Never modify `verdagostack`** directly — describe needed changes for the maintainer - **Commit only when asked** — don't auto-commit @@ -50,6 +53,51 @@ Skipping these steps leads to pattern violations, broken dual-mode, and pricing - [ ] `make test` passes (runs lint + unit tests) - [ ] `--help` renders correctly for changed commands - [ ] Interactive and non-interactive modes both work +- [ ] Interactive Selects pass `tui.WithShowHints(true)` so the hint bar renders - [ ] No leftover debug code, TODOs, or commented-out blocks If `make lint` reports issues, fix them *before* announcing completion. See `CLAUDE.md` § "Go House Style" for the patterns that prevent the common hits (http.NoBody, American spelling, reused constants, rangeValCopy, nilerr annotations, etc.). + + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **verda-cli** (7546 symbols, 19146 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/verda-cli/context` | Codebase overview, check index freshness | +| `gitnexus://repo/verda-cli/clusters` | All functional areas | +| `gitnexus://repo/verda-cli/processes` | All execution flows | +| `gitnexus://repo/verda-cli/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/CLAUDE.md b/CLAUDE.md index 95debbc..eced4bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,6 +84,15 @@ The repo lints with `golangci-lint` via `make lint` (included in `make test`). T `.golangci.yaml` is the authoritative list — all of the above come from linters enabled there. +### Comment style — write like a senior + +- **Default to no comment.** Well-named identifiers carry the meaning. Add a comment only when the *why* is non-obvious: an invariant, a workaround, a gotcha a future reader would miss, an evolution point. +- **One line, identifier-first.** `// resolveContainerName: args[0], else picker; agent requires .` beats a three-line paragraph. +- **Never narrate WHAT.** `// Loop over deployments and build labels` is noise — delete it. +- **Capture invariants, not history.** `// Describe still succeeds if status RPC fails.` is durable. `// Added for ticket VC-1234` rots — put it in the commit message. +- **Flag known evolution points.** `// if SDK gains json:"status", switch to explicit fields.` documents a future-failure mode so the next reader doesn't have to rediscover it. +- **Delete when the reason expires.** Workaround landed, gotcha fixed, SDK gap closed → remove the comment in the same commit. + ### Every API-calling command MUST: 1. **Timeout context**: `ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout)` @@ -98,6 +107,12 @@ The repo lints with `golangci-lint` via `make lint` (included in `make test`). T - Require `prompter.Confirm()` — return nil on cancel or Esc - In agent mode (`f.AgentMode()`): require `--yes` flag, never auto-confirm +### Interactive commands MUST: + +- **Show the hint bar at the bottom of every direct `Prompter.Select`** — pass `tui.WithShowHints(true)` to render `↑/↓ navigate · type to filter · enter select · esc back · ctrl+c exit` below the choices. Same for `MultiSelect` via the equivalent option. Wizard step Loaders are exempt — the wizard composite already renders its own hint bar; double-rendering is a bug. +- **Treat Ctrl+C as a hard exit, Esc as a soft back** — never show a confirmation dialog on either. Unix users expect Ctrl+C to be terminal; an "Exit?" prompt is friction, and confirmation dialogs themselves can be cancelled which makes the design contradictory. Use `cmdutil.IsPromptInterrupt(err)` for Ctrl+C and `cmdutil.IsPromptBack(err)` for Esc when the two need different handling (e.g. in a "Back to list / Exit" gate, Esc returns to the list while Ctrl+C exits the whole loop). Both are cleanly distinguishable via `cmdutil.IsPromptCancel(err)` if a flow doesn't care which key triggered it. +- **Use `cmdutil.IsPromptCancel(err)`** — never bare-`return nil` on prompter errors; distinguish clean Ctrl+C / Esc from real I/O failures and propagate the latter. + ### Pricing — get this wrong and users get billed wrong: - Instance `price_per_hour` from API is **per-unit** (per-GPU or per-vCPU) @@ -154,3 +169,47 @@ If you modified a command, also verify: ## Other Agents This repo targets Claude Code and OpenAI Codex. Claude auto-loads this file; Codex auto-loads `AGENTS.md` (execution contract). A `.cursor/rules/main.mdc` pointer exists for Cursor users but is not a primary target — if Cursor drops out of the stack, delete it rather than letting it drift. + + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **verda-cli** (7546 symbols, 19146 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/verda-cli/context` | Codebase overview, check index freshness | +| `gitnexus://repo/verda-cli/clusters` | All functional areas | +| `gitnexus://repo/verda-cli/processes` | All execution flows | +| `gitnexus://repo/verda-cli/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/go.mod b/go.mod index a737baf..1eac9ec 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/verda-cloud/verda-cli -go 1.25.9 +go 1.25.10 require ( charm.land/lipgloss/v2 v2.0.2 @@ -9,7 +9,7 @@ require ( github.com/spf13/pflag v1.0.10 github.com/spf13/viper v1.21.0 github.com/verda-cloud/verdacloud-sdk-go v1.4.2 - github.com/verda-cloud/verdagostack v1.3.3 + github.com/verda-cloud/verdagostack v1.4.1 go.yaml.in/yaml/v3 v3.0.4 ) @@ -99,6 +99,6 @@ require ( go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.27.1 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.43.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index 0871a65..99d6ce7 100644 --- a/go.sum +++ b/go.sum @@ -189,8 +189,8 @@ github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CP github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= github.com/verda-cloud/verdacloud-sdk-go v1.4.2 h1:oVb8fHVQOY+YPuuMYMee9gYCkPTwAw01LmkqxM21T/Y= github.com/verda-cloud/verdacloud-sdk-go v1.4.2/go.mod h1:pmlpiCL9fTSikZ3qWLJPpHOG0E8PKkQVUX5s4Z+SktY= -github.com/verda-cloud/verdagostack v1.3.3 h1:5ILWctJ4+InsdmYwEfqB4olT/1NAUUvr54m+n1DEpuI= -github.com/verda-cloud/verdagostack v1.3.3/go.mod h1:eWTGv3kbBUGVCjNKZYLzzK9+UwpNWoPN3B2vebN2otY= +github.com/verda-cloud/verdagostack v1.4.1 h1:Jj+15fw+RlBWGuY4dPSbjgLoqS8UKLdQa9RTYeB8VxM= +github.com/verda-cloud/verdagostack v1.4.1/go.mod h1:TuJkNkis787dfJTU//dTKEMTbL/tDWDlgcPPI0WiJgw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -223,10 +223,10 @@ golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/skills/files/verda-cloud.md b/internal/skills/files/verda-cloud.md index 8bca92c..41146e5 100644 --- a/internal/skills/files/verda-cloud.md +++ b/internal/skills/files/verda-cloud.md @@ -1,6 +1,6 @@ --- name: verda-cloud -description: Use when the user mentions Verda Cloud, GPU/CPU VMs, cloud instances, deploying servers, ML training infrastructure, cloud costs/billing, SSH into remote machines, or verda CLI commands. +description: Use when the user mentions Verda Cloud, GPU/CPU VMs, cloud instances, deploying servers, ML training infrastructure, cloud costs/billing, SSH into remote machines, object storage / S3 buckets, uploading or downloading files, or verda CLI commands. --- # Verda Cloud @@ -14,7 +14,7 @@ description: Use when the user mentions Verda Cloud, GPU/CPU VMs, cloud instance **Example:** `verda --agent instance-types --gpu -o json` **NEVER do these:** -- NEVER run `verda` without `--agent -o json` (except `verda ssh` which is interactive) +- NEVER run `verda` without `--agent -o json` (except `verda ssh` and `verda s3 configure`, which are interactive — tell the user to run those) - NEVER guess commands — consult the verda-reference skill or run `verda --help` - NEVER create resources without checking cost first - NEVER delete/shutdown without explicit user confirmation @@ -35,7 +35,8 @@ description: Use when the user mentions Verda Cloud, GPU/CPU VMs, cloud instance | **Manage** | "start", "stop", "delete", "SSH" | Find VM first, then act | | **VM Info** | "my VMs", "instances", "what's running", "what's offline" | `verda --agent vm list -o json` (add `--status` to filter). Use `vm describe ` for a specific VM | | **Cost** | "balance", "burn rate", "spending", "how much" | `verda --agent cost balance -o json` and/or `cost running -o json` | -| **Storage** | "volumes", "disks", "storage" | `verda --agent volume list -o json` | +| **Storage** | "volumes", "disks", "block storage" | `verda --agent volume list -o json` | +| **Object Storage** | "bucket", "S3", "object storage", "upload a file", "download a file" | `verda --agent s3 ls -o json` (needs `s3 configure` first — see below) | ### Explore — Use Specific Commands, Not `status` @@ -75,6 +76,33 @@ Otherwise walk this chain. **ALWAYS** steps must run even if user specified valu ``` 10. **Verify** — `verda --agent vm describe -o json`. Tell user: `verda ssh ` (do NOT run it) +## Object Storage (S3) + +S3-compatible object storage. **Separate credentials** from the main API — +keys are prefixed `verda_s3_` and set up by `verda s3 configure` (interactive, +user-only — like `auth login`; never run it yourself, never handle the keys). + +1. **Check setup first:** `verda s3 show` (prints text, not JSON). If it shows `s3_configured: false` (or `access_key_loaded: false`), tell the user to run `verda s3 configure` (do NOT run it). Configured ⇔ `access_key_loaded: true`. +2. **Then operate** (all support `--agent -o json`): + +| Question / intent | Command | +|-------------------|---------| +| List buckets | `verda --agent s3 ls -o json` | +| List a bucket's contents | `verda --agent s3 ls s3://bucket -o json` (add `--recursive`) | +| Upload a file | `verda --agent s3 cp ./file s3://bucket/key -o json` | +| Download a file | `verda --agent s3 cp s3://bucket/key ./file -o json` | +| Copy / move within S3 | `verda --agent s3 cp\|mv s3://b/a s3://b/c -o json` | +| Mirror a directory | `verda --agent s3 sync ./dir s3://bucket/prefix/ -o json` | +| Delete object(s) | `verda --agent s3 rm s3://bucket/key --yes -o json` | +| Make / remove a bucket | `verda --agent s3 mb\|rb s3://bucket -o json` (`rb` needs `--yes`) | +| Time-limited share URL | `verda --agent s3 presign s3://bucket/key -o json` | + +**Destructive (`rm`, `rb`):** require `--yes` in agent mode, else they return +`CONFIRMATION_REQUIRED`. `cp`/`mv`/`sync` don't prompt — confirm intent with the +user before bulk/`--recursive`/`--delete` operations. Always offer `--dryrun` +first for recursive deletes and `sync --delete`. See the verda-reference skill +for flags and output fields. + ## Error Recovery | Error Code | Action | diff --git a/internal/skills/files/verda-reference.md b/internal/skills/files/verda-reference.md index 566c184..59ddc8d 100644 --- a/internal/skills/files/verda-reference.md +++ b/internal/skills/files/verda-reference.md @@ -34,6 +34,14 @@ All commands: `--agent -o json` (except `verda ssh` and `verda auth show`). | "estimate", "how much will it cost" | `cost estimate` | | "connect", "SSH in", "remote access" | Tell user to run `verda ssh ` themselves (interactive) | | "login", "authenticate", "credentials" | `auth login` (user runs manually) | +| "bucket", "S3", "object storage", "list buckets" | `s3 ls` | +| "upload", "put file in bucket" | `s3 cp ./file s3://bucket/key` | +| "download", "get file from bucket" | `s3 cp s3://bucket/key ./file` | +| "sync", "mirror folder to/from bucket" | `s3 sync ` | +| "delete object", "remove from bucket" | `s3 rm s3://bucket/key --yes` | +| "make bucket", "create bucket" / "remove bucket" | `s3 mb` / `s3 rb --yes` | +| "share link", "presigned URL", "temporary link" | `s3 presign s3://bucket/key` | +| "set up S3", "configure object storage" | `s3 configure` (user runs manually — interactive) | ## Discovery @@ -131,6 +139,31 @@ Hostname patterns: `{random}` → random words, `{location}` → location code | `verda volume action ` | Actions: detach, rename, resize, clone, delete | | `verda volume trash -o json` | Recoverable within 96 hours | +## Object Storage (S3) + +Separate credentials from the main API (keys prefixed `verda_s3_`). Set up with +`verda s3 configure` (interactive — user runs it). Check status first: + +| Command | Key Flags | Output Fields | +|---------|-----------|---------------| +| `verda s3 show` | `--profile` | Text key:value (NOT JSON): `s3_configured: false` only when unset; otherwise `access_key_loaded`, `secret_key_loaded`, `endpoint`, `region`. Configured ⇔ `access_key_loaded: true` | +| `verda s3 ls -o json` | — (lists buckets) | `buckets[]`: `name`, `created_at` | +| `verda s3 ls s3://bucket[/prefix] -o json` | `--recursive`, `--human-readable`, `--summarize` | `objects[]`: `key`, `size`, `modified`; `common_prefixes[]` | +| `verda s3 cp -o json` | `--recursive`, `--include`, `--exclude`, `--content-type`, `--part-size`, `--concurrency`, `--no-resume`, `--dryrun` | `transfers[]`: `source`, `destination`, `bytes`, `status`; `summary` | +| `verda s3 mv -o json` | same as `cp` (minus resume flags) | same as `cp` (`status: "moved"`) | +| `verda s3 rm s3://bucket/key -o json` | `--recursive`, `--include`, `--exclude`, `--dryrun`, **`--yes`** | `deleted[]`, `errors[]`, `dryrun` | +| `verda s3 sync -o json` | `--delete`, `--exact-timestamps`, `--include`, `--exclude`, `--dryrun` | `transfers[]`, `deleted[]`, `summary` | +| `verda s3 mb s3://bucket -o json` | — | `bucket`, `created` | +| `verda s3 rb s3://bucket -o json` | `--force` (empty first), **`--yes`** | `bucket`, `removed`, `objects_deleted` | +| `verda s3 presign s3://bucket/key -o json` | `--expires-in` (e.g. `15m`, `24h`; default `1h`) | `url`, `expires_at` (table mode prints the bare URL to stdout) | + +Rules: +- **`src`/`dst`**: at least one must be an `s3://bucket/key` URI; the other may be a local path (upload/download) or another `s3://` URI (server-side copy). +- **Destructive** (`rm`, `rb`): require `--yes` in `--agent` mode, else `CONFIRMATION_REQUIRED`. `cp`/`mv`/`sync` never prompt — the verb is the commitment. +- **`--dryrun`** previews `rm`/`cp`/`mv`/`sync` (esp. recursive + `sync --delete`) with no changes — prefer it before bulk operations. +- **Part size** accepts `MiB`/`GiB` (and loose `MB`/`M`, treated as binary). Large single-file `cp` uploads/downloads are multipart, parallel, and resumable (re-run the same command); `--no-resume` forces a fresh transfer. +- **`configure`** is interactive (tell the user to run it); everything else takes `--agent -o json`. + ## Spot VMs - Add `--is-spot` and `--os-volume-on-spot-discontinue keep_detached` to create command @@ -160,3 +193,5 @@ Hostname patterns: `{random}` → random words, `{location}` → location code | volume ID | `volume list` | `id` | | VM ID / hostname | `vm list` | `id`, `hostname` | | template name | `template list` | `name` | +| bucket name | `s3 ls` | `buckets[].name` | +| object key | `s3 ls s3://bucket` | `objects[].key` | diff --git a/internal/verda-cli/cmd/auth/use.go b/internal/verda-cli/cmd/auth/use.go index 7c70020..d522668 100644 --- a/internal/verda-cli/cmd/auth/use.go +++ b/internal/verda-cli/cmd/auth/use.go @@ -19,6 +19,7 @@ import ( "os" "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" "go.yaml.in/yaml/v3" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" @@ -58,7 +59,7 @@ func NewCmdUse(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { return fmt.Errorf("no profiles found in %s — run 'verda auth login' first", path) } - idx, err := f.Prompter().Select(cmd.Context(), "Select profile", profiles) + 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. } diff --git a/internal/verda-cli/cmd/banner.go b/internal/verda-cli/cmd/banner.go new file mode 100644 index 0000000..454caa8 --- /dev/null +++ b/internal/verda-cli/cmd/banner.go @@ -0,0 +1,56 @@ +// 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 cmd + +import ( + _ "embed" + "encoding/base64" + "fmt" + "io" + "os" + + "github.com/charmbracelet/x/term" +) + +//go:embed verda-logo.png +var verdaLogoPNG []byte + +// printBanner draws the embedded logo via iTerm2 inline-image OSC when stdout +// is a TTY and the terminal is known to support it; otherwise it no-ops so +// unsupported terminals never print raw escape garbage. +func printBanner(out io.Writer) { + f, ok := out.(*os.File) + if !ok || !term.IsTerminal(f.Fd()) { + return + } + if !supportsITermImageProtocol() { + return + } + enc := base64.StdEncoding.EncodeToString(verdaLogoPNG) + // Height in terminal rows; width follows aspect ratio. + _, _ = fmt.Fprintf(f, + "\x1b]1337;File=inline=1;height=6;preserveAspectRatio=1:%s\x07\n\n", + enc) +} + +// supportsITermImageProtocol is best-effort TERM_PROGRAM/LC_TERMINAL sniffing. +// Wrong guesses omit the banner or print a stray escape; data paths are unaffected. +func supportsITermImageProtocol() bool { + switch os.Getenv("TERM_PROGRAM") { + case "iTerm.app", "WezTerm": + return true + } + return os.Getenv("LC_TERMINAL") == "iTerm2" +} diff --git a/internal/verda-cli/cmd/banner_test.go b/internal/verda-cli/cmd/banner_test.go new file mode 100644 index 0000000..0d8d9d3 --- /dev/null +++ b/internal/verda-cli/cmd/banner_test.go @@ -0,0 +1,28 @@ +// 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 cmd + +import ( + "bytes" + "testing" +) + +func TestPrintBanner_NoOpForNonTTYWriter(t *testing.T) { + var buf bytes.Buffer + printBanner(&buf) + if buf.Len() != 0 { + t.Fatalf("banner leaked into non-TTY writer: %q", buf.String()) + } +} diff --git a/internal/verda-cli/cmd/cmd.go b/internal/verda-cli/cmd/cmd.go index 30e069f..a5e82a8 100644 --- a/internal/verda-cli/cmd/cmd.go +++ b/internal/verda-cli/cmd/cmd.go @@ -37,6 +37,7 @@ import ( mcpcmd "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/mcp" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/registry" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/s3" + "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/serverless" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/settings" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/skills" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/ssh" @@ -70,21 +71,17 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op _, _ = fmt.Fprint(cmd.OutOrStdout(), versionOutput()) return ErrVersionRequested } + printBanner(cmd.OutOrStdout()) return cmd.Help() }, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Agent mode always implies JSON output and no TUI. Apply - // this before the credential-skip check so commands that - // bypass Complete() (skills, mcp serve, auth show/use) still - // get the right output mode and suppress spinners. + // --agent forces JSON before credential skip paths run Complete(). if opts.Agent { opts.Output = "json" } - // Skip heavy credential resolution for commands that don't need it: - // - mcp serve: defers auth to the first tool call - // - auth show: diagnostic command that should work even without valid credentials - // - auth use: switches profiles, doesn't need current credentials + // Commands that legitimately run without resolved credentials (mcp serve, + // auth show/use, registry/s3/skills trees, doctor). if skipCredentialResolution(cmd) { log.Init(opts.Log) return nil @@ -94,26 +91,22 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op return err } log.Init(opts.Log) - // Apply saved theme (best effort). + // Best-effort theme from ~/.verda config. if theme := viper.GetString("settings.theme"); theme != "" { bubbletea.SetThemeByName(theme) } return nil }, PersistentPostRun: func(cmd *cobra.Command, _ []string) { - // Version-update hint is best-effort and ONLY runs on commands - // where the user is plausibly interested in version info - // (doctor, update, help, and bare `verda`). Business commands - // like `vm list` or `vccr ls` never do a network fetch for a - // cosmetic hint. See shouldCheckVersion below. + // Update hint: read disk cache only (doctor refreshes it over the network). if opts.Agent || opts.Output != "table" { return } if !shouldCheckVersion(cmd) { return } - latest, current, err := cmdutil.CheckVersion(cmd.Context()) - if err != nil { + latest, current, err := cmdutil.CheckVersionFromCache() + if err != nil || latest == "" { return } cmdutil.PrintVersionHint(ioStreams.ErrOut, latest, current) @@ -135,21 +128,19 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op initConfig(viper.GetString(clioptions.FlagConfig)) }) - f := cmdutil.NewFactory(opts) + f := cmdutil.NewFactory(opts, ioStreams.ErrOut) resourceCmds := []*cobra.Command{ availability.NewCmdAvailability(f, ioStreams), images.NewCmdImages(f, ioStreams), instancetypes.NewCmdInstanceTypes(f, ioStreams), locations.NewCmdLocations(f, ioStreams), + s3.NewCmdS3(f, ioStreams), sshkey.NewCmdSSHKey(f, ioStreams), startupscript.NewCmdStartupScript(f, ioStreams), template.NewCmdTemplate(f, ioStreams), volume.NewCmdVolume(f, ioStreams), } - if s3Enabled() { - resourceCmds = append(resourceCmds, s3.NewCmdS3(f, ioStreams)) - } if registryEnabled() { resourceCmds = append(resourceCmds, registry.NewCmdRegistry(f, ioStreams)) } @@ -168,25 +159,36 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op ssh.NewCmdSSH(f, ioStreams), }, }, - { + } + if serverlessEnabled() { + groups = append(groups, cmdutil.CommandGroup{ + Message: "Serverless Commands:", + Commands: []*cobra.Command{ + serverless.NewCmdContainer(f, ioStreams), + serverless.NewCmdBatchjob(f, ioStreams), + }, + }) + } + groups = append(groups, + cmdutil.CommandGroup{ Message: "Resource Commands:", Commands: resourceCmds, }, - { + cmdutil.CommandGroup{ Message: "Info Commands:", Commands: []*cobra.Command{ status.NewCmdStatus(f, ioStreams), cost.NewCmdCost(f, ioStreams), }, }, - { + cmdutil.CommandGroup{ Message: "AI Agent Commands:", Commands: []*cobra.Command{ mcpcmd.NewCmdMCP(f, ioStreams), skills.NewCmdSkills(f, ioStreams), }, }, - { + cmdutil.CommandGroup{ Message: "Other Commands:", Commands: []*cobra.Command{ completion.NewCmdCompletion(ioStreams), @@ -195,7 +197,7 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op update.NewCmdUpdate(f, ioStreams), }, }, - } + ) groups.Add(cmd) cmdutil.SetUsageTemplate(cmd, groups) @@ -203,22 +205,19 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op return cmd, opts } -// s3Enabled gates the pre-release S3 object-storage commands. The whole -// command tree is omitted from registration unless VERDA_S3_ENABLED is "1" -// or "true". When the feature ships GA, delete this function, drop the -// gate in NewRootCommand, and remove `Hidden: true` from cmd/s3/s3.go. -func s3Enabled() bool { - v := os.Getenv("VERDA_S3_ENABLED") +// registryEnabled hides the registry subtree unless VERDA_REGISTRY_ENABLED is 1/true (pre-GA). +func registryEnabled() bool { + v := os.Getenv("VERDA_REGISTRY_ENABLED") return v == "1" || v == "true" } -// registryEnabled gates the pre-release Verda Container Registry commands. -// The whole command tree is omitted from registration unless -// VERDA_REGISTRY_ENABLED is "1" or "true". When the feature ships GA, -// delete this function, drop the gate in NewRootCommand, and remove -// `Hidden: true` from cmd/registry/registry.go. -func registryEnabled() bool { - v := os.Getenv("VERDA_REGISTRY_ENABLED") +// serverlessEnabled gates the container + batchjob subtrees behind +// VERDA_SERVERLESS_ENABLED=1/true (pre-GA). Without it the Serverless group is +// not registered; `verda container`/`verda batchjob` return "unknown command". +// The parent commands also set Hidden so they stay out of `verda --help` even +// when a tester flips the env var on. +func serverlessEnabled() bool { + v := os.Getenv("VERDA_SERVERLESS_ENABLED") return v == "1" || v == "true" } @@ -249,29 +248,14 @@ func skipCredentialResolution(cmd *cobra.Command) bool { return false } -// shouldCheckVersion returns true for commands where the user is plausibly -// interested in version information, so it's okay to spend up to a couple of -// seconds on a GitHub fetch after the command runs. Everything else (business -// commands like `vm list`, `vccr ls`, `volume rm`, etc.) must never pay that -// cost for a cosmetic hint — they read no cache and perform no network I/O. -// -// Included: -// - `verda doctor` (explicit diagnostic; network expected) -// - `verda update` (canonical place for version info) -// - `verda help` / help on any -// subcommand via `help` verb (user is reading docs) -// - bare `verda` (no args, prints help — new-user first run) -// -// Not included: every subcommand bare-invocation (e.g. `verda vm` with no -// subcommand). Those also print help, but users running them are browsing -// a specific feature area, not asking about the CLI itself. +// shouldCheckVersion limits PostRun update hints to bare root/help only. +// PostRun never hits the network; doctor/update already surface version noise. func shouldCheckVersion(cmd *cobra.Command) bool { switch cmd.Name() { - case "doctor", "update", "help": + case "help": return true case "verda": - // Root command, typically invoked as `verda` (no args) — cobra - // runs RunE which calls cmd.Help(), then PersistentPostRun. + // Root with no subcommand (prints help then PostRun). return true } return false diff --git a/internal/verda-cli/cmd/cmd_test.go b/internal/verda-cli/cmd/cmd_test.go index 17f21ea..3bb6108 100644 --- a/internal/verda-cli/cmd/cmd_test.go +++ b/internal/verda-cli/cmd/cmd_test.go @@ -46,8 +46,7 @@ func TestSkipCredentialResolution_RegistryChildren(t *testing.T) { } func TestShouldCheckVersion(t *testing.T) { - // newCmd returns a *cobra.Command whose Name() is `name` (cobra derives - // Name from the first token of Use). + // cobra.Command.Name comes from the first word of Use. newCmd := func(name string) *cobra.Command { return &cobra.Command{Use: name} } for _, tc := range []struct { @@ -55,16 +54,14 @@ func TestShouldCheckVersion(t *testing.T) { cmd *cobra.Command want bool }{ - // ---- Yes: CLI-meta commands the user is already asking about. ---- - {"doctor", newCmd("doctor"), true}, - {"update", newCmd("update"), true}, {"help", newCmd("help"), true}, {"verda root (bare)", newCmd("verda"), true}, - // ---- No: resource / business commands. They must NEVER do a - // network fetch or even read the cache to print a cosmetic hint. + {"doctor", newCmd("doctor"), false}, + {"update", newCmd("update"), false}, + {"vm", newCmd("vm"), false}, - {"vm list", newCmd("list"), false}, // subcommand runs with its own Name + {"vm list", newCmd("list"), false}, // leaf Name() is "list", not parent path {"vccr/registry", newCmd("registry"), false}, {"s3", newCmd("s3"), false}, {"volume", newCmd("volume"), false}, @@ -74,8 +71,6 @@ func TestShouldCheckVersion(t *testing.T) { {"completion", newCmd("completion"), false}, {"settings", newCmd("settings"), false}, - // Previously these short-circuited with an early return in PostRun; - // with the new gate, shouldCheckVersion just returns false for them. {"mcp", newCmd("mcp"), false}, {"skills", newCmd("skills"), false}, } { diff --git a/internal/verda-cli/cmd/doctor/doctor.go b/internal/verda-cli/cmd/doctor/doctor.go index 31dfaae..5ffe779 100644 --- a/internal/verda-cli/cmd/doctor/doctor.go +++ b/internal/verda-cli/cmd/doctor/doctor.go @@ -82,14 +82,19 @@ func runDoctor(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream // 3. Authentication valid (skip if creds or API failed) 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) { + return checkCLIVersion(ctx), nil + }) + checks := []checkResult{ credResult, apiResult, authResult, - checkCLIVersion(ctx), // 4. CLI up to date - checkBinaryInstalled(), // 5. Binary installed - checkTemplatesDir(), // 6. Templates directory - checkConfigDir(), // 7. Config directory + versionResult, + checkBinaryInstalled(), + checkTemplatesDir(), + checkConfigDir(), } r := report{Checks: checks} diff --git a/internal/verda-cli/cmd/registry/delete.go b/internal/verda-cli/cmd/registry/delete.go index 809e25b..572ed1d 100644 --- a/internal/verda-cli/cmd/registry/delete.go +++ b/internal/verda-cli/cmd/registry/delete.go @@ -21,6 +21,7 @@ import ( "charm.land/lipgloss/v2" "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/options" @@ -432,7 +433,7 @@ func runDeleteInteractive(ctx context.Context, f cmdutil.Factory, ioStreams cmdu } labels = append(labels, "Exit") - idx, err := prompter.Select(ctx, "Select repository to manage (type to filter)", labels) + idx, err := prompter.Select(ctx, "Select repository to manage (type to filter)", labels, tui.WithShowHints(true)) if err != nil { return nil //nolint:nilerr // intentional: prompter cancel is a clean exit } @@ -481,7 +482,7 @@ func runDeleteRepoMenu(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil for { idx, err := prompter.Select(ctx, - fmt.Sprintf("What would you like to delete in %s?", repo.Name), choices) + fmt.Sprintf("What would you like to delete in %s?", repo.Name), choices, tui.WithShowHints(true)) if err != nil { return true, nil //nolint:nilerr // intentional: prompter cancel is a clean exit } diff --git a/internal/verda-cli/cmd/registry/ls.go b/internal/verda-cli/cmd/registry/ls.go index 9eace93..d4ebc8c 100644 --- a/internal/verda-cli/cmd/registry/ls.go +++ b/internal/verda-cli/cmd/registry/ls.go @@ -22,6 +22,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) @@ -180,7 +181,7 @@ func runLsInteractive(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil. labels = append(labels, "Exit") for { - idx, err := prompter.Select(ctx, "Select repository (type to filter)", labels) + idx, err := prompter.Select(ctx, "Select repository (type to filter)", 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. diff --git a/internal/verda-cli/cmd/s3/README.md b/internal/verda-cli/cmd/s3/README.md index 80e9c8e..774f5ca 100644 --- a/internal/verda-cli/cmd/s3/README.md +++ b/internal/verda-cli/cmd/s3/README.md @@ -2,8 +2,6 @@ AWS-CLI-style object storage commands for Verda's S3-compatible endpoint. Uses a separate credential set (keys prefixed `verda_s3_`) so object-storage access is independent of the main API credentials while still sharing the profile system. -> **Pre-release.** The `s3` command tree is gated behind `VERDA_S3_ENABLED=1` and hidden from `verda --help`. Without the env var, `verda s3 ...` returns "unknown command". When the feature ships GA, drop the gate in `internal/verda-cli/cmd/cmd.go` (`s3Enabled` + the `if`) and remove `Hidden: true` from `internal/verda-cli/cmd/s3/s3.go`. - ## Quick reference | Command | Description | @@ -73,8 +71,62 @@ verda s3 cp ./file s3://my-bucket/key --content-type 'application/json' # preview what would happen verda s3 cp ./dir s3://my-bucket/prefix/ --recursive --dryrun + +# tune throughput for large transfers (uploads and single-object downloads) +verda s3 cp ./big.bin s3://my-bucket/big.bin --concurrency 16 --part-size 32MiB +``` + +### Resumable large transfers + +Single-file uploads and single-object downloads larger than the part size are +multipart, parallel (5 concurrent parts by default), and **resumable**. If a +transfer is interrupted (network drop, Ctrl+C, crash), **re-run the exact same +command** and it continues — only the missing parts are sent/fetched: + +```bash +# upload; if it breaks, run the SAME command again to resume +verda s3 cp ./model.safetensors s3://my-bucket/models/model.safetensors + +# download; re-run to resume (a partial .part is kept until it completes) +verda s3 cp s3://my-bucket/models/model.safetensors ./model.safetensors + +# force a fresh transfer, ignoring any saved progress +verda s3 cp s3://my-bucket/models/model.safetensors ./model.safetensors --no-resume ``` +Resume reuses the **same part size** that the interrupted run used. Passing a +different `--part-size` on the resume (or changing the file) is detected and the +transfer restarts cleanly rather than mixing incompatible part boundaries. Part +sizes accept binary (`MiB`, `GiB`) and the loose `MB`/`M` forms — all treated as +binary (`1MB` = 1048576 bytes). + +How it works: + +- Resume state lives locally: uploads under `~/.verda/s3-uploads/` (+ the + server-side multipart parts); downloads under `~/.verda/s3-downloads/` (+ a + `.part` file). The key is a hash of the **source path + destination**, so + resume requires re-running with the same source and destination. +- Uploads reconcile against the server (`ListParts`); downloads guard against the + object changing with an `If-Match` ETag check (a changed object restarts cleanly). +- A same-host lock prevents two transfers of the same object running at once. +- For incomplete **uploads** specifically, `verda s3 ls-uploads` lists them and + lets you pick one to resume; the staged parts cost storage until completed or + aborted (`verda s3 abort-uploads`). +- Interactive downloads from the `verda s3 ls` browser (per-object **Download** + or the **Download files here…** multi-select) use the same resumable path — + re-selecting Download on an interrupted object resumes from its `.part`. They + save to your **Downloads folder** (`~/Downloads`, created if missing; falls + back to the current directory if the home dir can't be resolved) and pause on a + *Back to list / Exit* summary so the result stays on screen. `cp` keeps writing + to the explicit destination you pass. + - **No silent overwrites:** if a file of the same name already exists locally, + the download is saved as `name-2.ext`, `name-3.ext`, … instead of clobbering + it. A genuine resume of the *same* object keeps its original name (so its + `.part` is continued). Multi-select is scoped to a single folder, so a batch + never spans folders. +- Recursive (`--recursive`), `sync`, and `mv` transfers are not yet resumable + per-file. + ## Moving Same flag surface as `cp`; source is removed on success. @@ -159,11 +211,30 @@ Switch with `--profile staging` on any command, or persist it with `verda auth u ## Interactive vs Non-Interactive -Only `configure` has an interactive wizard. Every other subcommand is one-shot: it takes positional URIs + flags and either succeeds or returns a structured error. +Every command works two ways: **non-interactively** with positional URIs + flags +(scripts, `--agent`, pipes), and **interactively** with a TUI when you omit the +target on a terminal. The interactive path triggers only when stdout is a TTY, +not in `--agent` mode, and the output format is the default `table`; otherwise an +omitted target returns the command help (or a structured error in `--agent`). + +| Command | Interactive trigger (on a TTY) | Flow | +|---------|--------------------------------|------| +| `configure` | any of `--access-key`/`--secret-key`/`--endpoint` missing | credential wizard | +| `ls` | no argument | folder browser (drill in, per-object actions, multi-download) | +| `cp` | no destination (and not a bare `s3://` download) | upload wizard (source → bucket → folder → confirm) | +| `mb` | no argument | prompts for the new bucket name | +| `rb` | no argument | bucket picker, then the destructive confirm | +| `rm` | no argument | folder browser; tick files at a level to delete (red confirm + preview) | +| `mv` | no args, or a single `s3://` source | S3→S3 move/rename wizard (source → dest bucket → dest key → confirm) | + +Notes: - **`configure` wizard**: triggers when any of `--access-key`, `--secret-key`, `--endpoint` is missing. Supply all three (plus optionally `--profile`, `--region`, `--credentials-file`) to skip the wizard entirely. -- **Destructive prompts** (`rb`, `rm`): an interactive `prompter.Confirm()` warns before deletion unless `--yes` is passed. `cp`, `mv`, `sync`, `sync --delete` do not prompt (AWS convention — the verb itself is the commitment). -- **Agent mode** (`--agent`): disables every interactive prompt, implies `--output json`, and requires `--yes` for any destructive operation. Without `--yes`, destructive subcommands return `cmdutil.AgentError{Code: "CONFIRMATION_REQUIRED"}` so calling agents know exactly what to add. +- **Destructive prompts** (`rb`, `rm`): an interactive `prompter.Confirm()` with a red warning + preview runs before deletion unless `--yes` is passed — in both the flag and TUI paths. `cp`, `mv`, `sync`, `sync --delete` do not prompt (AWS convention — the verb itself is the commitment), though the interactive `mv` wizard adds a final confirm. +- **`mv` interactive scope**: the wizard covers S3→S3 moves/renames only; local↔S3 moves still require both explicit arguments (a local path can't be picked in the TUI). +- **`rm` interactive scope**: multi-select is scoped to one folder level; drill into subfolders to delete within them, or use `rm --recursive` for bulk deletes across a whole prefix. +- **Navigation**: `Esc` steps back (ascends a folder / returns to the previous wizard step), `Ctrl+C` exits immediately — never a confirmation dialog on either. +- **Agent mode** (`--agent`): disables every interactive prompt, implies `--output json`, and requires `--yes` for any destructive operation. Without `--yes`, destructive subcommands return `cmdutil.AgentError{Code: "CONFIRMATION_REQUIRED"}` so calling agents know exactly what to add. With no target, bucket/object-targeting commands return a `MISSING_REQUIRED_FLAGS` error rather than prompting. ## Architecture Notes diff --git a/internal/verda-cli/cmd/s3/abortuploads.go b/internal/verda-cli/cmd/s3/abortuploads.go new file mode 100644 index 0000000..70b2d82 --- /dev/null +++ b/internal/verda-cli/cmd/s3/abortuploads.go @@ -0,0 +1,237 @@ +// 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 s3 + +import ( + "context" + "fmt" + "time" + + "charm.land/lipgloss/v2" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// abortedUpload is the structured shape for one aborted multipart upload. +type abortedUpload struct { + Key string `json:"key" yaml:"key"` + UploadID string `json:"upload_id" yaml:"upload_id"` +} + +// abortUploadsPayload is the structured output shape for abort-uploads. +type abortUploadsPayload struct { + Aborted []abortedUpload `json:"aborted" yaml:"aborted"` +} + +type abortUploadsOptions struct { + OlderThan string + Key string + Prefix string + Yes bool +} + +// NewCmdAbortUploads builds the `verda s3 abort-uploads` cobra command. +func NewCmdAbortUploads(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + opts := &abortUploadsOptions{} + + cmd := &cobra.Command{ + Use: "abort-uploads s3://bucket", + Short: "Abort in-progress multipart uploads to reclaim storage", + Long: cmdutil.LongDesc(` + Abort in-progress (incomplete) multipart uploads in a bucket. The + staged parts of an incomplete upload consume real, billed storage + even though the object never appears in "verda s3 ls". Aborting + reclaims that storage. + + Use --older-than to abort only uploads initiated before a given age + (e.g. 7d, 12h), and --key to target a single object key. Without + either, EVERY in-progress upload in the bucket is aborted. + + This is destructive: aborted uploads cannot be resumed and their + parts are deleted. In --agent mode, --yes is required. + `), + Example: cmdutil.Examples(` + # Abort uploads older than 7 days + verda s3 abort-uploads s3://my-bucket --older-than 7d + + # Abort every in-progress upload (with confirmation) + verda s3 abort-uploads s3://my-bucket + + # Abort uploads for a single key + verda s3 abort-uploads s3://my-bucket --key path/to/big.bin + + # Skip the confirmation prompt + verda s3 abort-uploads s3://my-bucket --older-than 30d --yes + `), + // 0 args on a TTY launches the bucket picker; an explicit s3://bucket + // runs directly. --agent errors; non-TTY shows help (no silent prompt). + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + arg, err := resolveBucketArg(cmd, f, ioStreams, args) + if err != nil || arg == "" { + return err + } + return runAbortUploads(cmd, f, ioStreams, opts, arg) + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.OlderThan, "older-than", "", "Only abort uploads initiated before this age (e.g. 7d, 12h)") + flags.StringVar(&opts.Key, "key", "", "Only abort uploads for this exact object key") + flags.StringVar(&opts.Prefix, "prefix", "", "Only abort uploads whose key starts with this prefix") + flags.BoolVar(&opts.Yes, "yes", false, "Skip confirmation prompt") + + return cmd +} + +func runAbortUploads(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *abortUploadsOptions, arg string) error { + uri, err := ParseS3URI(arg) + if err != nil { + return cmdutil.UsageErrorf(cmd, "%v", err) + } + age, err := parseOlderThan(opts.OlderThan) + if err != nil { + return cmdutil.UsageErrorf(cmd, "%v", err) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + + // Destructive guard: agent mode requires --yes. + if f.AgentMode() && !opts.Yes { + return cmdutil.NewConfirmationRequiredError("abort-uploads") + } + + client, err := buildClient(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + + prefix := firstNonEmpty(opts.Prefix, uri.Key) + listed, err := collectUploads(ctx, f, ioStreams, client, uri.Bucket, prefix) + if err != nil { + return err + } + + targets := filterAbortTargets(listed.Uploads, opts.Key, age) + if len(targets) == 0 { + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), abortUploadsPayload{Aborted: []abortedUpload{}}); wrote { + return werr + } + _, _ = fmt.Fprintln(ioStreams.Out, "No matching in-progress uploads to abort.") + return nil + } + + if !opts.Yes && !f.AgentMode() { + confirmed, confirmErr := confirmAbort(ctx, f, ioStreams, uri.Bucket, targets) + 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 + } + } + + return executeAbort(ctx, f, ioStreams, client, uri.Bucket, targets) +} + +// filterAbortTargets narrows the listed uploads to those matching --key (exact) +// and --older-than (initiated before now-age). A zero age means no age filter. +func filterAbortTargets(uploads []uploadEntry, key string, age time.Duration) []uploadEntry { + var cutoff time.Time + if age > 0 { + cutoff = time.Now().Add(-age) + } + targets := make([]uploadEntry, 0, len(uploads)) + for i := range uploads { + if key != "" && uploads[i].Key != key { + continue + } + if age > 0 && !uploads[i].Initiated.Before(cutoff) { + continue + } + targets = append(targets, uploads[i]) + } + return targets +} + +// confirmAbort prints the destructive warning + preview and asks to confirm. +func confirmAbort(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, bucket string, targets []uploadEntry) (bool, error) { + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) + _, _ = fmt.Fprintln(ioStreams.ErrOut) + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n", + warnStyle.Render(fmt.Sprintf("This will abort %d in-progress upload(s) in s3://%s and delete their staged parts", len(targets), bucket))) + + preview := targets + if len(preview) > previewCap { + preview = preview[:previewCap] + } + for i := range preview { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " - s3://%s/%s (%s)\n", bucket, preview[i].Key, preview[i].UploadID) + } + if more := len(targets) - len(preview); more > 0 { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " … and %d more\n", more) + } + _, _ = fmt.Fprintln(ioStreams.ErrOut) + return f.Prompter().Confirm(ctx, fmt.Sprintf("Abort %d upload(s) in s3://%s?", len(targets), bucket)) +} + +// executeAbort issues an AbortMultipartUpload per target and renders results. +func executeAbort(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, bucket string, targets []uploadEntry) error { + payload := abortUploadsPayload{Aborted: make([]abortedUpload, 0, len(targets))} + + for i := range targets { + var sp interface{ Stop(string) } + if status := f.Status(); status != nil { + sp, _ = status.Spinner(ctx, fmt.Sprintf("Aborting s3://%s/%s...", bucket, targets[i].Key)) + } + out, err := client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{ + Bucket: aws.String(bucket), + Key: aws.String(targets[i].Key), + UploadId: aws.String(targets[i].UploadID), + }) + if sp != nil { + sp.Stop("") + } + if err != nil { + // An upload aborted/expired between list and now is already gone. + if isNoSuchUpload(err) { + payload.Aborted = append(payload.Aborted, abortedUpload{Key: targets[i].Key, UploadID: targets[i].UploadID}) + continue + } + return translateError(err) + } + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "AbortMultipartUpload response:", out) + payload.Aborted = append(payload.Aborted, abortedUpload{Key: targets[i].Key, UploadID: targets[i].UploadID}) + } + + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), payload); wrote { + return werr + } + + for i := range payload.Aborted { + _, _ = fmt.Fprintf(ioStreams.Out, "✓ aborted s3://%s/%s\n", bucket, payload.Aborted[i].Key) + } + _, _ = fmt.Fprintf(ioStreams.Out, "%d upload(s) aborted\n", len(payload.Aborted)) + return nil +} diff --git a/internal/verda-cli/cmd/s3/browse.go b/internal/verda-cli/cmd/s3/browse.go new file mode 100644 index 0000000..bca30af --- /dev/null +++ b/internal/verda-cli/cmd/s3/browse.go @@ -0,0 +1,443 @@ +// 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 s3 + +import ( + "context" + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strconv" + "strings" + + "charm.land/lipgloss/v2" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/verda-cloud/verdagostack/pkg/tui" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// rowKind tags each browser row so a Select index maps back to an action. +type rowKind int + +const ( + rowUp rowKind = iota + rowExit + rowFolder + rowObject + rowDownloadMulti + rowDeleteMulti +) + +type browseRow struct { + kind rowKind + label string + value string // folder: full prefix; object: full key + size int64 +} + +// runLsBrowser is the interactive S3 explorer launched by `verda s3 ls` with no +// argument on a terminal. It walks buckets -> prefixes -> objects (one +// ListObjectsV2 delimiter level at a time) and offers per-object actions +// (download / info / delete). Esc ascends one level; Ctrl+C exits. +func runLsBrowser(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API) error { + // Best-effort prune of stale download checkpoints + shared lock files so + // download-only users (who never hit the upload-path GC) don't accumulate them. + _ = gcDownloadCheckpoints(0) + _ = gcCheckpoints(0) + + cur := URI{} // empty Bucket == root (the bucket list) + for { + if cur.Bucket == "" { + next, exit, err := browseBuckets(ctx, f, ioStreams, client) + if err != nil || exit { + return err + } + if next != "" { + cur = URI{Bucket: next} + } + continue + } + + again, err := browseLevel(ctx, f, ioStreams, client, &cur) + if err != nil { + return err + } + if !again { + return nil + } + } +} + +// 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) { + return client.ListBuckets(ctx, &s3.ListBucketsInput{}) + }) + if err != nil { + return "", true, translateError(err) + } + if len(out.Buckets) == 0 { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "No buckets found.") + return "", true, nil + } + + labels := make([]string, 0, len(out.Buckets)+1) + for i := range out.Buckets { + labels = append(labels, "📦 "+aws.ToString(out.Buckets[i].Name)) + } + labels = append(labels, "Exit") + + idx, err := f.Prompter().Select(ctx, "Select bucket", labels, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", true, nil + } + return "", true, err + } + if idx == len(out.Buckets) { // Exit + return "", true, nil + } + return aws.ToString(out.Buckets[idx].Name), false, nil +} + +// browseLevel lists one delimiter level under cur and handles the selection. +// 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) { + return collectObjects(ctx, f, ioStreams, client, *cur, "/") + }) + if err != nil { + return false, err + } + + rows := buildBrowseRows(*cur, payload) + labels := make([]string, len(rows)) + for i := range rows { + labels[i] = rows[i].label + } + + idx, err := f.Prompter().Select(ctx, browseBreadcrumb(*cur), labels, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptInterrupt(err) { + return false, nil // Ctrl+C exits the browser + } + if cmdutil.IsPromptBack(err) { + ascend(cur) // Esc goes up one level + return true, nil + } + return false, err + } + + switch row := rows[idx]; row.kind { + case rowUp: + ascend(cur) + case rowExit: + return false, nil + case rowFolder: + cur.Key = row.value + case rowDownloadMulti: + exit, err := browseDownloadMulti(ctx, f, ioStreams, client, *cur, payload) + if err != nil { + return false, err + } + if exit { + return false, nil + } + case rowObject: + exit, err := objectActionMenu(ctx, f, ioStreams, client, URI{Bucket: cur.Bucket, Key: row.value}, row.size) + if err != nil { + return false, err + } + if exit { + return false, nil + } + } + return true, nil +} + +// buildBrowseRows orders the rows: up, [download-multi], folders, objects, exit. +func buildBrowseRows(cur URI, payload objectsPayload) []browseRow { + objRows := make([]browseRow, 0, len(payload.Objects)) + for i := range payload.Objects { + o := &payload.Objects[i] + name := relName(cur.Key, o.Key) + if name == "" { + continue // the prefix placeholder object, if any + } + objRows = append(objRows, browseRow{ + kind: rowObject, + label: fmt.Sprintf("📄 %-40s %10s %s", name, humanBytes(o.Size), o.Modified.UTC().Format(timestampLayout)), + value: o.Key, + size: o.Size, + }) + } + + rows := make([]browseRow, 0, len(payload.CommonPrefixes)+len(objRows)+3) + rows = append(rows, browseRow{kind: rowUp, label: "↑ .."}) + if len(objRows) > 0 { + rows = append(rows, browseRow{kind: rowDownloadMulti, label: "⬇ Download files here…"}) + } + for _, p := range payload.CommonPrefixes { + rows = append(rows, browseRow{kind: rowFolder, label: "📁 " + relName(cur.Key, p), value: p}) + } + rows = append(rows, objRows...) + rows = append(rows, browseRow{kind: rowExit, label: "Exit"}) + return rows +} + +// browseBreadcrumb renders the current path as the Select title. +func browseBreadcrumb(cur URI) string { + if cur.Key == "" { + return "s3://" + cur.Bucket + "/" + } + return "s3://" + cur.Bucket + "/" + cur.Key +} + +// ascend moves cur up one prefix level; at the bucket root it clears the bucket +// (returning to the bucket list). +func ascend(cur *URI) { + if cur.Key == "" { + cur.Bucket = "" + return + } + trimmed := strings.TrimSuffix(cur.Key, "/") + if i := strings.LastIndex(trimmed, "/"); i >= 0 { + cur.Key = trimmed[:i+1] + } else { + cur.Key = "" + } +} + +// relName returns the segment of full beneath prefix (the display name), with +// any trailing slash preserved for folders. +func relName(prefix, full string) string { + return strings.TrimPrefix(full, prefix) +} + +// objectActionMenu presents the per-object actions (Download / Info / Delete). +// Returns exit=true when the user chose to leave the browser after the action. +func objectActionMenu(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, obj URI, size int64) (bool, error) { + name := path.Base(obj.Key) + const ( + actDownload = iota + actInfo + actDelete + actBack + ) + labels := []string{"Download", "Info", "Delete", "← Back"} + idx, err := f.Prompter().Select(ctx, fmt.Sprintf("%s (%s)", name, humanBytes(size)), labels, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return false, nil + } + return false, err + } + switch idx { + case actDownload: + return browseDownload(ctx, f, ioStreams, client, obj) + case actInfo: + return false, browseInfo(ctx, f, ioStreams, client, obj) + case actDelete: + return false, browseDelete(ctx, f, ioStreams, client, obj) + default: + return false, nil + } +} + +// browseDownload downloads one object to the user's Downloads folder via the +// resumable downloader, so re-selecting Download on an interrupted object +// resumes from its .part file. Pauses on a Back/Exit gate after completion so +// the summary stays on screen instead of snapping back to the list. +func browseDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, obj URI) (bool, error) { + dir, derr := defaultDownloadDir() + if derr != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " note: %v — saving to the current directory\n", derr) + } + local := resolveLocalDest(dir, filepath.Base(obj.Key), obj.Bucket, obj.Key, map[string]bool{}) + announceRename(ioStreams, obj.Key, local) + n, rate, err := downloadToLocal(ctx, f, ioStreams, client, obj, local, 0, defaultConcurrency, false, false) + if err != nil { + return false, err + } + _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)%s\n", obj.String(), absOrSelf(local), humanBytes(n), rate) + return cmdutil.PromptBackOrExit(ctx, f.Prompter()) +} + +// browseDownloadMulti multi-selects objects at the current level and downloads +// the ticked set to the user's Downloads folder. Selection is scoped to ONE +// folder by construction — only the current level's direct objects are listed, +// subfolders are non-selectable drill-in entries — so the picked set never spans +// folders. Each file is placed via resolveLocalDest so an existing local file of +// the same name is renamed rather than overwritten. Non-destructive, so no +// confirmation. Pauses on a Back/Exit gate after the summary so it stays on screen. +func browseDownloadMulti(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, cur URI, payload objectsPayload) (bool, error) { + objs := make([]objectEntry, 0, len(payload.Objects)) + labels := make([]string, 0, len(payload.Objects)) + for i := range payload.Objects { + name := relName(cur.Key, payload.Objects[i].Key) + if name == "" { + continue + } + objs = append(objs, payload.Objects[i]) + labels = append(labels, fmt.Sprintf("%s (%s)", name, humanBytes(payload.Objects[i].Size))) + } + if len(objs) == 0 { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "No files to download at this level.") + return false, nil + } + + idxs, err := f.Prompter().MultiSelect(ctx, "Select files to download (space to toggle)", labels, tui.WithMultiSelectShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return false, nil + } + return false, err + } + if len(idxs) == 0 { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Nothing selected.") + return false, nil + } + + dir, derr := defaultDownloadDir() + if derr != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " note: %v — saving to the current directory\n", derr) + } + used := map[string]bool{} + var total int64 + for _, ix := range idxs { + obj := URI{Bucket: cur.Bucket, Key: objs[ix].Key} + local := resolveLocalDest(dir, filepath.Base(obj.Key), obj.Bucket, obj.Key, used) + announceRename(ioStreams, obj.Key, local) + n, rate, dlErr := downloadToLocal(ctx, f, ioStreams, client, obj, local, 0, defaultConcurrency, false, false) + if dlErr != nil { + return false, fmt.Errorf("downloading %s: %w", obj.String(), dlErr) + } + total += n + _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)%s\n", obj.String(), absOrSelf(local), humanBytes(n), rate) + } + _, _ = fmt.Fprintf(ioStreams.Out, "Downloaded %d file(s), %s total -> %s\n", len(idxs), humanBytes(total), absOrSelf(dir)) + return cmdutil.PromptBackOrExit(ctx, f.Prompter()) +} + +// defaultDownloadDir returns the user's Downloads folder (created if needed) for +// interactive browser downloads. On failure it returns "." (the current +// directory) plus a non-nil reason so the caller can tell the user where the +// file actually landed. cp uses its explicit destination argument instead. +func defaultDownloadDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return ".", errors.New("could not resolve your home directory") + } + dir := filepath.Join(home, "Downloads") + if mkErr := os.MkdirAll(dir, 0o750); mkErr != nil { + return ".", fmt.Errorf("could not use %s: %w", dir, mkErr) + } + return dir, nil +} + +// resolveLocalDest picks the local path for downloading obj into dir without +// clobbering an unrelated local file, while still allowing a genuine resume. +// It returns dir/ when that name is free OR is an in-progress resume of +// THIS object (its .part + checkpoint match); otherwise it appends "-2", "-3", … +// before the extension until it finds a name that is neither an existing file +// nor a foreign .part. used tracks names already handed out in the same batch. +func resolveLocalDest(dir, base, bucket, key string, used map[string]bool) string { + ext := filepath.Ext(base) + stem := strings.TrimSuffix(base, ext) + for i := 0; ; i++ { + name := base + if i > 0 { + name = stem + "-" + strconv.Itoa(i+1) + ext + } + if used[name] { + continue + } + full := filepath.Join(dir, name) + if fileExists(full + ".part") { + if hasResumableDownload(full, bucket, key) { + used[name] = true + return full // our interrupted download → resume into it + } + continue // a foreign partial download owns this name → don't clobber + } + if fileExists(full) { + continue // unrelated completed file → don't overwrite + } + used[name] = true + return full + } +} + +// announceRename notes when resolveLocalDest had to pick a non-default filename +// to avoid overwriting an existing local file. A resume (name unchanged) is +// silent; the downloader prints its own "Resuming…" line. +func announceRename(ioStreams cmdutil.IOStreams, key, local string) { + if want := path.Base(key); filepath.Base(local) != want { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s already exists locally — saving as %s\n", want, filepath.Base(local)) + } +} + +// 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) { + return client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(obj.Bucket), Key: aws.String(obj.Key)}) + }) + if err != nil { + return translateError(err) + } + label := lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + w := ioStreams.Out + _, _ = fmt.Fprintf(w, "\n %s\n", label.Render(obj.String())) + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Size"), humanBytes(aws.ToInt64(head.ContentLength))) + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Modified"), aws.ToTime(head.LastModified).UTC().Format(timestampLayout)) + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("ETag"), aws.ToString(head.ETag)) + if ct := aws.ToString(head.ContentType); ct != "" { + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Type"), ct) + } + if sc := string(head.StorageClass); sc != "" { + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Storage"), sc) + } + _, _ = fmt.Fprintln(w) + return nil +} + +// browseDelete confirms then deletes a single object (reusing the destructive +// red-warning + prompter.Confirm convention). +func browseDelete(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, obj URI) error { + warn := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) + _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n\n", warn.Render("This will permanently delete "+obj.String())) + confirmed, err := f.Prompter().Confirm(ctx, fmt.Sprintf("Delete %s?", obj.String())) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil + } + return err + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + if _, err := client.DeleteObject(ctx, &s3.DeleteObjectInput{Bucket: aws.String(obj.Bucket), Key: aws.String(obj.Key)}); err != nil { + return translateError(err) + } + _, _ = fmt.Fprintf(ioStreams.Out, "✓ deleted %s\n", obj.String()) + return nil +} diff --git a/internal/verda-cli/cmd/s3/browse_test.go b/internal/verda-cli/cmd/s3/browse_test.go new file mode 100644 index 0000000..9c3ae76 --- /dev/null +++ b/internal/verda-cli/cmd/s3/browse_test.go @@ -0,0 +1,220 @@ +// 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 s3 + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// browseFakeAPI is prefix-aware: the root level exposes a "data/" folder, and +// the "data/" level exposes a single object. It records DeleteObject calls. +type browseFakeAPI struct { + API + deleteInputs []*s3.DeleteObjectInput + dlBody []byte // served by Head/GetObject for resumable browser downloads +} + +func (b *browseFakeAPI) ListBuckets(ctx context.Context, in *s3.ListBucketsInput, opts ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { + return &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("b")}}}, nil +} + +func (b *browseFakeAPI) ListObjectsV2(ctx context.Context, in *s3.ListObjectsV2Input, opts ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { + out := &s3.ListObjectsV2Output{IsTruncated: aws.Bool(false)} + switch aws.ToString(in.Prefix) { + case "": + out.CommonPrefixes = []s3types.CommonPrefix{{Prefix: aws.String("data/")}} + case "data/": + out.Contents = []s3types.Object{{Key: aws.String("data/file.txt"), Size: aws.Int64(1024)}} + } + return out, nil +} + +func (b *browseFakeAPI) DeleteObject(ctx context.Context, in *s3.DeleteObjectInput, opts ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { + b.deleteInputs = append(b.deleteInputs, in) + return &s3.DeleteObjectOutput{}, nil +} + +func (b *browseFakeAPI) HeadObject(ctx context.Context, in *s3.HeadObjectInput, opts ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { + return &s3.HeadObjectOutput{ContentLength: aws.Int64(int64(len(b.dlBody))), ETag: aws.String("\"e\"")}, nil +} + +func (b *browseFakeAPI) GetObject(ctx context.Context, in *s3.GetObjectInput, opts ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + body := b.dlBody + if rng := aws.ToString(in.Range); rng != "" { + var start, end int64 + if _, err := fmt.Sscanf(rng, "bytes=%d-%d", &start, &end); err == nil && start <= end && end < int64(len(body)) { + body = body[start : end+1] + } + } + return &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(body))}, nil +} + +// TestBrowse_DrillDownDeleteAndExit walks bucket -> data/ folder -> file.txt, +// deletes it via the action menu, then exits — exercising browseBuckets, +// browseLevel, buildBrowseRows, objectActionMenu and browseDelete. +func TestBrowse_DrillDownDeleteAndExit(t *testing.T) { + // no t.Parallel — prompter/clientBuilder state + fake := &browseFakeAPI{} + + // Select sequence: + // 0 -> bucket "b" + // 1 -> folder "data/" (root rows: up, 📁data/, exit — no objects, no multi row) + // 2 -> object file.txt (data/ rows: up, ⬇download-multi, 📄file.txt, exit) + // 2 -> Delete (menu: Download, Info, Delete, Back) + // 3 -> Exit (post-delete re-list: up, ⬇download-multi, 📄file.txt, exit) + mock := tuitest.New(). + AddSelect(0).AddSelect(1).AddSelect(2).AddSelect(2).AddSelect(3). + AddConfirm(true) + + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + f := cmdutil.NewTestFactory(mock) + + if err := runLsBrowser(context.Background(), f, cmdutil.IOStreams{Out: out, ErrOut: errOut}, fake); err != nil { + t.Fatalf("runLsBrowser: %v", err) + } + + if len(fake.deleteInputs) != 1 { + t.Fatalf("DeleteObject calls = %d, want 1", len(fake.deleteInputs)) + } + if k := aws.ToString(fake.deleteInputs[0].Key); k != "data/file.txt" { + t.Errorf("deleted key = %q, want data/file.txt", k) + } + if !strings.Contains(out.String(), "deleted") { + t.Errorf("stdout missing delete confirmation:\n%s", out.String()) + } + if !strings.Contains(errOut.String(), "permanently delete") { + t.Errorf("stderr missing destructive warning:\n%s", errOut.String()) + } +} + +// TestBrowse_MultiDownload drills into data/, opens the multi-download entry, +// ticks the one object, downloads it, then exits. +func TestBrowse_MultiDownload(t *testing.T) { + // no t.Parallel — prompter/cwd/~/.verda state + withTempVerdaHome(t) // resumable downloader writes checkpoint/lock here + home := t.TempDir() + t.Setenv("HOME", home) // browser downloads land in $HOME/Downloads + + fake := &browseFakeAPI{dlBody: []byte("hello-world")} + + // Selects: bucket(0) -> folder data/(1) -> Download-files-here(1) -> Back/Exit gate Exit(1) + // MultiSelect: tick the single object [0]. + mock := tuitest.New(). + AddSelect(0).AddSelect(1).AddSelect(1).AddSelect(1). + AddMultiSelect([]int{0}) + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(mock) + + if err := runLsBrowser(context.Background(), f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}, fake); err != nil { + t.Fatalf("runLsBrowser: %v", err) + } + + // The resumable downloader wrote file.txt (basename of data/file.txt) to ~/Downloads. + got, err := os.ReadFile(filepath.Join(home, "Downloads", "file.txt")) //nolint:gosec // G304: reads from the test's own temp HOME + if err != nil || !bytes.Equal(got, fake.dlBody) { + t.Errorf("downloaded file.txt mismatch (err=%v, got=%q)", err, got) + } + if !strings.Contains(out.String(), "Downloaded 1 file(s)") { + t.Errorf("stdout missing multi-download summary:\n%s", out.String()) + } +} + +// TestResolveLocalDest covers the local-overwrite policy: a free name is used +// as-is, an existing unrelated file is dodged with a "-N" suffix, an in-progress +// resume of the SAME object keeps its name, and batch-assigned names don't repeat. +func TestResolveLocalDest(t *testing.T) { + withTempVerdaHome(t) // hasResumableDownload reads checkpoints under VERDA_HOME + dir := t.TempDir() + used := map[string]bool{} + + // 1. Free name -> used verbatim. + if got := resolveLocalDest(dir, "report.pdf", "b", "k1", used); got != filepath.Join(dir, "report.pdf") { + t.Errorf("free name = %q, want report.pdf", got) + } + // 2. Same batch again -> the just-assigned name is taken, so suffix. + if got := resolveLocalDest(dir, "report.pdf", "b", "k1", used); got != filepath.Join(dir, "report-2.pdf") { + t.Errorf("batch repeat = %q, want report-2.pdf", got) + } + + // 3. An unrelated completed file on disk -> dodge it. + if err := os.WriteFile(filepath.Join(dir, "data.bin"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if got := resolveLocalDest(dir, "data.bin", "b", "k2", map[string]bool{}); got != filepath.Join(dir, "data-2.bin") { + t.Errorf("existing-file dodge = %q, want data-2.bin", got) + } + + // 4. A resumable .part for THIS object at the default name -> keep the name. + partBase := filepath.Join(dir, "model.safetensors") + if err := os.WriteFile(partBase+".part", []byte("partial"), 0o600); err != nil { + t.Fatal(err) + } + abs, _ := filepath.Abs(partBase) + if err := saveDownloadCheckpoint(downloadIdentity(abs, "b", "k3"), &downloadCheckpoint{ + Bucket: "b", Key: "k3", DestPath: partBase, + }); err != nil { + t.Fatal(err) + } + if got := resolveLocalDest(dir, "model.safetensors", "b", "k3", map[string]bool{}); got != partBase { + t.Errorf("resume = %q, want the original name %q (must not dodge its own .part)", got, partBase) + } + + // 5. A foreign .part (no matching checkpoint) -> treated as occupied, dodge. + if err := os.WriteFile(filepath.Join(dir, "foreign.bin")+".part", []byte("partial"), 0o600); err != nil { + t.Fatal(err) + } + if got := resolveLocalDest(dir, "foreign.bin", "b", "k4", map[string]bool{}); got != filepath.Join(dir, "foreign-2.bin") { + t.Errorf("foreign .part dodge = %q, want foreign-2.bin", got) + } +} + +func TestAscend(t *testing.T) { + t.Parallel() + cases := []struct{ in, want string }{ + {"data/sub/", "data/"}, + {"data/", ""}, + {"data/file.txt", "data/"}, + {"", ""}, // bucket cleared separately; key stays "" + } + for _, tc := range cases { + cur := URI{Bucket: "b", Key: tc.in} + ascend(&cur) + if tc.in == "" { + if cur.Bucket != "" { + t.Errorf("ascend at key root should clear bucket, got %q", cur.Bucket) + } + continue + } + if cur.Key != tc.want { + t.Errorf("ascend(%q) key = %q, want %q", tc.in, cur.Key, tc.want) + } + } +} diff --git a/internal/verda-cli/cmd/s3/checkpoint.go b/internal/verda-cli/cmd/s3/checkpoint.go new file mode 100644 index 0000000..9902963 --- /dev/null +++ b/internal/verda-cli/cmd/s3/checkpoint.go @@ -0,0 +1,204 @@ +// 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 s3 + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/verda-cloud/verda-cli/internal/verda-cli/options" +) + +// checkpointDirName is the subdirectory under the Verda config dir that holds +// one JSON file per in-progress resumable upload. +const checkpointDirName = "s3-uploads" + +// checkpointMaxAge bounds how long a stale local checkpoint is kept before GC +// prunes it. The server is authoritative (ListParts reconciles), so this only +// limits local clutter; it is intentionally generous. +const checkpointMaxAge = 7 * 24 * time.Hour + +// checkpointPart is one completed part recorded in a checkpoint. ETag is stored +// exactly as the server returned it (quotes included) so Complete can echo it. +type checkpointPart struct { + N int32 `json:"n"` + ETag string `json:"etag"` +} + +// checkpoint is the on-disk resume state for a single multipart upload. +// fileSize+mtime form the change-detector; whole-file contents are never hashed. +type checkpoint struct { + UploadID string `json:"uploadId"` + Bucket string `json:"bucket"` + Key string `json:"key"` + AbsPath string `json:"absPath"` + FileSize int64 `json:"fileSize"` + MTime time.Time `json:"mtime"` + PartSize int64 `json:"partSize"` + CreatedAt time.Time `json:"createdAt"` + Parts []checkpointPart `json:"parts"` +} + +// uploadIdentity is the stable checkpoint key: sha256 over the NUL-separated +// triple (absSourcePath, dstBucket, dstKey). Cheap, deterministic across runs, +// and never depends on file contents. +func uploadIdentity(absPath, bucket, key string) string { + h := sha256.New() + h.Write([]byte(absPath)) + h.Write([]byte{0}) + h.Write([]byte(bucket)) + h.Write([]byte{0}) + h.Write([]byte(key)) + return hex.EncodeToString(h.Sum(nil)) +} + +// checkpointDir returns ~/.verda/s3-uploads, creating ~/.verda if needed. +func checkpointDir() (string, error) { + base, err := options.VerdaDir() + if err != nil { + return "", err + } + return filepath.Join(base, checkpointDirName), nil +} + +// checkpointPath maps an identity to its JSON file path. +func checkpointPath(identity string) (string, error) { + dir, err := checkpointDir() + if err != nil { + return "", err + } + return filepath.Join(dir, identity+".json"), nil +} + +// loadCheckpoint reads the checkpoint for identity. A missing file returns +// (nil, nil) — absence is not an error, it just means "no resume state". +func loadCheckpoint(identity string) (*checkpoint, error) { + path, err := checkpointPath(identity) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) // #nosec G304 -- path derived from sha256 identity under ~/.verda + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("read checkpoint: %w", err) + } + var cp checkpoint + if err := json.Unmarshal(data, &cp); err != nil { + // A corrupt checkpoint is treated as absent rather than fatal: the + // caller falls back to a fresh upload, which is always safe. + return nil, nil //nolint:nilerr // intentional: unreadable checkpoint → treat as no resume state + } + return &cp, nil +} + +// saveCheckpoint writes cp atomically (temp file + rename) so a crash mid-write +// never leaves a half-written JSON that would later parse as garbage. +func saveCheckpoint(identity string, cp *checkpoint) error { + dir, err := checkpointDir() + if err != nil { + return err + } + if mkErr := os.MkdirAll(dir, 0o700); mkErr != nil { + return fmt.Errorf("create checkpoint dir: %w", mkErr) + } + path := filepath.Join(dir, identity+".json") + data, err := json.Marshal(cp) + if err != nil { + return fmt.Errorf("marshal checkpoint: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write checkpoint: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("commit checkpoint: %w", err) + } + return nil +} + +// appendPart records a completed part (updating in place if N already exists) +// and flushes the checkpoint to disk so a crash after this point resumes from +// the new part. Insertion order is not maintained; completeUpload and +// reconcileCheckpoint sort by N before the slice is used. +func appendPart(identity string, cp *checkpoint, n int32, etag string) error { + for i := range cp.Parts { + if cp.Parts[i].N == n { + cp.Parts[i].ETag = etag + return saveCheckpoint(identity, cp) + } + } + cp.Parts = append(cp.Parts, checkpointPart{N: n, ETag: etag}) + return saveCheckpoint(identity, cp) +} + +// deleteCheckpoint removes the checkpoint for identity. A missing file is not +// an error — Complete/abort paths call this unconditionally. +func deleteCheckpoint(identity string) error { + path, err := checkpointPath(identity) + if err != nil { + return err + } + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("delete checkpoint: %w", err) + } + return nil +} + +// gcCheckpoints prunes stale upload checkpoint and lock files (both live under +// ~/.verda/s3-uploads). A zero maxAge falls back to checkpointMaxAge. +func gcCheckpoints(maxAge time.Duration) error { + dir, err := checkpointDir() + if err != nil { + return err + } + return gcStaleFiles(dir, maxAge) +} + +// gcStaleFiles removes files in dir whose modtime is older than maxAge (a zero +// maxAge falls back to checkpointMaxAge). Errors on individual files are +// swallowed (best-effort cleanup); a missing directory is a no-op. +func gcStaleFiles(dir string, maxAge time.Duration) error { + if maxAge <= 0 { + maxAge = checkpointMaxAge + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("read checkpoint dir: %w", err) + } + cutoff := time.Now().Add(-maxAge) + for i := range entries { + if entries[i].IsDir() { + continue + } + info, infoErr := entries[i].Info() + if infoErr != nil { + continue + } + if info.ModTime().Before(cutoff) { + _ = os.Remove(filepath.Join(dir, entries[i].Name())) + } + } + return nil +} diff --git a/internal/verda-cli/cmd/s3/checkpoint_test.go b/internal/verda-cli/cmd/s3/checkpoint_test.go new file mode 100644 index 0000000..ed7cc70 --- /dev/null +++ b/internal/verda-cli/cmd/s3/checkpoint_test.go @@ -0,0 +1,191 @@ +// 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 s3 + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// withTempVerdaHome points VERDA_HOME at a temp dir so checkpoint I/O never +// touches the developer's real ~/.verda. Returns a restore func. +func withTempVerdaHome(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("VERDA_HOME", dir) +} + +func TestUploadIdentity_StableAndDistinct(t *testing.T) { + t.Parallel() + a := uploadIdentity("/abs/big.bin", "bucket", "key") + b := uploadIdentity("/abs/big.bin", "bucket", "key") + if a != b { + t.Fatalf("identity not stable: %q vs %q", a, b) + } + // NUL separation: changing the boundary between fields must change identity. + c := uploadIdentity("/abs/big.binbucket", "", "key") + if a == c { + t.Fatalf("identity collision across field boundary: %q", a) + } + for _, other := range []string{ + uploadIdentity("/abs/other.bin", "bucket", "key"), + uploadIdentity("/abs/big.bin", "other", "key"), + uploadIdentity("/abs/big.bin", "bucket", "other"), + } { + if a == other { + t.Fatalf("identity should differ: %q", a) + } + } +} + +func TestCheckpoint_SaveLoadDelete(t *testing.T) { + withTempVerdaHome(t) + id := uploadIdentity("/abs/f", "b", "k") + + if got, err := loadCheckpoint(id); err != nil || got != nil { + t.Fatalf("load before save = (%v, %v), want (nil, nil)", got, err) + } + + cp := &checkpoint{ + UploadID: "u1", + Bucket: "b", + Key: "k", + AbsPath: "/abs/f", + FileSize: 100, + MTime: time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC), + PartSize: minPartSize, + CreatedAt: time.Now().UTC(), + } + if err := saveCheckpoint(id, cp); err != nil { + t.Fatalf("save: %v", err) + } + + got, err := loadCheckpoint(id) + if err != nil { + t.Fatalf("load: %v", err) + } + if got == nil || got.UploadID != "u1" || got.FileSize != 100 { + t.Fatalf("loaded = %+v, want UploadID=u1 FileSize=100", got) + } + if !got.MTime.Equal(cp.MTime) { + t.Errorf("mtime = %v, want %v", got.MTime, cp.MTime) + } + + if err := deleteCheckpoint(id); err != nil { + t.Fatalf("delete: %v", err) + } + if got, err := loadCheckpoint(id); err != nil || got != nil { + t.Fatalf("load after delete = (%v, %v), want (nil, nil)", got, err) + } + // Delete is idempotent. + if err := deleteCheckpoint(id); err != nil { + t.Fatalf("second delete: %v", err) + } +} + +func TestCheckpoint_AppendPart(t *testing.T) { + withTempVerdaHome(t) + id := uploadIdentity("/abs/f", "b", "k") + cp := &checkpoint{UploadID: "u1", Bucket: "b", Key: "k", AbsPath: "/abs/f"} + if err := saveCheckpoint(id, cp); err != nil { + t.Fatalf("save: %v", err) + } + + for n := int32(1); n <= 3; n++ { + if err := appendPart(id, cp, n, "etag"+string('0'+n)); err != nil { + t.Fatalf("append %d: %v", n, err) + } + } + // Re-appending the same part updates rather than duplicates. + if err := appendPart(id, cp, 2, "etag2-new"); err != nil { + t.Fatalf("re-append: %v", err) + } + + got, err := loadCheckpoint(id) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(got.Parts) != 3 { + t.Fatalf("parts = %d, want 3 (no dup)", len(got.Parts)) + } + for i := range got.Parts { + if got.Parts[i].N == 2 && got.Parts[i].ETag != "etag2-new" { + t.Errorf("part 2 etag = %q, want etag2-new", got.Parts[i].ETag) + } + } +} + +func TestCheckpoint_LoadCorruptIsAbsent(t *testing.T) { + withTempVerdaHome(t) + id := uploadIdentity("/abs/f", "b", "k") + path, err := checkpointPath(id) + if err != nil { + t.Fatalf("path: %v", err) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatalf("write: %v", err) + } + got, err := loadCheckpoint(id) + if err != nil { + t.Fatalf("load corrupt returned err: %v", err) + } + if got != nil { + t.Fatalf("corrupt checkpoint should load as nil, got %+v", got) + } +} + +func TestGCCheckpoints_PrunesOld(t *testing.T) { + withTempVerdaHome(t) + dir, err := checkpointDir() + if err != nil { + t.Fatalf("dir: %v", err) + } + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir: %v", err) + } + oldFile := filepath.Join(dir, "old.json") + newFile := filepath.Join(dir, "new.json") + for _, p := range []string{oldFile, newFile} { + if err := os.WriteFile(p, []byte("{}"), 0o600); err != nil { + t.Fatalf("write %s: %v", p, err) + } + } + old := time.Now().Add(-8 * 24 * time.Hour) + if err := os.Chtimes(oldFile, old, old); err != nil { + t.Fatalf("chtimes: %v", err) + } + + if err := gcCheckpoints(0); err != nil { + t.Fatalf("gc: %v", err) + } + if _, err := os.Stat(oldFile); !os.IsNotExist(err) { + t.Errorf("old checkpoint should be pruned, stat err = %v", err) + } + if _, err := os.Stat(newFile); err != nil { + t.Errorf("new checkpoint should survive, stat err = %v", err) + } +} + +func TestGCCheckpoints_MissingDirNoError(t *testing.T) { + withTempVerdaHome(t) + if err := gcCheckpoints(0); err != nil { + t.Fatalf("gc on missing dir = %v, want nil", err) + } +} diff --git a/internal/verda-cli/cmd/s3/client.go b/internal/verda-cli/cmd/s3/client.go index 631f4a1..e1ddf05 100644 --- a/internal/verda-cli/cmd/s3/client.go +++ b/internal/verda-cli/cmd/s3/client.go @@ -46,6 +46,12 @@ type API interface { CreateBucket(ctx context.Context, in *s3.CreateBucketInput, opts ...func(*s3.Options)) (*s3.CreateBucketOutput, error) DeleteBucket(ctx context.Context, in *s3.DeleteBucketInput, opts ...func(*s3.Options)) (*s3.DeleteBucketOutput, error) CopyObject(ctx context.Context, in *s3.CopyObjectInput, opts ...func(*s3.Options)) (*s3.CopyObjectOutput, error) + CreateMultipartUpload(ctx context.Context, in *s3.CreateMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error) + UploadPart(ctx context.Context, in *s3.UploadPartInput, opts ...func(*s3.Options)) (*s3.UploadPartOutput, error) + CompleteMultipartUpload(ctx context.Context, in *s3.CompleteMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error) + AbortMultipartUpload(ctx context.Context, in *s3.AbortMultipartUploadInput, opts ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error) + ListParts(ctx context.Context, in *s3.ListPartsInput, opts ...func(*s3.Options)) (*s3.ListPartsOutput, error) + ListMultipartUploads(ctx context.Context, in *s3.ListMultipartUploadsInput, opts ...func(*s3.Options)) (*s3.ListMultipartUploadsOutput, error) } // ClientOverrides captures per-invocation flag overrides for S3 client construction. @@ -85,6 +91,12 @@ func NewClient(ctx context.Context, creds *options.S3Credentials, authMode strin return s3.NewFromConfig(cfg, func(o *s3.Options) { o.BaseEndpoint = aws.String(endpoint) o.UsePathStyle = true + // Verda S3 is Ceph RADOS Gateway. Since aws-sdk-go-v2 enabled default + // data-integrity checksums (CRC32 over aws-chunked/STREAMING-…-TRAILER), + // RGW rejects uploads with 400 XAmzContentSHA256Mismatch. Opt back to + // "when required" so checksums are only sent when explicitly requested. + o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired + o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired }), nil } diff --git a/internal/verda-cli/cmd/s3/cp.go b/internal/verda-cli/cmd/s3/cp.go index ac6f22c..e1578a4 100644 --- a/internal/verda-cli/cmd/s3/cp.go +++ b/internal/verda-cli/cmd/s3/cp.go @@ -17,6 +17,7 @@ package s3 import ( "context" "fmt" + "io" "io/fs" "net/url" "os" @@ -42,6 +43,20 @@ const ( dirCopy ) +// Output format names (mirrors options.Output*); kept local so s3 helpers can +// compare without importing options just for the strings. +const ( + outputTable = "table" + outputJSON = "json" + outputYAML = "yaml" +) + +// interactiveTTY reports whether to drive an interactive TUI: stdout is a +// terminal, not agent mode, and the default table format (not json/yaml). +func interactiveTTY(f cmdutil.Factory) bool { + return cmdutil.IsStdoutTerminal() && !f.AgentMode() && f.OutputFormat() == outputTable +} + // detectDirection returns the direction implied by src/dst. Both-local is // reported as dirInvalid; the caller turns that into a UsageErrorf. func detectDirection(src, dst string) direction { @@ -66,6 +81,13 @@ type cpOptions struct { Exclude []string Dryrun bool ContentType string + PartSize string + Concurrency int + NoResume bool + + // quietProgress suppresses the per-file live progress line. Set by sync + // (many files) and other batch callers that render their own output. + quietProgress bool } // transferEntry is the structured shape for a single completed (or previewed) @@ -109,28 +131,66 @@ func NewCmdCp(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { the set (matched against the relative path; '*' does not cross '/'). - With --dryrun, the planned transfers are listed but no SDK + Large single-file uploads and single-object downloads are multipart + and parallel, and they RESUME: re-run the EXACT same command to + continue an interrupted transfer (only the missing parts move). + --no-resume starts over; --concurrency / --part-size tune throughput. + Interactive downloads from the "verda s3 ls" browser resume the same way. + + With --dryrun, the planned transfers are listed but no SDK calls are made. `), Example: cmdutil.Examples(` # Upload a single file verda s3 cp ./report.csv s3://my-bucket/reports/report.csv + # Upload into a "folder" (trailing slash keeps the filename) + verda s3 cp ./report.csv s3://my-bucket/reports/ + # Download a single object verda s3 cp s3://my-bucket/report.csv ./report.csv - # Copy between buckets + # Download into a directory (keeps the object's name) + verda s3 cp s3://my-bucket/report.csv ./downloads/ + + # Recursively download a whole prefix + verda s3 cp s3://my-bucket/logs/ ./logs --recursive + + # Resume an interrupted upload or download — re-run the same command + verda s3 cp ./model.bin s3://my-bucket/model.bin + verda s3 cp s3://my-bucket/model.bin ./model.bin + + # Faster large transfer + verda s3 cp s3://my-bucket/model.bin ./model.bin --concurrency 16 --part-size 32MiB + + # Copy between buckets verda s3 cp s3://src/a.txt s3://dst/b.txt # Recursive upload with filter verda s3 cp ./data s3://my-bucket/data/ --recursive --include "*.csv" + # Recursive upload, skipping temp files + verda s3 cp ./site s3://my-bucket/site/ --recursive --exclude "*.tmp" + + # Override the content type on upload + verda s3 cp ./page.html s3://my-bucket/page.html --content-type text/html + # Preview a recursive download verda s3 cp s3://my-bucket/logs/ ./logs --recursive --dryrun `), - Args: cobra.ExactArgs(2), + // 2 args = direct cp. Fewer args on a TTY guides an upload interactively + // (source -> bucket -> location); pipes/--agent still require both args. + Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - return runCp(cmd, f, ioStreams, opts, args[0], args[1]) + if len(args) == 2 { + return runCp(cmd, f, ioStreams, opts, args[0], args[1]) + } + interactive := interactiveTTY(f) + loneS3 := len(args) == 1 && IsS3URI(args[0]) // a bare s3:// is a download w/o dest, not an upload + if interactive && !loneS3 { + return runUploadWizard(cmd, f, ioStreams, opts, args) + } + return cmdutil.UsageErrorf(cmd, "cp requires a source and destination: verda s3 cp ") }, } @@ -140,6 +200,9 @@ func NewCmdCp(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { flags.StringArrayVar(&opts.Exclude, "exclude", nil, "Skip entries matching this glob (repeatable, overrides --include)") flags.BoolVar(&opts.Dryrun, "dryrun", false, "Preview transfers without performing them") flags.StringVar(&opts.ContentType, "content-type", "", "Override Content-Type on uploads") + flags.StringVar(&opts.PartSize, "part-size", "", "Part size for large uploads/downloads, e.g. 32MiB (default auto)") + flags.IntVar(&opts.Concurrency, "concurrency", defaultConcurrency, "Parallel parts for large uploads/downloads") + flags.BoolVar(&opts.NoResume, "no-resume", false, "Ignore saved progress and restart the transfer (upload or download)") return cmd } @@ -150,8 +213,11 @@ func runCp(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o return cmdutil.UsageErrorf(cmd, "cp requires at least one s3:// URI") } - ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) - defer cancel() + // Bulk transfers are NOT bounded by the short per-request --timeout: a large + // object legitimately takes minutes, and bounding the whole operation made + // big uploads fail with "context deadline exceeded". cmd.Context() (Ctrl+C) + // is the stop signal; the resumable uploader continues an interrupted upload. + ctx := cmd.Context() switch dir { case dirUpload: @@ -194,6 +260,20 @@ func runUpload(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioStr return cmdutil.UsageErrorf(cmd, "--recursive requires the source to be a directory") } + flagPartSize, err := parseByteSize(opts.PartSize) + if err != nil { + return cmdutil.UsageErrorf(cmd, "%v", err) + } + + // Prune stale local checkpoints from prior aborted uploads (best-effort). + _ = gcCheckpoints(0) + + // Single large file → custom resumable multipart uploader; everything else + // (recursive trees, small files) stays on the transfer-manager path. + if !opts.Recursive && !opts.Dryrun && info.Size() > computePartSize(info.Size(), flagPartSize) { + return runResumableUpload(ctx, f, ioStreams, src, info, dst, opts, flagPartSize) + } + transporter, err := transporterBuilder(ctx, f, ClientOverrides{}) if err != nil { return err @@ -216,6 +296,122 @@ func runUpload(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioStr return finalizeCp(ioStreams, f, &payload, started, opts.Dryrun) } +// runResumableUpload drives the custom multipart uploader for a single large +// local file. It resolves an absolute source path (the checkpoint identity and +// every part read depend on it), prints a resume line when the server already +// holds parts, and emits the same finalize footer as the transfer-manager path. +func runResumableUpload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, src string, info os.FileInfo, dst URI, opts *cpOptions, flagPartSize int64) error { + absPath, err := filepath.Abs(src) + if err != nil { + return err + } + client, err := buildClient(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + ropts := resumableOptions{ + AbsPath: absPath, + Bucket: dst.Bucket, + Key: singleTargetKey(dst.Key, filepath.Base(src)), + ContentType: inferContentType(absPath, opts.ContentType), + FileSize: info.Size(), + MTime: info.ModTime(), + PartSize: flagPartSize, + Concurrency: opts.Concurrency, + NoResume: opts.NoResume, + } + return runResumable(ctx, f, ioStreams, client, &ropts, filepath.Base(src)) +} + +// runResumable wraps resumableUpload with the resume announcement, a part-level +// progress bar, and the finalize footer. ropts must be fully populated; shared +// by the cp upload path and the ls-uploads resume path. +func runResumable(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, ropts *resumableOptions, displayName string) error { + announceResume(ctx, f, ioStreams, client, ropts) + + partSize := computePartSize(ropts.FileSize, ropts.PartSize) + started := time.Now() + + // Self-rendered part-level progress line (bar + % + live rate), shown only + // on an interactive terminal with table output. The OnProgress callback is + // always installed (to measure throughput) and runs on the uploader's + // serialized result loop, so it's race-free. + var prog *transferProgress + if f.Status() != nil && cmdutil.IsStderrTerminal() { + prog = newTransferProgress(ioStreams.ErrOut, "Uploading", displayName, ropts.FileSize, started) + } + var firstDone, lastDone int32 + firstSet := false + ropts.OnProgress = func(done, _ int32) { + if !firstSet { + firstDone, firstSet = done, true + } + lastDone = done + if prog != nil { + prog.update(min(int64(done)*partSize, ropts.FileSize)) + } + } + + err := resumableUpload(ctx, client, ropts) + if prog != nil { + prog.finish() + } + if err != nil { + return err + } + elapsed := time.Since(started) + + // Rate over the bytes actually moved this run (a resume only sends the + // missing parts, so this reports the true session throughput, not inflated). + rateSuffix := "" + if newParts := lastDone - firstDone; newParts > 0 { + transferred := min(int64(newParts)*partSize, ropts.FileSize) + if secs := elapsed.Seconds(); secs > 0 { + rateSuffix = fmt.Sprintf(" @ %s/s", humanBytes(int64(float64(transferred)/secs))) + } + } + + payload := newCpPayload(false) + payload.Transfers = append(payload.Transfers, transferEntry{ + Source: ropts.AbsPath, + Destination: URI{Bucket: ropts.Bucket, Key: ropts.Key}.String(), + Bytes: ropts.FileSize, + DurationMs: elapsed.Milliseconds(), + Status: "ok", + }) + if !isStructured(f.OutputFormat()) { + _, _ = fmt.Fprintf(ioStreams.Out, "✓ uploaded %s (%s)%s\n", displayName, humanBytes(ropts.FileSize), rateSuffix) + } + return finalizeCp(ioStreams, f, &payload, started, false) +} + +// announceResume prints a concise human resume line to ErrOut when a valid +// checkpoint and live server-side upload still hold k of N parts. Best-effort: +// any error (no checkpoint, expired upload, --no-resume) leaves it silent and +// resumableUpload handles the real decision tree authoritatively. +func announceResume(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, ropts *resumableOptions) { + if ropts.NoResume || isStructured(f.OutputFormat()) { + return + } + identity := uploadIdentity(ropts.AbsPath, ropts.Bucket, ropts.Key) + cp, err := loadCheckpoint(identity) + if err != nil || cp == nil { + return + } + if cp.FileSize != ropts.FileSize || !cp.MTime.Equal(ropts.MTime) || cp.UploadID == "" { + return + } + // Paginated: a single ListParts caps at 1000, which would understate the + // resumed count for files with >1000 parts. + listed, err := listAllParts(ctx, client, ropts.Bucket, ropts.Key, cp.UploadID) + if err != nil { + return + } + partSize := computePartSize(ropts.FileSize, ropts.PartSize) + total := numParts(ropts.FileSize, partSize) + _, _ = fmt.Fprintf(ioStreams.ErrOut, "Resuming upload (%d/%d parts already on server)\n", len(listed), total) +} + // uploadTree walks srcDir and uploads every regular file matching the filters. func uploadTree(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, tr Transporter, srcDir string, dst URI, opts *cpOptions, payload *cpPayload) error { return filepath.WalkDir(srcDir, func(path string, d fs.DirEntry, err error) error { @@ -306,6 +502,12 @@ func runDownload(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioS return cmdutil.UsageErrorf(cmd, "source is a bucket/prefix; pass --recursive to download its contents") } + // Single object -> resumable, parallel downloader (progress + rate + resume). + if !opts.Recursive { + return runResumableDownload(ctx, f, ioStreams, src, dst, opts) + } + + // Recursive -> the transfer-manager path (per-file resume is a follow-up). transporter, err := transporterBuilder(ctx, f, ClientOverrides{}) if err != nil { return err @@ -314,22 +516,123 @@ func runDownload(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioS if err != nil { return err } + payload := newCpPayload(opts.Dryrun) + started := time.Now() + if err := downloadTree(ctx, f, ioStreams, apiClient, transporter, src, dst, opts, &payload); err != nil { + return err + } + return finalizeCp(ioStreams, f, &payload, started, opts.Dryrun) +} +// runResumableDownload downloads a single object with the resumable parallel +// downloader, a live progress bar, and a final rate. Re-running the same command +// resumes from ".part" if the object is unchanged. +func runResumableDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, src URI, dst string, opts *cpOptions) error { + localPath := resolveDownloadPath(dst, src.Key) + srcStr := src.String() payload := newCpPayload(opts.Dryrun) started := time.Now() - if opts.Recursive { - if err := downloadTree(ctx, f, ioStreams, apiClient, transporter, src, dst, opts, &payload); err != nil { - return err - } - } else { - localPath := resolveDownloadPath(dst, src.Key) - if err := downloadOne(ctx, f, ioStreams, transporter, src, localPath, src.Key, opts, &payload); err != nil { - return err + if opts.Dryrun { + if !isStructured(f.OutputFormat()) { + _, _ = fmt.Fprintf(ioStreams.Out, "(dry run) would download %s -> %s\n", srcStr, localPath) } + payload.Transfers = append(payload.Transfers, transferEntry{Source: srcStr, Destination: localPath, Status: "dryrun"}) + return finalizeCp(ioStreams, f, &payload, started, true) } - return finalizeCp(ioStreams, f, &payload, started, opts.Dryrun) + client, err := buildClient(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + partSize, err := parseByteSize(opts.PartSize) + if err != nil { + return err + } + + // Best-effort prune of stale download checkpoints and shared lock files from + // prior interrupted transfers (downloads never triggered the upload-path GC). + _ = gcDownloadCheckpoints(0) + _ = gcCheckpoints(0) + + n, rateSuffix, err := downloadToLocal(ctx, f, ioStreams, client, src, localPath, partSize, opts.Concurrency, opts.NoResume, opts.quietProgress) + if err != nil { + return err + } + payload.Transfers = append(payload.Transfers, transferEntry{ + Source: srcStr, + Destination: localPath, + Bytes: n, + DurationMs: time.Since(started).Milliseconds(), + Status: "ok", + }) + if !isStructured(f.OutputFormat()) { + _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)%s\n", srcStr, absOrSelf(localPath), humanBytes(n), rateSuffix) + } + return finalizeCp(ioStreams, f, &payload, started, false) +} + +// absOrSelf returns the absolute form of p for display, falling back to p if it +// can't be resolved. Used so download result lines show the full local path. +func absOrSelf(p string) string { + if abs, err := filepath.Abs(p); err == nil { + return abs + } + return p +} + +// downloadToLocal runs a resumable download of src to localPath with a live +// progress bar (when interactive) and returns the object size plus a " @ rate" +// suffix for the result line. Shared by `cp` and the ls browser, so both get +// resumable downloads + progress. quiet suppresses the live bar. +func downloadToLocal(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, src URI, localPath string, partSize int64, concurrency int, noResume, quiet bool) (size int64, rateSuffix string, err error) { + rel := filepath.Base(localPath) + enabled := !quiet && f.Status() != nil && cmdutil.IsStderrTerminal() + var prog *transferProgress + var firstBytes, lastBytes int64 + firstSet := false + started := time.Now() + + announce := !quiet && !isStructured(f.OutputFormat()) + n, err := resumableDownload(ctx, client, &resumableDownloadOptions{ + Bucket: src.Bucket, Key: src.Key, DestPath: localPath, + PartSize: partSize, Concurrency: concurrency, NoResume: noResume, + OnResume: func(already, total int64) { + if announce { + pct := 0.0 + if total > 0 { + pct = float64(already) / float64(total) * 100 + } + _, _ = fmt.Fprintf(ioStreams.ErrOut, "Resuming download of %s (%s of %s, %.0f%% already on disk)\n", + rel, humanBytes(already), humanBytes(total), pct) + } + }, + OnProgress: func(done, total int64) { + if !firstSet { + firstBytes, firstSet = done, true + } + lastBytes = done + if enabled { + if prog == nil { + prog = newTransferProgress(ioStreams.ErrOut, "Downloading", rel, total, started) + } + prog.update(done) + } + }, + }) + if prog != nil { + prog.finish() + } + if err != nil { + return 0, "", err + } + rate := "" + if moved := lastBytes - firstBytes; moved > 0 { + if secs := time.Since(started).Seconds(); secs > 0 { + rate = fmt.Sprintf(" @ %s/s", humanBytes(int64(float64(moved)/secs))) + } + } + return n, rate, nil } // downloadTree lists src.Key and downloads each matching object into dstDir @@ -350,7 +653,7 @@ func downloadTree(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt if err != nil { return err } - if err := downloadOne(ctx, f, ioStreams, tr, URI{Bucket: src.Bucket, Key: k}, localPath, rel, opts, payload); err != nil { + if err := downloadOne(ctx, f, ioStreams, tr, client, URI{Bucket: src.Bucket, Key: k}, localPath, rel, opts, payload); err != nil { return err } } @@ -358,7 +661,7 @@ func downloadTree(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt } // downloadOne performs a single GetObject download (or records a dryrun entry). -func downloadOne(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, tr Transporter, src URI, localPath, rel string, opts *cpOptions, payload *cpPayload) error { +func downloadOne(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, tr Transporter, client API, src URI, localPath, rel string, opts *cpOptions, payload *cpPayload) error { srcStr := src.String() structured := isStructured(f.OutputFormat()) if opts.Dryrun { @@ -383,9 +686,27 @@ func downloadOne(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStr } in := &s3.GetObjectInput{Bucket: aws.String(src.Bucket), Key: aws.String(src.Key)} + + // Live progress (bar + % + rate) on an interactive terminal. HeadObject is + // only issued when we're actually rendering, so non-interactive/batch + // (sync, --agent, pipes) pay no extra request. + var prog *transferProgress + var w io.WriterAt = file + if !opts.quietProgress && f.Status() != nil && cmdutil.IsStderrTerminal() { + var total int64 + if head, herr := client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(src.Bucket), Key: aws.String(src.Key)}); herr == nil { + total = aws.ToInt64(head.ContentLength) + } + prog = newTransferProgress(ioStreams.ErrOut, "Downloading", rel, total, time.Now()) + w = &countingWriterAt{w: file, onWrite: prog.update} + } + started := time.Now() - n, err := tr.Download(ctx, file, in) + n, err := tr.Download(ctx, w, in) elapsed := time.Since(started) + if prog != nil { + prog.finish() + } if closeErr := file.Close(); err == nil { err = closeErr } @@ -407,8 +728,12 @@ func downloadOne(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStr DurationMs: elapsed.Milliseconds(), Status: "ok", }) + rateSuffix := "" + if secs := elapsed.Seconds(); n > 0 && secs > 0 { + rateSuffix = fmt.Sprintf(" @ %s/s", humanBytes(int64(float64(n)/secs))) + } if !structured { - _, _ = fmt.Fprintf(ioStreams.Out, "\u2713 downloaded %s (%s)\n", rel, humanBytes(n)) + _, _ = fmt.Fprintf(ioStreams.Out, "\u2713 downloaded %s (%s)%s\n", rel, humanBytes(n), rateSuffix) } return nil } @@ -513,7 +838,7 @@ func copyOne(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams // that must not be interleaved with human progress lines. "table" (or an // empty default) yields false. func isStructured(format string) bool { - return format == "json" || format == "yaml" + return format == outputJSON || format == outputYAML } func newCpPayload(dryrun bool) cpPayload { diff --git a/internal/verda-cli/cmd/s3/cp_resume_test.go b/internal/verda-cli/cmd/s3/cp_resume_test.go new file mode 100644 index 0000000..c775eb7 --- /dev/null +++ b/internal/verda-cli/cmd/s3/cp_resume_test.go @@ -0,0 +1,218 @@ +// 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 s3 + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// writeCpSrc writes size bytes to a file under t.TempDir and returns its path. +func writeCpSrc(t *testing.T, size int64) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "big.bin") + data := make([]byte, size) + for i := range data { + data[i] = byte(i % 251) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write src: %v", err) + } + return path +} + +// TestCp_Upload_LargeFile_RoutesResumable verifies a single file larger than +// the (auto) part size is uploaded via the custom multipart loop (CreateMpu + +// UploadPart + Complete) rather than the transfer-manager PutObject path. +func TestCp_Upload_LargeFile_RoutesResumable(t *testing.T) { + // no t.Parallel — clientBuilder/transporterBuilder mutation + withTempVerdaHome(t) + src := writeCpSrc(t, 3*minPartSize+100) // 4 parts at the 5MiB floor + + fake := newFakeMPUploadAPI() + restore := withFakeClient(fake) + defer restore() + // The transporter must NOT be used for a large single file. + tr := &cpFakeTransporter{} + restoreT := withFakeTransporter(tr) + defer restoreT() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdCp(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{src, "s3://my-bucket/dest/big.bin", "--part-size", "5MiB", "--concurrency", "1"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if len(tr.uploads) != 0 { + t.Errorf("transfer-manager Upload must not be used for a large file, got %d", len(tr.uploads)) + } + if fake.createCalls != 1 { + t.Errorf("CreateMultipartUpload calls = %d, want 1", fake.createCalls) + } + if fake.uploadCalls != 4 { + t.Errorf("UploadPart calls = %d, want 4", fake.uploadCalls) + } + if fake.completeCalls != 1 { + t.Errorf("Complete calls = %d, want 1", fake.completeCalls) + } + if !strings.Contains(out.String(), "uploaded") { + t.Errorf("stdout missing 'uploaded':\n%s", out.String()) + } + // Key must be the resolved single-target key. + if len(fake.completedSet) != 4 { + t.Errorf("completed parts = %d, want 4", len(fake.completedSet)) + } +} + +// TestCp_Upload_SmallFile_StaysOnTransferManager verifies that a file at or +// below the part size still goes through the transfer-manager PutObject path +// (no multipart machinery, no checkpoint). +func TestCp_Upload_SmallFile_StaysOnTransferManager(t *testing.T) { + // no t.Parallel + withTempVerdaHome(t) + src := writeCpSrc(t, 1024) // well under 5MiB + + fake := newFakeMPUploadAPI() + restore := withFakeClient(fake) + defer restore() + tr := &cpFakeTransporter{} + restoreT := withFakeTransporter(tr) + defer restoreT() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdCp(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{src, "s3://my-bucket/dest/small.bin"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if len(tr.uploads) != 1 { + t.Errorf("transfer-manager Upload calls = %d, want 1 (small file)", len(tr.uploads)) + } + if fake.createCalls != 0 { + t.Errorf("CreateMultipartUpload must not be called for a small file, got %d", fake.createCalls) + } +} + +// TestCp_Upload_LargeFile_Resume drives the cp command twice against the same +// fake server: the first run breaks after 2 parts (checkpoint persists), the +// second resumes, uploads only the missing parts, prints the resume line, and +// completes. +func TestCp_Upload_LargeFile_Resume(t *testing.T) { + // no t.Parallel + withTempVerdaHome(t) + src := writeCpSrc(t, 3*minPartSize+100) // 4 parts + + fake := newFakeMPUploadAPI() + fake.failAfterPart = 2 + restore := withFakeClient(fake) + defer restore() + restoreT := withFakeTransporter(&cpFakeTransporter{}) + defer restoreT() + + // First run: expect failure, checkpoint persisted with 2 parts. + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdCp(f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{src, "s3://my-bucket/dest/big.bin", "--part-size", "5MiB", "--concurrency", "1"}) + cmd.SetContext(context.Background()) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + if err := cmd.Execute(); err == nil { + t.Fatal("expected first-run failure") + } + if fake.completeCalls != 0 { + t.Fatalf("Complete must not be called on first-run failure, got %d", fake.completeCalls) + } + + // Second run: same fake (parts 1-2 retained), so only 3,4 should upload. + fake.failAfterPart = 0 + fake.uploadCalls = 0 + fake.uploadOrder = nil + + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + cmd2 := NewCmdCp(f, cmdutil.IOStreams{Out: out, ErrOut: errOut}) + cmd2.SetArgs([]string{src, "s3://my-bucket/dest/big.bin", "--part-size", "5MiB", "--concurrency", "1"}) + cmd2.SetContext(context.Background()) + if err := cmd2.Execute(); err != nil { + t.Fatalf("resume Execute: %v", err) + } + if fake.createCalls != 1 { + t.Errorf("Create calls = %d, want 1 (resume must not re-create)", fake.createCalls) + } + if fake.uploadCalls != 2 { + t.Errorf("resume UploadPart calls = %d, want 2 (only missing 3,4)", fake.uploadCalls) + } + if fake.completeCalls != 1 { + t.Errorf("Complete calls = %d, want 1", fake.completeCalls) + } + if !strings.Contains(errOut.String(), "Resuming upload (2/4 parts already on server)") { + t.Errorf("expected resume line on stderr:\n%s", errOut.String()) + } +} + +// TestCp_Upload_LargeFile_NoResume verifies --no-resume aborts the stale upload +// and restarts fresh, skipping the resume reconcile. +func TestCp_Upload_LargeFile_NoResume(t *testing.T) { + // no t.Parallel + withTempVerdaHome(t) + src := writeCpSrc(t, minPartSize+10) // 2 parts + + fake := newFakeMPUploadAPI() + fake.failAfterPart = 1 + restore := withFakeClient(fake) + defer restore() + restoreT := withFakeTransporter(&cpFakeTransporter{}) + defer restoreT() + + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdCp(f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{src, "s3://my-bucket/dest/big.bin", "--part-size", "5MiB", "--concurrency", "1"}) + cmd.SetContext(context.Background()) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + if err := cmd.Execute(); err == nil { + t.Fatal("expected first-run failure") + } + + fake.failAfterPart = 0 + cmd2 := NewCmdCp(f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}) + cmd2.SetArgs([]string{src, "s3://my-bucket/dest/big.bin", "--part-size", "5MiB", "--concurrency", "1", "--no-resume"}) + cmd2.SetContext(context.Background()) + if err := cmd2.Execute(); err != nil { + t.Fatalf("no-resume Execute: %v", err) + } + if fake.abortCalls != 1 { + t.Errorf("Abort calls = %d, want 1 (--no-resume aborts stale)", fake.abortCalls) + } + if fake.createCalls != 2 { + t.Errorf("Create calls = %d, want 2 (one per run)", fake.createCalls) + } + if fake.completeCalls != 1 { + t.Errorf("Complete calls = %d, want 1", fake.completeCalls) + } +} diff --git a/internal/verda-cli/cmd/s3/cp_test.go b/internal/verda-cli/cmd/s3/cp_test.go index 081d7e8..23cd5e0 100644 --- a/internal/verda-cli/cmd/s3/cp_test.go +++ b/internal/verda-cli/cmd/s3/cp_test.go @@ -17,6 +17,7 @@ package s3 import ( "bytes" "context" + "fmt" "io" "os" "path/filepath" @@ -84,6 +85,22 @@ type cpFakeAPI struct { listObjectsPages []*s3.ListObjectsV2Output listObjectsCalls int listErr error + downloadBody []byte // served by Head/GetObject for the resumable downloader +} + +func (c *cpFakeAPI) HeadObject(ctx context.Context, in *s3.HeadObjectInput, opts ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { + return &s3.HeadObjectOutput{ContentLength: aws.Int64(int64(len(c.downloadBody))), ETag: aws.String("\"e\"")}, nil +} + +func (c *cpFakeAPI) GetObject(ctx context.Context, in *s3.GetObjectInput, opts ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + body := c.downloadBody + if rng := aws.ToString(in.Range); rng != "" { + var start, end int64 + if _, err := fmt.Sscanf(rng, "bytes=%d-%d", &start, &end); err == nil && start <= end && end < int64(len(body)) { + body = body[start : end+1] + } + } + return &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(body))}, nil } func (c *cpFakeAPI) CopyObject(ctx context.Context, in *s3.CopyObjectInput, opts ...func(*s3.Options)) (*s3.CopyObjectOutput, error) { @@ -180,14 +197,12 @@ func TestCp_Upload_SingleFile(t *testing.T) { } func TestCp_Download_SingleFile(t *testing.T) { - // no t.Parallel + // no t.Parallel. Single-file download goes through the resumable downloader, + // which fetches via the API (Head + ranged Get), not the transfer manager. tmp := t.TempDir() dst := filepath.Join(tmp, "out.txt") - fake := &cpFakeTransporter{downloadWrite: []byte("hello")} - restoreT := withFakeTransporter(fake) - defer restoreT() - restore := withFakeClient(&cpFakeAPI{}) + restore := withFakeClient(&cpFakeAPI{downloadBody: []byte("hello")}) defer restore() out := &bytes.Buffer{} @@ -199,9 +214,6 @@ func TestCp_Download_SingleFile(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("Execute: %v", err) } - if len(fake.downloads) != 1 { - t.Fatalf("Download calls = %d, want 1", len(fake.downloads)) - } body, err := os.ReadFile(dst) // #nosec G304 -- dst is under t.TempDir() if err != nil { t.Fatalf("ReadFile: %v", err) @@ -443,6 +455,58 @@ func TestCp_RecursiveDownload_EscapeAttempt(t *testing.T) { } } +// TestCp_RecursiveS3ToS3 exercises copyTree: every listed key under the source +// prefix is copied to the destination prefix with its relative path preserved, +// and --exclude is honored against the relative key. +func TestCp_RecursiveS3ToS3(t *testing.T) { + // no t.Parallel + fake := &cpFakeAPI{ + listObjectsPages: []*s3.ListObjectsV2Output{ + { + Contents: []s3types.Object{ + {Key: aws.String("data/a.txt"), Size: aws.Int64(1)}, + {Key: aws.String("data/sub/b.txt"), Size: aws.Int64(1)}, + {Key: aws.String("data/skip.log"), Size: aws.Int64(1)}, + }, + IsTruncated: aws.Bool(false), + }, + }, + } + restore := withFakeClient(fake) + defer restore() + restoreT := withFakeTransporter(&cpFakeTransporter{}) + defer restoreT() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdCp(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"s3://src-bucket/data/", "s3://dst-bucket/dest/", "--recursive", "--exclude", "*.log"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + // "*.log" excludes data/skip.log (rel "skip.log"); the two .txt keys copy + // with their relative paths preserved under the dest prefix. + gotKeys := make([]string, 0, len(fake.copyInputs)) + for _, in := range fake.copyInputs { + if b := aws.ToString(in.Bucket); b != "dst-bucket" { + t.Errorf("copy dst Bucket = %q, want dst-bucket", b) + } + gotKeys = append(gotKeys, aws.ToString(in.Key)) + } + sort.Strings(gotKeys) + want := []string{"dest/a.txt", "dest/sub/b.txt"} + if len(gotKeys) != len(want) { + t.Fatalf("copied keys = %v, want %v", gotKeys, want) + } + for i := range want { + if gotKeys[i] != want[i] { + t.Errorf("copied keys[%d] = %q, want %q (all=%v)", i, gotKeys[i], want[i], gotKeys) + } + } +} + func TestCp_InvalidURI(t *testing.T) { // no t.Parallel restore := withFakeClient(&cpFakeAPI{}) diff --git a/internal/verda-cli/cmd/s3/errors.go b/internal/verda-cli/cmd/s3/errors.go index 4df7e5a..07612ee 100644 --- a/internal/verda-cli/cmd/s3/errors.go +++ b/internal/verda-cli/cmd/s3/errors.go @@ -42,6 +42,10 @@ func translateError(err error) error { return cmdutil.NewAuthError(apiErr.ErrorMessage()) case "BucketAlreadyOwnedByYou", "BucketAlreadyExists": return cmdutil.NewValidationError("bucket", apiErr.ErrorMessage()) + case "BucketNotEmpty": + return cmdutil.NewValidationError("bucket", "bucket is not empty — pass --force to delete its contents first") + case "NoSuchUpload": + return cmdutil.NewNotFoundError("upload", apiErr.ErrorMessage()) } } @@ -52,6 +56,21 @@ func translateError(err error) error { return err } +// isNoSuchUpload reports whether err is the S3/RGW NoSuchUpload (404) returned +// when a multipart UploadId is unknown — expired, aborted, or never existed. +// The resume decision tree treats this as "drop checkpoint + fresh upload". +// Keeps smithy imports isolated to this file. +func isNoSuchUpload(err error) bool { + if err == nil { + return false + } + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + return apiErr.ErrorCode() == "NoSuchUpload" + } + return false +} + func isNetworkError(err error) bool { var netErr net.Error if errors.As(err, &netErr) { diff --git a/internal/verda-cli/cmd/s3/helper.go b/internal/verda-cli/cmd/s3/helper.go index af45b6c..fc3e4b6 100644 --- a/internal/verda-cli/cmd/s3/helper.go +++ b/internal/verda-cli/cmd/s3/helper.go @@ -52,10 +52,12 @@ func buildClientDefault(ctx context.Context, f cmdutil.Factory, ov ClientOverrid // the "no S3 credentials configured" friendly error. // // S3 commands are exempt from Options.Complete() (see cmd.go skipCredentialResolution), -// so AuthOptions.Profile is never auto-resolved here. Fall back to defaultProfileName -// to match the "[default]" section that `verda s3 configure` writes. +// so AuthOptions.Profile is not auto-resolved. options.ActiveProfile honors the +// --auth.profile flag, VERDA_PROFILE, and the `verda auth use` config setting; +// only when none is set do we fall back to defaultProfileName ("[default]", the +// section `verda s3 configure` writes). func loadCredsFromFactory(f cmdutil.Factory) (*options.S3Credentials, error) { - profile := f.Options().AuthOptions.Profile + profile := options.ActiveProfile(f.Options().AuthOptions.Profile) if profile == "" { profile = defaultProfileName } @@ -109,3 +111,21 @@ func (c *sdkS3Client) DeleteBucket(ctx context.Context, in *s3.DeleteBucketInput func (c *sdkS3Client) CopyObject(ctx context.Context, in *s3.CopyObjectInput, opts ...func(*s3.Options)) (*s3.CopyObjectOutput, error) { return (*s3.Client)(c).CopyObject(ctx, in, opts...) } +func (c *sdkS3Client) CreateMultipartUpload(ctx context.Context, in *s3.CreateMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error) { + return (*s3.Client)(c).CreateMultipartUpload(ctx, in, opts...) +} +func (c *sdkS3Client) UploadPart(ctx context.Context, in *s3.UploadPartInput, opts ...func(*s3.Options)) (*s3.UploadPartOutput, error) { + return (*s3.Client)(c).UploadPart(ctx, in, opts...) +} +func (c *sdkS3Client) CompleteMultipartUpload(ctx context.Context, in *s3.CompleteMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error) { + return (*s3.Client)(c).CompleteMultipartUpload(ctx, in, opts...) +} +func (c *sdkS3Client) AbortMultipartUpload(ctx context.Context, in *s3.AbortMultipartUploadInput, opts ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error) { + return (*s3.Client)(c).AbortMultipartUpload(ctx, in, opts...) +} +func (c *sdkS3Client) ListParts(ctx context.Context, in *s3.ListPartsInput, opts ...func(*s3.Options)) (*s3.ListPartsOutput, error) { + return (*s3.Client)(c).ListParts(ctx, in, opts...) +} +func (c *sdkS3Client) ListMultipartUploads(ctx context.Context, in *s3.ListMultipartUploadsInput, opts ...func(*s3.Options)) (*s3.ListMultipartUploadsOutput, error) { + return (*s3.Client)(c).ListMultipartUploads(ctx, in, opts...) +} diff --git a/internal/verda-cli/cmd/s3/lock.go b/internal/verda-cli/cmd/s3/lock.go new file mode 100644 index 0000000..35d3029 --- /dev/null +++ b/internal/verda-cli/cmd/s3/lock.go @@ -0,0 +1,57 @@ +// 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. + +//go:build !windows + +package s3 + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "syscall" +) + +// acquireTransferLock takes a non-blocking advisory exclusive lock keyed by the +// upload identity, so two processes can't push the same object concurrently +// (which would race on the checkpoint and double-upload parts). Returns +// acquired=false when another process already holds it. The lock is released by +// the returned func, and the OS frees it automatically when the process exits — +// so a crash leaves no stale lock. +func acquireTransferLock(identity string) (release func(), acquired bool, err error) { + dir, err := checkpointDir() + if err != nil { + return nil, false, err + } + if mkErr := os.MkdirAll(dir, 0o700); mkErr != nil { + return nil, false, fmt.Errorf("create lock dir: %w", mkErr) + } + path := filepath.Join(dir, identity+".lock") + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) // #nosec G304 -- path derived from sha256 identity under ~/.verda + if err != nil { + return nil, false, fmt.Errorf("open lock file: %w", err) + } + if flockErr := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); flockErr != nil { //nolint:gosec // G115: a file descriptor always fits in int + _ = f.Close() + if errors.Is(flockErr, syscall.EWOULDBLOCK) { + return nil, false, nil // held by another process + } + return nil, false, fmt.Errorf("lock %q: %w", path, flockErr) + } + return func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) //nolint:gosec // G115: a file descriptor always fits in int + _ = f.Close() + }, true, nil +} diff --git a/internal/verda-cli/cmd/s3/lock_test.go b/internal/verda-cli/cmd/s3/lock_test.go new file mode 100644 index 0000000..d692a4d --- /dev/null +++ b/internal/verda-cli/cmd/s3/lock_test.go @@ -0,0 +1,43 @@ +// 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. + +//go:build !windows + +package s3 + +import "testing" + +// TestAcquireUploadLock_ExclusiveThenReleasable verifies the same-host guard: +// a held lock blocks a second acquire, and releasing frees it. +func TestAcquireUploadLock_ExclusiveThenReleasable(t *testing.T) { + withTempVerdaHome(t) + const id = "deadbeef" + + release, acquired, err := acquireTransferLock(id) + if err != nil || !acquired { + t.Fatalf("first acquire: acquired=%v err=%v", acquired, err) + } + + if _, ok, err2 := acquireTransferLock(id); err2 != nil || ok { + t.Errorf("second acquire while held: ok=%v err=%v, want ok=false", ok, err2) + } + + release() + + release2, ok3, err3 := acquireTransferLock(id) + if err3 != nil || !ok3 { + t.Fatalf("re-acquire after release: ok=%v err=%v", ok3, err3) + } + release2() +} diff --git a/internal/verda-cli/cmd/s3/lock_windows.go b/internal/verda-cli/cmd/s3/lock_windows.go new file mode 100644 index 0000000..1edb57f --- /dev/null +++ b/internal/verda-cli/cmd/s3/lock_windows.go @@ -0,0 +1,23 @@ +// 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. + +//go:build windows + +package s3 + +// acquireTransferLock is a no-op on Windows: syscall.Flock is unavailable and the +// CLI targets macOS/Linux. Same-host upload concurrency is not guarded here. +func acquireTransferLock(_ string) (release func(), acquired bool, err error) { + return func() {}, true, nil +} diff --git a/internal/verda-cli/cmd/s3/ls.go b/internal/verda-cli/cmd/s3/ls.go index 6e7623c..7e5ee72 100644 --- a/internal/verda-cli/cmd/s3/ls.go +++ b/internal/verda-cli/cmd/s3/ls.go @@ -107,6 +107,17 @@ func NewCmdLs(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { } func runLs(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *lsOptions, args []string) error { + // The interactive browser is unbounded: user navigation (think-time) and its + // downloads must not hit the short per-request --timeout. The static + // listings keep the timeout — they're quick control-plane calls. + if len(args) == 0 && interactiveTTY(f) { + client, err := buildClient(cmd.Context(), f, ClientOverrides{}) + if err != nil { + return err + } + return runLsBrowser(cmd.Context(), f, ioStreams, client) + } + ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() @@ -114,7 +125,6 @@ func runLs(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o if err != nil { return err } - if len(args) == 0 { return runLsBuckets(ctx, f, ioStreams, client) } @@ -123,7 +133,6 @@ func runLs(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o if err != nil { return cmdutil.UsageErrorf(cmd, "%v", err) } - return runLsObjects(ctx, f, ioStreams, client, uri, opts) } diff --git a/internal/verda-cli/cmd/s3/lsuploads.go b/internal/verda-cli/cmd/s3/lsuploads.go new file mode 100644 index 0000000..cab9da1 --- /dev/null +++ b/internal/verda-cli/cmd/s3/lsuploads.go @@ -0,0 +1,246 @@ +// 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 s3 + +import ( + "context" + "fmt" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// uploadEntry is the JSON/YAML shape for one in-progress multipart upload. +type uploadEntry struct { + Key string `json:"key" yaml:"key"` + UploadID string `json:"upload_id" yaml:"upload_id"` + Initiated time.Time `json:"initiated" yaml:"initiated"` + Size int64 `json:"size" yaml:"size"` +} + +// uploadsPayload is the top-level structured shape for ls-uploads. +type uploadsPayload struct { + Uploads []uploadEntry `json:"uploads" yaml:"uploads"` + Truncated bool `json:"truncated" yaml:"truncated"` +} + +type lsUploadsOptions struct { + Prefix string +} + +// NewCmdLsUploads builds the `verda s3 ls-uploads` cobra command. +func NewCmdLsUploads(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + opts := &lsUploadsOptions{} + + cmd := &cobra.Command{ + Use: "ls-uploads s3://bucket", + Short: "List in-progress (incomplete) multipart uploads", + Long: cmdutil.LongDesc(` + List in-progress multipart uploads in a bucket. The staged parts of + an incomplete upload consume real storage and are billed even though + the object does not appear in "verda s3 ls". This command surfaces + that hidden cost: key, UploadId, when it was initiated, and the + accumulated size of the parts uploaded so far. + + Use "verda s3 abort-uploads" to reclaim the storage. + `), + Example: cmdutil.Examples(` + # List every in-progress upload in a bucket + verda s3 ls-uploads s3://my-bucket + + # Only uploads under a key prefix + verda s3 ls-uploads s3://my-bucket --prefix logs/ + + # Machine-readable output + verda s3 ls-uploads s3://my-bucket -o json + `), + // 0 args on a TTY launches the bucket picker; an explicit s3://bucket + // runs directly. --agent errors; non-TTY shows help (no silent prompt). + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + arg, err := resolveBucketArg(cmd, f, ioStreams, args) + if err != nil || arg == "" { + return err + } + return runLsUploads(cmd, f, ioStreams, opts, arg) + }, + } + + cmd.Flags().StringVar(&opts.Prefix, "prefix", "", "Only list uploads whose key starts with this prefix") + + return cmd +} + +func runLsUploads(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *lsUploadsOptions, arg string) error { + uri, err := ParseS3URI(arg) + if err != nil { + return cmdutil.UsageErrorf(cmd, "%v", err) + } + prefix := firstNonEmpty(opts.Prefix, uri.Key) + + ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + + client, err := buildClient(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + + payload, err := collectUploads(ctx, f, ioStreams, client, uri.Bucket, prefix) + if err != nil { + return err + } + + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), payload); wrote { + return werr + } + + renderUploads(ioStreams, payload) + + // On an interactive terminal, offer to resume one. The resume itself runs a + // full upload, so it uses the unbounded cmd.Context() (not the listing ctx). + interactive := interactiveTTY(f) + if interactive && len(payload.Uploads) > 0 { + return promptResumeFromUploads(cmd.Context(), f, ioStreams, client, uri.Bucket, payload.Uploads) + } + return nil +} + +// collectUploads paginates ListMultipartUploads and, for each upload, sums its +// accumulated part size via ListParts. Guards against the truncated-with-empty- +// marker loop (same caveat as ListObjectsV2 in the s3 CLAUDE.md). +func collectUploads(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, bucket, prefix string) (uploadsPayload, error) { + payload := uploadsPayload{Uploads: []uploadEntry{}} + + var sp interface{ Stop(string) } + if status := f.Status(); status != nil { + sp, _ = status.Spinner(ctx, "Listing in-progress uploads...") + } + defer func() { + if sp != nil { + sp.Stop("") + } + }() + + var keyMarker, uploadIDMarker *string + for { + in := &s3.ListMultipartUploadsInput{Bucket: aws.String(bucket)} + if prefix != "" { + in.Prefix = aws.String(prefix) + } + if keyMarker != nil { + in.KeyMarker = keyMarker + } + if uploadIDMarker != nil { + in.UploadIdMarker = uploadIDMarker + } + + out, err := client.ListMultipartUploads(ctx, in) + if err != nil { + return payload, translateError(err) + } + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), + fmt.Sprintf("ListMultipartUploads response: %d upload(s)", len(out.Uploads)), out) + + for i := range out.Uploads { + key := aws.ToString(out.Uploads[i].Key) + uploadID := aws.ToString(out.Uploads[i].UploadId) + size, sizeErr := accumulatedPartSize(ctx, client, bucket, key, uploadID) + if sizeErr != nil { + return payload, sizeErr + } + payload.Uploads = append(payload.Uploads, uploadEntry{ + Key: key, + UploadID: uploadID, + Initiated: aws.ToTime(out.Uploads[i].Initiated), + Size: size, + }) + } + + if !aws.ToBool(out.IsTruncated) { + payload.Truncated = false + return payload, nil + } + nextKey := aws.ToString(out.NextKeyMarker) + nextUpload := aws.ToString(out.NextUploadIdMarker) + if nextKey == "" && nextUpload == "" { + payload.Truncated = true + return payload, nil + } + keyMarker = out.NextKeyMarker + uploadIDMarker = out.NextUploadIdMarker + } +} + +// accumulatedPartSize sums every part's size for one upload via ListParts. +// A NoSuchUpload (the upload was aborted/expired between the list and this call) +// is treated as zero rather than a fatal error. +func accumulatedPartSize(ctx context.Context, client API, bucket, key, uploadID string) (int64, error) { + var ( + total int64 + marker *string + ) + for { + in := &s3.ListPartsInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + UploadId: aws.String(uploadID), + } + if marker != nil { + in.PartNumberMarker = marker + } + out, err := client.ListParts(ctx, in) + if err != nil { + if isNoSuchUpload(err) { + return total, nil + } + return 0, translateError(err) + } + for i := range out.Parts { + total += aws.ToInt64(out.Parts[i].Size) + } + if !aws.ToBool(out.IsTruncated) || aws.ToString(out.NextPartNumberMarker) == "" { + return total, nil + } + marker = out.NextPartNumberMarker + } +} + +func renderUploads(ioStreams cmdutil.IOStreams, payload uploadsPayload) { + if len(payload.Uploads) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No in-progress multipart uploads.") + return + } + + _, _ = fmt.Fprintf(ioStreams.Out, " %d in-progress upload(s)\n\n", len(payload.Uploads)) + _, _ = fmt.Fprintf(ioStreams.Out, " %-19s %-10s %-32s %s\n", "INITIATED", "SIZE", "UPLOAD ID", "KEY") + _, _ = fmt.Fprintf(ioStreams.Out, " %-19s %-10s %-32s %s\n", "---------", "----", "---------", "---") + for i := range payload.Uploads { + u := &payload.Uploads[i] + _, _ = fmt.Fprintf(ioStreams.Out, " %-19s %-10s %-32s %s\n", + u.Initiated.UTC().Format(timestampLayout), + humanBytes(u.Size), + u.UploadID, + u.Key, + ) + } + if payload.Truncated { + _, _ = fmt.Fprintln(ioStreams.Out, "\n(results truncated)") + } +} diff --git a/internal/verda-cli/cmd/s3/mb.go b/internal/verda-cli/cmd/s3/mb.go index e1781f9..12a90f5 100644 --- a/internal/verda-cli/cmd/s3/mb.go +++ b/internal/verda-cli/cmd/s3/mb.go @@ -38,14 +38,25 @@ func NewCmdMb(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { Long: cmdutil.LongDesc(` Create a new S3 bucket. The URI must be a bucket-only URI (s3://bucket) with no key component. + + Run with no argument on a terminal to be prompted for the name. `), Example: cmdutil.Examples(` # Create a new bucket verda s3 mb s3://my-new-bucket + + # Prompt for the name interactively + verda s3 mb `), - Args: cobra.ExactArgs(1), + // 0 args on a TTY prompts for the name; an explicit s3://bucket runs + // directly. --agent/non-TTY with no arg errors or shows help. + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runMb(cmd, f, ioStreams, args[0]) + arg, err := resolveNewBucketArg(cmd, f, args) + if err != nil || arg == "" { + return err + } + return runMb(cmd, f, ioStreams, arg) }, } diff --git a/internal/verda-cli/cmd/s3/move_wizard.go b/internal/verda-cli/cmd/s3/move_wizard.go new file mode 100644 index 0000000..cc480b4 --- /dev/null +++ b/internal/verda-cli/cmd/s3/move_wizard.go @@ -0,0 +1,212 @@ +// 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 s3 + +import ( + "context" + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// printMoveWizardIntro tells the user up front what the wizard will do — a move +// is a copy followed by deleting the source, so the heads-up matters. +func printMoveWizardIntro(ioStreams cmdutil.IOStreams) { + title := lipgloss.NewStyle().Bold(true) + dim := lipgloss.NewStyle().Faint(true) + _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n", title.Render("Move / rename an S3 object")) + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n", dim.Render("Copies the object to a new location, then deletes the source.")) + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n\n", dim.Render("Steps: pick source object → destination bucket → destination key → confirm. Esc: back · Ctrl+C: cancel")) +} + +// move wizard steps. +const ( + mvStepSourceBucket = iota + mvStepSourceObject + mvStepDestBucket + mvStepDestKey + mvStepConfirm +) + +// moveStepTitles is indexed by the step constants above. Keep in sync. +var moveStepTitles = []string{"Source bucket", "Source object", "Destination bucket", "Destination key", "Confirm"} + +// runMoveWizard guides an interactive S3->S3 move/rename as a stepped wizard. +// Every prompt is its own step, walked by an index into a steps slice, so Esc +// steps BACK exactly one prompt and Ctrl+C exits — the standard hint-bar +// contract. Steps the user can't act on (a source fixed by an argument) are +// dropped from the slice, so the "Step N of M" numbering always matches reality. +// On confirm it reuses the normal S3->S3 move path (CopyObject + delete). +// +// ctx is cmd.Context() (unbounded): the prompts involve user think-time and must +// not hit the short per-request --timeout. +func runMoveWizard(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *cpOptions, args []string) error { + ctx := cmd.Context() + client, err := buildClient(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + + srcBucket, srcKey, sourceFixed, err := parseMoveSourceArg(cmd, args) + if err != nil { + return err + } + var dstBucket, dstKey string + + printMoveWizardIntro(ioStreams) + + steps := buildMoveSteps(sourceFixed, srcBucket) + // i < 0 (Esc on the first step) or i == len(steps) (done) ends the loop. + for i := 0; i >= 0 && i < len(steps); { + step := steps[i] + printMoveStep(ioStreams, i, len(steps), step) + + switch step { + case mvStepSourceBucket: + b, perr := pickSourceBucket(ctx, f, ioStreams, client) + if i, err = selectStep(i, b, perr, func() { srcBucket = b }); err != nil { + return err + } + + case mvStepSourceObject: + k, perr := selectObjectKey(ctx, f, ioStreams, client, srcBucket) + if i, err = selectStep(i, k, perr, func() { srcKey = k }); err != nil { + return err + } + + case mvStepDestBucket: + b, perr := selectBucketOrCreate(ctx, f, ioStreams, client) + // selectBucketOrCreate never returns an empty name without an error. + if i, err = navIdx(i, perr, func() { dstBucket = b }); err != nil { + return err + } + + case mvStepDestKey: + k, perr := f.Prompter().TextInput(ctx, "Destination key", tui.WithDefault(srcKey)) + i, err = navIdx(i, perr, func() { + if k = strings.TrimSpace(k); k == "" { + k = srcKey + } + dstKey = k + }) + if err != nil { + return err + } + + case mvStepConfirm: + done, cerr := moveConfirmStep(ctx, cmd, f, ioStreams, opts, URI{Bucket: srcBucket, Key: srcKey}, URI{Bucket: dstBucket, Key: dstKey}) + if cerr != nil || done { + return cerr + } + i-- // not done (Esc or identical src/dst) -> back to the destination key + } + } + return nil +} + +// parseMoveSourceArg interprets an optional single s3:// argument: a full +// bucket/key fixes the source (sourceFixed=true); a bucket-only URI pre-fills +// srcBucket but still prompts for the object; no arg returns zeros. +func parseMoveSourceArg(cmd *cobra.Command, args []string) (srcBucket, srcKey string, sourceFixed bool, err error) { + if len(args) != 1 { + return "", "", false, nil + } + uri, perr := ParseS3URI(args[0]) + if perr != nil { + return "", "", false, cmdutil.UsageErrorf(cmd, "invalid source %q: %v", args[0], perr) + } + if uri.Key != "" { + return uri.Bucket, uri.Key, true, nil + } + return uri.Bucket, "", false, nil +} + +// buildMoveSteps returns the ordered steps the user will walk, dropping any that +// an argument already satisfied: a full bucket/key source skips both source +// steps; a bucket-only source skips just the bucket step. +func buildMoveSteps(sourceFixed bool, srcBucket string) []int { + var steps []int + if !sourceFixed { + if srcBucket == "" { + steps = append(steps, mvStepSourceBucket) + } + steps = append(steps, mvStepSourceObject) + } + return append(steps, mvStepDestBucket, mvStepDestKey, mvStepConfirm) +} + +// selectStep handles a list-picker step: an empty value with no error ends the +// wizard (no buckets/objects to act on), otherwise it delegates to navIdx. +func selectStep(i int, value string, perr error, apply func()) (next int, out error) { + if perr == nil && value == "" { + return -1, nil + } + return navIdx(i, perr, apply) +} + +// navIdx advances the wizard index based on a prompter error: Esc steps back +// (i-1; -1 on the first step ends the loop = exit), Ctrl+C exits (returns a +// terminal index), a real error propagates, and success runs apply() then i+1. +func navIdx(i int, err error, apply func()) (next int, out error) { + back, exit, fatal := classifyNav(err, false) + switch { + case fatal != nil: + return i, fatal + case exit: + return -1, nil // terminate the loop without acting + case back: + return i - 1, nil + default: + apply() + return i + 1, nil + } +} + +// moveConfirmStep previews the move and confirms it. done=false means step back +// to the key prompt (Esc, or an identical src/dst); done=true ends the wizard — +// the move ran, or the user exited/declined, with err carrying any real failure. +func moveConfirmStep(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *cpOptions, srcURI, dstURI URI) (done bool, err error) { + if srcURI == dstURI { + _, _ = fmt.Fprintln(ioStreams.ErrOut, " Source and destination are identical — choose a different destination.") + return false, nil + } + _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n Will run: verda s3 mv %s %s\n\n", srcURI.String(), dstURI.String()) + confirmed, cerr := f.Prompter().Confirm(ctx, "Proceed with move? (esc to go back)", tui.WithConfirmDefault(true)) + back, exit, fatal := classifyNav(cerr, false) + switch { + case fatal != nil: + return true, fatal + case back: + return false, nil // Esc -> step back to the key prompt + case exit: + return true, nil // Ctrl+C + case !confirmed: + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return true, nil + default: + return true, runCopyMv(ctx, cmd, f, ioStreams, srcURI, dstURI, opts) + } +} + +// printMoveStep renders the "Step N of M · Title" header for the i-th step of n. +func printMoveStep(ioStreams cmdutil.IOStreams, i, n, step int) { + header := fmt.Sprintf("Step %d of %d · %s", i+1, n, moveStepTitles[step]) + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n", lipgloss.NewStyle().Bold(true).Render(header)) +} diff --git a/internal/verda-cli/cmd/s3/mpdownload.go b/internal/verda-cli/cmd/s3/mpdownload.go new file mode 100644 index 0000000..0854ca3 --- /dev/null +++ b/internal/verda-cli/cmd/s3/mpdownload.go @@ -0,0 +1,375 @@ +// 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 s3 + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + + "github.com/verda-cloud/verda-cli/internal/verda-cli/options" +) + +// defaultDownloadPartSize is the chunk size for resumable downloads. Unlike +// uploads there is no 5 MiB floor or 10000-chunk ceiling on the download side. +const defaultDownloadPartSize int64 = 8 * 1024 * 1024 + +const downloadCheckpointDirName = "s3-downloads" + +// downloadCheckpoint is the on-disk resume state for a single download. ETag + +// TotalSize are the change-detector: if the remote object changes, they won't +// match and the download restarts. Chunks holds the completed 1-indexed chunks. +type downloadCheckpoint struct { + Bucket string `json:"bucket"` + Key string `json:"key"` + ETag string `json:"etag"` + DestPath string `json:"destPath"` + TotalSize int64 `json:"totalSize"` + PartSize int64 `json:"partSize"` + CreatedAt time.Time `json:"createdAt"` + Chunks []int32 `json:"chunks"` +} + +// resumableDownloadOptions parameterizes a single resumable download. +type resumableDownloadOptions struct { + Bucket string + Key string + DestPath string + PartSize int64 // 0 -> default + Concurrency int // 0 -> default + NoResume bool + // OnProgress, if set, is called with (doneBytes, totalBytes) after the + // initial reconcile and after each chunk. Calls are serialized. + OnProgress func(done, total int64) + // OnResume, if set, is called once with (alreadyBytes, totalBytes) when the + // download is continuing from a checkpoint (some chunks already on disk). + OnResume func(already, total int64) +} + +func downloadIdentity(absDest, bucket, key string) string { + h := sha256.New() + h.Write([]byte(absDest)) + h.Write([]byte{0}) + h.Write([]byte(bucket)) + h.Write([]byte{0}) + h.Write([]byte(key)) + return hex.EncodeToString(h.Sum(nil)) +} + +func downloadCheckpointPath(identity string) (string, error) { + base, err := options.VerdaDir() + if err != nil { + return "", err + } + return filepath.Join(base, downloadCheckpointDirName, identity+".json"), nil +} + +func loadDownloadCheckpoint(identity string) *downloadCheckpoint { + path, err := downloadCheckpointPath(identity) + if err != nil { + return nil + } + data, err := os.ReadFile(path) // #nosec G304 -- path derived from sha256 identity under ~/.verda + if err != nil { + return nil + } + var cp downloadCheckpoint + if json.Unmarshal(data, &cp) != nil { + return nil // corrupt -> treat as absent + } + return &cp +} + +func saveDownloadCheckpoint(identity string, cp *downloadCheckpoint) error { + path, err := downloadCheckpointPath(identity) + if err != nil { + return err + } + if mkErr := os.MkdirAll(filepath.Dir(path), 0o700); mkErr != nil { + return fmt.Errorf("create download checkpoint dir: %w", mkErr) + } + data, err := json.Marshal(cp) + if err != nil { + return fmt.Errorf("marshal download checkpoint: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("write download checkpoint: %w", err) + } + return os.Rename(tmp, path) +} + +func deleteDownloadCheckpoint(identity string) { + if path, err := downloadCheckpointPath(identity); err == nil { + _ = os.Remove(path) + } +} + +// hasResumableDownload reports whether a resume checkpoint exists for this exact +// (local destination, object) triple — i.e. an adjacent ".part" belongs to THIS +// download and should be continued, not treated as an occupied filename. +func hasResumableDownload(destPath, bucket, key string) bool { + abs, err := filepath.Abs(destPath) + if err != nil { + abs = destPath + } + return loadDownloadCheckpoint(downloadIdentity(abs, bucket, key)) != nil +} + +// gcDownloadCheckpoints prunes stale download checkpoints under +// ~/.verda/s3-downloads (left behind by interrupted downloads that were never +// resumed). Best-effort; a zero maxAge falls back to checkpointMaxAge. +func gcDownloadCheckpoints(maxAge time.Duration) error { + base, err := options.VerdaDir() + if err != nil { + return err + } + return gcStaleFiles(filepath.Join(base, downloadCheckpointDirName), maxAge) +} + +// loadOrResetDownloadCheckpoint returns the checkpoint to use and the set of +// chunks already on disk. A checkpoint that still matches the remote object +// (etag/size/partSize) with its .part file present is reused (resume); otherwise +// the stale .part is removed and a fresh checkpoint is created and persisted. +func loadOrResetDownloadCheckpoint(identity, partPath, etag string, total, partSize int64, opts *resumableDownloadOptions) (*downloadCheckpoint, map[int32]bool, error) { + done := map[int32]bool{} + cp := loadDownloadCheckpoint(identity) + if !opts.NoResume && cp != nil && cp.ETag == etag && cp.TotalSize == total && cp.PartSize == partSize && fileExists(partPath) { + for _, n := range cp.Chunks { + done[n] = true + } + return cp, done, nil + } + _ = os.Remove(partPath) // stale/changed -> start over + cp = &downloadCheckpoint{ + Bucket: opts.Bucket, Key: opts.Key, ETag: etag, DestPath: opts.DestPath, + TotalSize: total, PartSize: partSize, CreatedAt: time.Now().UTC(), + } + if err := saveDownloadCheckpoint(identity, cp); err != nil { + return nil, nil, err + } + return cp, done, nil +} + +// numChunks is the chunk count for total bytes at partSize (ceil division). +func numChunks(total, partSize int64) int32 { + if total <= 0 { + return 0 + } + n := total / partSize + if total%partSize != 0 { + n++ + } + return int32(n) //nolint:gosec // G115: chunk count is bounded by object size / part size +} + +// chunkRange returns the [start,end) byte range for chunk n (1-indexed). +func chunkRange(n int32, total, partSize int64) (start, end int64) { + start = int64(n-1) * partSize + end = min(start+partSize, total) + return start, end +} + +type downloadChunkResult struct { + n int32 + err error +} + +// resumableDownload downloads opts.Bucket/opts.Key to opts.DestPath using +// concurrent ranged GETs, resuming from a local checkpoint + ".part" file when +// the remote object is unchanged (ETag + size). It writes to ".part" and +// renames on success. Returns the object's total size. Only the API interface +// is used, so it is fully fakeable. +func resumableDownload(ctx context.Context, client API, opts *resumableDownloadOptions) (int64, error) { + head, err := client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(opts.Bucket), + Key: aws.String(opts.Key), + }) + if err != nil { + return 0, translateError(err) + } + total := aws.ToInt64(head.ContentLength) + etag := aws.ToString(head.ETag) + + partSize := opts.PartSize + if partSize <= 0 { + partSize = defaultDownloadPartSize + } + concurrency := opts.Concurrency + if concurrency <= 0 { + concurrency = defaultConcurrency + } + + absDest, err := filepath.Abs(opts.DestPath) + if err != nil { + return 0, err + } + identity := downloadIdentity(absDest, opts.Bucket, opts.Key) + + // Same-host guard: two downloads of the same object would race the .part file. + release, acquired, err := acquireTransferLock(identity) + if err != nil { + return 0, err + } + if !acquired { + return 0, fmt.Errorf("a download of s3://%s/%s is already in progress on this machine", opts.Bucket, opts.Key) + } + defer release() + + // Keyed off absDest (not opts.DestPath) so the .part file is stable across + // runs regardless of cwd — matching the lock/checkpoint identity. + partPath := absDest + ".part" + cp, done, err := loadOrResetDownloadCheckpoint(identity, partPath, etag, total, partSize, opts) + if err != nil { + return 0, err + } + + if len(done) > 0 && opts.OnResume != nil { + opts.OnResume(min(int64(len(done))*partSize, total), total) + } + + if err := os.MkdirAll(filepath.Dir(opts.DestPath), 0o750); err != nil { + return 0, err + } + file, err := os.OpenFile(partPath, os.O_RDWR|os.O_CREATE, 0o600) // #nosec G304 -- caller-specified destination + if err != nil { + return 0, err + } + + if err := downloadMissingChunks(ctx, client, opts, identity, cp, file, etag, total, partSize, concurrency, done); err != nil { + _ = file.Close() + return 0, err + } + if err := file.Close(); err != nil { + return 0, err + } + if err := os.Rename(partPath, opts.DestPath); err != nil { + return 0, fmt.Errorf("finalize download: %w", err) + } + deleteDownloadCheckpoint(identity) + return total, nil +} + +// downloadMissingChunks fetches every chunk not already in done with a bounded +// worker pool, writing each at its offset and recording it in the checkpoint. +func downloadMissingChunks(ctx context.Context, client API, opts *resumableDownloadOptions, identity string, cp *downloadCheckpoint, file *os.File, etag string, total, partSize int64, concurrency int, done map[int32]bool) error { + totalChunks := numChunks(total, partSize) + if opts.OnProgress != nil { + opts.OnProgress(min(int64(len(done))*partSize, total), total) + } + + missing := make([]int32, 0, int(totalChunks)) + for n := int32(1); n <= totalChunks; n++ { + if !done[n] { + missing = append(missing, n) + } + } + if len(missing) == 0 { + return nil + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + jobs := make(chan int32) + results := make(chan downloadChunkResult) + var wg sync.WaitGroup + for range concurrency { + wg.Add(1) + go func() { + defer wg.Done() + for n := range jobs { + err := downloadOneChunk(ctx, client, opts.Bucket, opts.Key, etag, file, n, total, partSize) + select { + case results <- downloadChunkResult{n: n, err: err}: + case <-ctx.Done(): + return + } + } + }() + } + go func() { + defer close(jobs) + for _, n := range missing { + select { + case jobs <- n: + case <-ctx.Done(): + return + } + } + }() + go func() { + wg.Wait() + close(results) + }() + + completed := int64(len(done)) + var firstErr error + for res := range results { + if res.err != nil { + if firstErr == nil { + firstErr = res.err + cancel() + } + continue + } + cp.Chunks = append(cp.Chunks, res.n) + if err := saveDownloadCheckpoint(identity, cp); err != nil && firstErr == nil { + firstErr = err + cancel() + continue + } + completed++ + if opts.OnProgress != nil { + opts.OnProgress(min(completed*partSize, total), total) + } + } + return firstErr +} + +// downloadOneChunk fetches chunk n via a ranged, If-Match GET and writes it at +// the chunk's offset. If-Match makes the server reject (412) a changed object. +func downloadOneChunk(ctx context.Context, client API, bucket, key, etag string, file *os.File, n int32, total, partSize int64) error { + start, end := chunkRange(n, total, partSize) + out, err := client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + Range: aws.String(fmt.Sprintf("bytes=%d-%d", start, end-1)), + IfMatch: aws.String(etag), + }) + if err != nil { + return translateError(err) + } + defer func() { _ = out.Body.Close() }() + if _, err := io.Copy(io.NewOffsetWriter(file, start), out.Body); err != nil { + return fmt.Errorf("write chunk %d: %w", n, err) + } + return nil +} + +func fileExists(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} diff --git a/internal/verda-cli/cmd/s3/mpdownload_test.go b/internal/verda-cli/cmd/s3/mpdownload_test.go new file mode 100644 index 0000000..e54e8a0 --- /dev/null +++ b/internal/verda-cli/cmd/s3/mpdownload_test.go @@ -0,0 +1,191 @@ +// 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. + +//go:build !windows + +package s3 + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "sync" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +// dlFakeAPI serves a fixed object via Head + ranged Get, recording calls and +// optionally failing a specific chunk (to simulate a mid-download break). +type dlFakeAPI struct { + API + content []byte + etag string + partSize int64 + failChunk int32 + + mu sync.Mutex + getCalls int +} + +func (d *dlFakeAPI) HeadObject(ctx context.Context, in *s3.HeadObjectInput, opts ...func(*s3.Options)) (*s3.HeadObjectOutput, error) { + return &s3.HeadObjectOutput{ContentLength: aws.Int64(int64(len(d.content))), ETag: aws.String(d.etag)}, nil +} + +func (d *dlFakeAPI) GetObject(ctx context.Context, in *s3.GetObjectInput, opts ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + var start, end int64 + if _, err := fmt.Sscanf(aws.ToString(in.Range), "bytes=%d-%d", &start, &end); err != nil { + return nil, fmt.Errorf("bad range %q", aws.ToString(in.Range)) + } + n := int32(start/d.partSize) + 1 //nolint:gosec // G115: test chunk index + d.mu.Lock() + d.getCalls++ + d.mu.Unlock() + if d.failChunk != 0 && n == d.failChunk { + return nil, errors.New("injected get failure") + } + return &s3.GetObjectOutput{Body: io.NopCloser(bytes.NewReader(d.content[start : end+1]))}, nil +} + +func (d *dlFakeAPI) calls() int { d.mu.Lock(); defer d.mu.Unlock(); return d.getCalls } + +func TestResumableDownload_Fresh(t *testing.T) { + withTempVerdaHome(t) + t.Chdir(t.TempDir()) + content := bytes.Repeat([]byte("ab"), 1280) // 2560 bytes -> 3 chunks at 1 KiB + fake := &dlFakeAPI{content: content, etag: "\"e\"", partSize: 1024} + + resumeCalled := false + n, err := resumableDownload(context.Background(), fake, &resumableDownloadOptions{ + Bucket: "b", Key: "k", DestPath: "out.bin", PartSize: 1024, Concurrency: 1, + OnResume: func(already, total int64) { resumeCalled = true }, + }) + if err != nil { + t.Fatalf("resumableDownload: %v", err) + } + if resumeCalled { + t.Error("OnResume should not fire on a fresh download") + } + if n != int64(len(content)) { + t.Errorf("size = %d, want %d", n, len(content)) + } + got, rerr := os.ReadFile("out.bin") + if rerr != nil || !bytes.Equal(got, content) { + t.Errorf("downloaded file mismatch (err=%v)", rerr) + } + if fake.calls() != 3 { + t.Errorf("GetObject calls = %d, want 3", fake.calls()) + } + if _, statErr := os.Stat("out.bin.part"); statErr == nil { + t.Error(".part file should be renamed away on success") + } +} + +func TestResumableDownload_BreakThenResume(t *testing.T) { + withTempVerdaHome(t) + t.Chdir(t.TempDir()) + content := bytes.Repeat([]byte("xy"), 1280) // 2560 bytes -> 3 chunks + dst := "model.bin" + + // Run 1: chunk 2 fails after chunk 1 succeeds (concurrency 1 -> ordered). + fake := &dlFakeAPI{content: content, etag: "\"e\"", partSize: 1024, failChunk: 2} + if _, err := resumableDownload(context.Background(), fake, &resumableDownloadOptions{ + Bucket: "b", Key: "k", DestPath: dst, PartSize: 1024, Concurrency: 1, + }); err == nil { + t.Fatal("expected first run to fail") + } + if _, statErr := os.Stat(dst + ".part"); statErr != nil { + t.Fatalf(".part should persist after a break: %v", statErr) + } + + // Run 2: resume — only the missing chunks (2,3) are fetched. + fake.failChunk = 0 + fake.mu.Lock() + fake.getCalls = 0 + fake.mu.Unlock() + var resumeAlready, resumeTotal int64 + n, err := resumableDownload(context.Background(), fake, &resumableDownloadOptions{ + Bucket: "b", Key: "k", DestPath: dst, PartSize: 1024, Concurrency: 1, + OnResume: func(already, total int64) { resumeAlready, resumeTotal = already, total }, + }) + if err != nil { + t.Fatalf("resume: %v", err) + } + if resumeAlready != 1024 || resumeTotal != int64(len(content)) { + t.Errorf("OnResume = (%d, %d), want (1024, %d) — one chunk already on disk", resumeAlready, resumeTotal, len(content)) + } + if n != int64(len(content)) { + t.Errorf("size = %d, want %d", n, len(content)) + } + got, _ := os.ReadFile(dst) + if !bytes.Equal(got, content) { + t.Error("resumed file does not match the source content") + } + if c := fake.calls(); c != 2 { + t.Errorf("resume GetObject calls = %d, want 2 (only chunks 2,3)", c) + } +} + +// TestResumableDownload_ETagChangeRestarts proves the If-Match safety property: +// if the object changes between an interrupted run and the resume, the stale +// .part is discarded and every chunk is re-fetched against the new object. +func TestResumableDownload_ETagChangeRestarts(t *testing.T) { + withTempVerdaHome(t) + t.Chdir(t.TempDir()) + content := bytes.Repeat([]byte("xy"), 1280) // 2560 bytes -> 3 chunks + dst := "obj.bin" + + // Run 1: break on chunk 2 so chunk 1 lands in the .part + checkpoint. + fake := &dlFakeAPI{content: content, etag: "\"old\"", partSize: 1024, failChunk: 2} + if _, err := resumableDownload(context.Background(), fake, &resumableDownloadOptions{ + Bucket: "b", Key: "k", DestPath: dst, PartSize: 1024, Concurrency: 1, + }); err == nil { + t.Fatal("expected first run to fail") + } + + // Object changes server-side: new ETag and fresh content. + newContent := bytes.Repeat([]byte("zw"), 1280) + fake.etag = "\"new\"" + fake.content = newContent + fake.failChunk = 0 + fake.mu.Lock() + fake.getCalls = 0 + fake.mu.Unlock() + + resumeCalled := false + n, err := resumableDownload(context.Background(), fake, &resumableDownloadOptions{ + Bucket: "b", Key: "k", DestPath: dst, PartSize: 1024, Concurrency: 1, + OnResume: func(already, total int64) { resumeCalled = true }, + }) + if err != nil { + t.Fatalf("resume after change: %v", err) + } + if resumeCalled { + t.Error("OnResume must not fire — a changed object restarts from scratch") + } + if c := fake.calls(); c != 3 { + t.Errorf("GetObject calls = %d, want 3 (full re-fetch, not a partial resume)", c) + } + if n != int64(len(newContent)) { + t.Errorf("size = %d, want %d", n, len(newContent)) + } + got, _ := os.ReadFile(dst) + if !bytes.Equal(got, newContent) { + t.Error("restarted download must reflect the NEW object content, not a mix") + } +} diff --git a/internal/verda-cli/cmd/s3/mpupload.go b/internal/verda-cli/cmd/s3/mpupload.go new file mode 100644 index 0000000..3b2c242 --- /dev/null +++ b/internal/verda-cli/cmd/s3/mpupload.go @@ -0,0 +1,432 @@ +// 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 s3 + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "sort" + "sync" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +const ( + // minPartSize is the S3/RGW floor for every part except the last (5 MiB). + minPartSize int64 = 5 * 1024 * 1024 + // maxParts is the hard S3 ceiling on parts per multipart upload. + maxParts int64 = 10000 + // defaultConcurrency is the worker-pool width when the caller passes 0. + defaultConcurrency = 5 +) + +// resumableOptions parameterizes a single resumable multipart upload. +// PartSize/Concurrency of 0 fall back to computed/default values. +type resumableOptions struct { + AbsPath string + Bucket string + Key string + ContentType string + FileSize int64 + MTime time.Time + PartSize int64 + Concurrency int + NoResume bool + // OnProgress, if set, is called with (completedParts, totalParts) after the + // initial server reconcile and after each part finishes. Calls are + // serialized (safe to drive a progress bar). nil disables reporting. + OnProgress func(done, total int32) +} + +// computePartSize returns a part size >= minPartSize, scaled up so the file +// splits into at most maxParts parts. A requested size below the floor or that +// would exceed maxParts is bumped to the smallest valid value. +func computePartSize(fileSize, requested int64) int64 { + size := requested + if size < minPartSize { + size = minPartSize + } + // Double until the file splits into at most maxParts parts. Ceil division: + // a file that is an exact multiple of size*maxParts needs exactly maxParts + // parts (allowed) and must NOT be bumped — the old `fileSize/size+1` form + // fired on that boundary and needlessly doubled the part size. + for (fileSize+size-1)/size > maxParts { + size *= 2 + } + return size +} + +// numParts returns the part count for fileSize at partSize (ceil division). +// A zero-length file is still one part for the multipart machinery, but the +// resumable path is never entered for files <= partSize (see cp routing). +func numParts(fileSize, partSize int64) int32 { + if fileSize == 0 { + return 1 + } + n := fileSize / partSize + if fileSize%partSize != 0 { + n++ + } + // computePartSize guarantees n <= maxParts (10000), well within int32. + if n > maxParts { + n = maxParts + } + return int32(n) //nolint:gosec // G115: capped at maxParts (10000) just above +} + +// partRange returns the deterministic byte range [start,end) for part n +// (1-indexed) of a fileSize-byte file at partSize. The last part is short. +func partRange(n int32, fileSize, partSize int64) (start, end int64) { + start = int64(n-1) * partSize + end = start + partSize + if end > fileSize { + end = fileSize + } + return start, end +} + +// uploadPartResult carries one worker's outcome back to the collector. +type uploadPartResult struct { + n int32 + etag string + err error +} + +// resumableUpload runs (or resumes) a multipart upload of opts.AbsPath to +// opts.Bucket/opts.Key using only the API interface, so it is fully fakeable. +// +// Decision tree (design §2): a valid local checkpoint whose size+mtime match +// and whose UploadId the server still recognizes (ListParts) resumes, uploading +// only the parts the server lacks; otherwise it starts fresh, proactively +// aborting any stale upload it was about to abandon. The server is always +// authoritative — the local checkpoint is a hint reconciled against ListParts. +func resumableUpload(ctx context.Context, client API, opts *resumableOptions) error { + partSize := computePartSize(opts.FileSize, opts.PartSize) + identity := uploadIdentity(opts.AbsPath, opts.Bucket, opts.Key) + + // Same-host guard: refuse a second concurrent upload of this object so two + // processes can't race on the checkpoint and double-upload parts. + release, acquired, err := acquireTransferLock(identity) + if err != nil { + return err + } + if !acquired { + return fmt.Errorf("an upload of s3://%s/%s is already in progress on this machine", opts.Bucket, opts.Key) + } + defer release() + + cp, uploadID, err := resolveUpload(ctx, client, opts, identity, partSize) + if err != nil { + return err + } + + done := make(map[int32]string, len(cp.Parts)) + for i := range cp.Parts { + done[cp.Parts[i].N] = cp.Parts[i].ETag + } + + total := numParts(opts.FileSize, partSize) + if opts.OnProgress != nil { + opts.OnProgress(int32(len(done)), total) //nolint:gosec // G115: part count is capped at 10000 + } + if err := uploadMissingParts(ctx, client, opts, identity, cp, partSize, total, done); err != nil { + return err + } + + if err := completeUpload(ctx, client, opts, uploadID, cp); err != nil { + return err + } + return deleteCheckpoint(identity) +} + +// resolveUpload walks the decision tree and returns a checkpoint + UploadId +// that is ready to (re)use. On any fresh path it has already created a new +// multipart upload and persisted the initial checkpoint; on resume it returns +// the reconciled checkpoint backed by ListParts. +func resolveUpload(ctx context.Context, client API, opts *resumableOptions, identity string, partSize int64) (*checkpoint, string, error) { + existing, err := loadCheckpoint(identity) + if err != nil { + return nil, "", err + } + + fresh := func() (*checkpoint, string, error) { + if existing != nil && existing.UploadID != "" { + // Self-cleanup: never strand our own prior upload's parts. + _, _ = client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{ + Bucket: aws.String(opts.Bucket), + Key: aws.String(opts.Key), + UploadId: aws.String(existing.UploadID), + }) + } + return startFresh(ctx, client, opts, identity, partSize) + } + + if existing == nil || opts.NoResume { + return fresh() + } + // PartSize must match: the server holds parts at the checkpoint's boundaries, + // so resuming with a different part size (e.g. a changed --part-size) would + // upload misaligned ranges and assemble a corrupt object. Restart instead. + if existing.FileSize != opts.FileSize || !existing.MTime.Equal(opts.MTime) || existing.PartSize != partSize { + return fresh() + } + + listed, err := listAllParts(ctx, client, opts.Bucket, opts.Key, existing.UploadID) + if err != nil { + if isNoSuchUpload(err) { + _ = deleteCheckpoint(identity) + return startFresh(ctx, client, opts, identity, partSize) + } + return nil, "", translateError(err) + } + + reconciled := reconcileCheckpoint(existing, listed) + if err := saveCheckpoint(identity, reconciled); err != nil { + return nil, "", err + } + return reconciled, reconciled.UploadID, nil +} + +// startFresh creates a new multipart upload and writes the initial checkpoint +// (no parts yet). Returns the checkpoint and its UploadId. +func startFresh(ctx context.Context, client API, opts *resumableOptions, identity string, partSize int64) (*checkpoint, string, error) { + out, err := client.CreateMultipartUpload(ctx, &s3.CreateMultipartUploadInput{ + Bucket: aws.String(opts.Bucket), + Key: aws.String(opts.Key), + ContentType: aws.String(opts.ContentType), + }) + if err != nil { + return nil, "", translateError(err) + } + uploadID := aws.ToString(out.UploadId) + cp := &checkpoint{ + UploadID: uploadID, + Bucket: opts.Bucket, + Key: opts.Key, + AbsPath: opts.AbsPath, + FileSize: opts.FileSize, + MTime: opts.MTime, + PartSize: partSize, + CreatedAt: time.Now().UTC(), + } + if err := saveCheckpoint(identity, cp); err != nil { + // Don't strand the just-created server-side upload (it consumes storage + // and would be invisible to `ls`) if we can't persist its checkpoint. + _, _ = client.AbortMultipartUpload(ctx, &s3.AbortMultipartUploadInput{ + Bucket: aws.String(opts.Bucket), + Key: aws.String(opts.Key), + UploadId: aws.String(uploadID), + }) + return nil, "", err + } + return cp, uploadID, nil +} + +// listAllParts paginates ListParts via PartNumberMarker and returns every +// part the server holds for uploadID. S3/RGW caps each page at 1000 parts, so +// a single call would silently drop parts 1001+ — and resume would re-upload +// everything past the first page. Returns the raw API error (untranslated) so +// the caller can detect NoSuchUpload before mapping it. +func listAllParts(ctx context.Context, client API, bucket, key, uploadID string) ([]s3types.Part, error) { + var ( + parts []s3types.Part + marker *string + ) + for { + in := &s3.ListPartsInput{ + Bucket: aws.String(bucket), + Key: aws.String(key), + UploadId: aws.String(uploadID), + } + if marker != nil { + in.PartNumberMarker = marker + } + out, err := client.ListParts(ctx, in) + if err != nil { + return nil, err + } + parts = append(parts, out.Parts...) + if !aws.ToBool(out.IsTruncated) || aws.ToString(out.NextPartNumberMarker) == "" { + return parts, nil + } + marker = out.NextPartNumberMarker + } +} + +// reconcileCheckpoint rebuilds the parts list from the server's ListParts +// output — the server is authoritative. The local checkpoint's UploadId and +// metadata are preserved; only the completed-parts set is replaced. +func reconcileCheckpoint(cp *checkpoint, listed []s3types.Part) *checkpoint { + parts := make([]checkpointPart, 0, len(listed)) + for i := range listed { + parts = append(parts, checkpointPart{ + N: aws.ToInt32(listed[i].PartNumber), + ETag: aws.ToString(listed[i].ETag), + }) + } + sort.Slice(parts, func(i, j int) bool { return parts[i].N < parts[j].N }) + out := *cp + out.Parts = parts + return &out +} + +// uploadMissingParts uploads every part in [1,total] not already in done, +// using a bounded worker pool. Each successful part is appended to the +// checkpoint and flushed before the next is acknowledged, so a crash resumes +// from the last persisted part. Checkpoint mutation is serialized by mu. +func uploadMissingParts(ctx context.Context, client API, opts *resumableOptions, identity string, cp *checkpoint, partSize int64, total int32, done map[int32]string) error { + missing := make([]int32, 0, int(total)) + for n := int32(1); n <= total; n++ { + if _, ok := done[n]; !ok { + missing = append(missing, n) + } + } + if len(missing) == 0 { + return nil + } + + concurrency := opts.Concurrency + if concurrency <= 0 { + concurrency = defaultConcurrency + } + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + jobs := make(chan int32) + results := make(chan uploadPartResult) + var wg sync.WaitGroup + for range concurrency { + wg.Add(1) + go func() { + defer wg.Done() + for n := range jobs { + etag, err := uploadOnePart(ctx, client, opts, cp.UploadID, n, partSize) + select { + case results <- uploadPartResult{n: n, etag: etag, err: err}: + case <-ctx.Done(): + return + } + } + }() + } + + go func() { + defer close(jobs) + for _, n := range missing { + select { + case jobs <- n: + case <-ctx.Done(): + return + } + } + }() + + go func() { + wg.Wait() + close(results) + }() + + var mu sync.Mutex + var firstErr error + completed := int32(len(done)) //nolint:gosec // G115: part count is capped at 10000 + for res := range results { + if res.err != nil { + if firstErr == nil { + firstErr = res.err + cancel() + } + continue + } + mu.Lock() + err := appendPart(identity, cp, res.n, res.etag) + mu.Unlock() + if err != nil && firstErr == nil { + firstErr = err + cancel() + continue + } + completed++ + if opts.OnProgress != nil { + opts.OnProgress(completed, total) + } + } + return firstErr +} + +// uploadOnePart reads part n's deterministic byte range from the local file and +// uploads it. CRITICAL: no ChecksumAlgorithm/checksum fields are set — that +// would reintroduce aws-chunked/CRC32 trailers and break RGW (400 +// XAmzContentSHA256Mismatch). ContentLength is set so the non-seekable section +// reader does not trigger chunked transfer-encoding. +func uploadOnePart(ctx context.Context, client API, opts *resumableOptions, uploadID string, n int32, partSize int64) (string, error) { + f, err := os.Open(opts.AbsPath) // #nosec G304 -- AbsPath is the user-specified upload source + if err != nil { + return "", fmt.Errorf("open source: %w", err) + } + defer func() { _ = f.Close() }() + + start, end := partRange(n, opts.FileSize, partSize) + section := io.NewSectionReader(f, start, end-start) + + out, err := client.UploadPart(ctx, &s3.UploadPartInput{ + Bucket: aws.String(opts.Bucket), + Key: aws.String(opts.Key), + UploadId: aws.String(uploadID), + PartNumber: aws.Int32(n), + Body: section, + ContentLength: aws.Int64(end - start), + }) + if err != nil { + return "", translateError(err) + } + return aws.ToString(out.ETag), nil +} + +// completeUpload finalizes the multipart upload with the full ordered part set +// from the checkpoint. Parts MUST be ascending by PartNumber. +func completeUpload(ctx context.Context, client API, opts *resumableOptions, uploadID string, cp *checkpoint) error { + if len(cp.Parts) == 0 { + return errNoParts + } + sort.Slice(cp.Parts, func(i, j int) bool { return cp.Parts[i].N < cp.Parts[j].N }) + parts := make([]s3types.CompletedPart, 0, len(cp.Parts)) + for i := range cp.Parts { + parts = append(parts, s3types.CompletedPart{ + ETag: aws.String(cp.Parts[i].ETag), + PartNumber: aws.Int32(cp.Parts[i].N), + }) + } + _, err := client.CompleteMultipartUpload(ctx, &s3.CompleteMultipartUploadInput{ + Bucket: aws.String(opts.Bucket), + Key: aws.String(opts.Key), + UploadId: aws.String(uploadID), + MultipartUpload: &s3types.CompletedMultipartUpload{Parts: parts}, + }) + if err != nil { + return translateError(err) + } + return nil +} + +// errNoParts guards Complete against an empty part set (defensive; the upload +// loop always produces at least one part for a non-empty file). +var errNoParts = errors.New("multipart upload has no parts to complete") diff --git a/internal/verda-cli/cmd/s3/mpupload_test.go b/internal/verda-cli/cmd/s3/mpupload_test.go new file mode 100644 index 0000000..2fba703 --- /dev/null +++ b/internal/verda-cli/cmd/s3/mpupload_test.go @@ -0,0 +1,606 @@ +// 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 s3 + +import ( + "context" + "errors" + "os" + "path/filepath" + "sort" + "strconv" + "sync" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" +) + +// fakeMPUploadAPI is a thread-safe, in-memory multipart-upload backend. +// +// It records Create/UploadPart/Complete/Abort/ListParts calls and can be told +// to fail the Nth UploadPart (failAfterPart: succeed for the first N parts, +// then return an error). Parts that "succeed" are remembered so a later +// ListParts reflects server state — letting a resume run see only the missing +// parts. listPartsErr forces ListParts to fail (e.g. NoSuchUpload). +type fakeMPUploadAPI struct { + API + + mu sync.Mutex + + createCalls int + createUploadID string + + uploadedParts map[int32]string // server-side parts: PartNumber -> ETag + uploadCalls int + uploadOrder []int32 // PartNumbers in the order UploadPart was invoked + + completeCalls int + completedSet []s3types.CompletedPart + + abortCalls int + abortUploadIDs []string + + listPartsCalls int + listPartsErr error + // partsPageSize: when > 0, ListParts paginates at this many parts per page + // via PartNumberMarker (mirrors the S3/RGW 1000-part page cap). 0 returns + // every part in a single page. + partsPageSize int + + // failAfterPart: when > 0, the (failAfterPart+1)th *successful-so-far* + // UploadPart and beyond fail. i.e. exactly failAfterPart parts succeed. + failAfterPart int + failErr error +} + +func newFakeMPUploadAPI() *fakeMPUploadAPI { + return &fakeMPUploadAPI{ + createUploadID: "upload-1", + uploadedParts: map[int32]string{}, + } +} + +func (f *fakeMPUploadAPI) CreateMultipartUpload(ctx context.Context, in *s3.CreateMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.createCalls++ + return &s3.CreateMultipartUploadOutput{UploadId: aws.String(f.createUploadID)}, nil +} + +func (f *fakeMPUploadAPI) UploadPart(ctx context.Context, in *s3.UploadPartInput, opts ...func(*s3.Options)) (*s3.UploadPartOutput, error) { + // Guard against the checksum regression: a checksum field on UploadPart + // reintroduces the aws-chunked/CRC32 trailer that RGW rejects. + if in.ChecksumAlgorithm != "" || in.ChecksumCRC32 != nil || in.ChecksumSHA256 != nil { + return nil, errors.New("UploadPart must not set any checksum field (RGW compat)") + } + f.mu.Lock() + defer f.mu.Unlock() + f.uploadCalls++ + n := aws.ToInt32(in.PartNumber) + f.uploadOrder = append(f.uploadOrder, n) + if f.failAfterPart > 0 && len(f.uploadedParts) >= f.failAfterPart { + err := f.failErr + if err == nil { + err = errors.New("injected upload failure") + } + return nil, err + } + etag := "\"etag-" + string('0'+n) + "\"" + f.uploadedParts[n] = etag + return &s3.UploadPartOutput{ETag: aws.String(etag)}, nil +} + +func (f *fakeMPUploadAPI) ListParts(ctx context.Context, in *s3.ListPartsInput, opts ...func(*s3.Options)) (*s3.ListPartsOutput, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.listPartsCalls++ + if f.listPartsErr != nil { + return nil, f.listPartsErr + } + parts := make([]s3types.Part, 0, len(f.uploadedParts)) + for n, etag := range f.uploadedParts { + parts = append(parts, s3types.Part{PartNumber: aws.Int32(n), ETag: aws.String(etag)}) + } + sort.Slice(parts, func(i, j int) bool { + return aws.ToInt32(parts[i].PartNumber) < aws.ToInt32(parts[j].PartNumber) + }) + + if f.partsPageSize <= 0 { + return &s3.ListPartsOutput{Parts: parts, IsTruncated: aws.Bool(false)}, nil + } + + // Paginate: emit only parts with PartNumber > marker, capped at pageSize. + marker, _ := strconv.Atoi(aws.ToString(in.PartNumberMarker)) + page := make([]s3types.Part, 0, f.partsPageSize) + for i := range parts { + if int(aws.ToInt32(parts[i].PartNumber)) <= marker { + continue + } + page = append(page, parts[i]) + if len(page) == f.partsPageSize { + break + } + } + truncated := len(page) == f.partsPageSize && + aws.ToInt32(page[len(page)-1].PartNumber) < aws.ToInt32(parts[len(parts)-1].PartNumber) + out := &s3.ListPartsOutput{Parts: page, IsTruncated: aws.Bool(truncated)} + if truncated { + out.NextPartNumberMarker = aws.String(strconv.Itoa(int(aws.ToInt32(page[len(page)-1].PartNumber)))) + } + return out, nil +} + +func (f *fakeMPUploadAPI) CompleteMultipartUpload(ctx context.Context, in *s3.CompleteMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CompleteMultipartUploadOutput, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.completeCalls++ + if in.MultipartUpload != nil { + f.completedSet = in.MultipartUpload.Parts + } + return &s3.CompleteMultipartUploadOutput{}, nil +} + +func (f *fakeMPUploadAPI) AbortMultipartUpload(ctx context.Context, in *s3.AbortMultipartUploadInput, opts ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.abortCalls++ + f.abortUploadIDs = append(f.abortUploadIDs, aws.ToString(in.UploadId)) + return &s3.AbortMultipartUploadOutput{}, nil +} + +// writeTempFile writes size bytes (deterministic pattern) and returns abs path, +// size, and mtime for use as resumableOptions. +func writeTempFile(t *testing.T, size int64) (path string, fsize int64, mtime time.Time) { + t.Helper() + dir := t.TempDir() + path = filepath.Join(dir, "big.bin") + data := make([]byte, size) + for i := range data { + data[i] = byte(i % 251) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + abs, err := filepath.Abs(path) + if err != nil { + t.Fatalf("abs: %v", err) + } + info, err := os.Stat(abs) + if err != nil { + t.Fatalf("stat: %v", err) + } + return abs, info.Size(), info.ModTime() +} + +func optsFor(abs string, size int64, mtime time.Time, partSize int64, concurrency int) *resumableOptions { + return &resumableOptions{ + AbsPath: abs, + Bucket: "b", + Key: "k", + ContentType: "application/octet-stream", + FileSize: size, + MTime: mtime, + PartSize: partSize, + Concurrency: concurrency, + } +} + +// verifyCompletedParts asserts the completed set is exactly 1..wantN in +// ascending PartNumber order with non-empty ETags. +func verifyCompletedParts(t *testing.T, parts []s3types.CompletedPart, wantN int) { + t.Helper() + if len(parts) != wantN { + t.Fatalf("completed parts = %d, want %d", len(parts), wantN) + } + for i := range parts { + wantNum := int32(i + 1) //nolint:gosec // G115: test loop index + if aws.ToInt32(parts[i].PartNumber) != wantNum { + t.Errorf("completed[%d] PartNumber = %d, want %d (must be ascending)", i, aws.ToInt32(parts[i].PartNumber), wantNum) + } + if aws.ToString(parts[i].ETag) == "" { + t.Errorf("completed[%d] ETag empty", i) + } + } +} + +func TestResumableUpload_Fresh(t *testing.T) { + withTempVerdaHome(t) + abs, size, mtime := writeTempFile(t, 3*minPartSize+100) // 4 parts + fake := newFakeMPUploadAPI() + + if err := resumableUpload(context.Background(), fake, optsFor(abs, size, mtime, minPartSize, 1)); err != nil { + t.Fatalf("resumableUpload: %v", err) + } + if fake.createCalls != 1 { + t.Errorf("CreateMultipartUpload calls = %d, want 1", fake.createCalls) + } + if fake.uploadCalls != 4 { + t.Errorf("UploadPart calls = %d, want 4", fake.uploadCalls) + } + if fake.completeCalls != 1 { + t.Errorf("Complete calls = %d, want 1", fake.completeCalls) + } + verifyCompletedParts(t, fake.completedSet, 4) + + // Checkpoint deleted on success. + id := uploadIdentity(abs, "b", "k") + if cp, _ := loadCheckpoint(id); cp != nil { + t.Errorf("checkpoint should be deleted after success, got %+v", cp) + } +} + +func TestResumableUpload_BreakAfterPartK_PersistsCheckpoint(t *testing.T) { + withTempVerdaHome(t) + abs, size, mtime := writeTempFile(t, 3*minPartSize+100) // 4 parts + fake := newFakeMPUploadAPI() + fake.failAfterPart = 2 // 2 parts succeed, then fail + + err := resumableUpload(context.Background(), fake, optsFor(abs, size, mtime, minPartSize, 1)) + if err == nil { + t.Fatal("expected error when upload breaks mid-way") + } + if fake.completeCalls != 0 { + t.Errorf("Complete must NOT be called on failure, got %d", fake.completeCalls) + } + + id := uploadIdentity(abs, "b", "k") + cp, loadErr := loadCheckpoint(id) + if loadErr != nil { + t.Fatalf("load checkpoint: %v", loadErr) + } + if cp == nil { + t.Fatal("checkpoint should persist after a mid-upload break") + } + if len(cp.Parts) != 2 { + t.Errorf("persisted parts = %d, want 2", len(cp.Parts)) + } + if cp.UploadID != "upload-1" { + t.Errorf("checkpoint UploadID = %q, want upload-1", cp.UploadID) + } +} + +func TestResumableUpload_Resume_OnlyMissingParts(t *testing.T) { + withTempVerdaHome(t) + abs, size, mtime := writeTempFile(t, 3*minPartSize+100) // 4 parts + + // First run: break after 2 parts. + fake := newFakeMPUploadAPI() + fake.failAfterPart = 2 + if err := resumableUpload(context.Background(), fake, optsFor(abs, size, mtime, minPartSize, 1)); err == nil { + t.Fatal("expected first-run failure") + } + + // Second run: reuse the SAME server (uploadedParts retains parts 1-2), + // so ListParts reports them and only 3,4 should be uploaded. + fake.failAfterPart = 0 + fake.uploadOrder = nil + fake.uploadCalls = 0 + if err := resumableUpload(context.Background(), fake, optsFor(abs, size, mtime, minPartSize, 1)); err != nil { + t.Fatalf("resume run: %v", err) + } + if fake.createCalls != 1 { + t.Errorf("Create calls = %d, want 1 (resume must not re-create)", fake.createCalls) + } + if fake.uploadCalls != 2 { + t.Errorf("resume UploadPart calls = %d, want 2 (only missing 3,4)", fake.uploadCalls) + } + sort.Slice(fake.uploadOrder, func(i, j int) bool { return fake.uploadOrder[i] < fake.uploadOrder[j] }) + if len(fake.uploadOrder) != 2 || fake.uploadOrder[0] != 3 || fake.uploadOrder[1] != 4 { + t.Errorf("resumed parts = %v, want [3 4]", fake.uploadOrder) + } + if fake.completeCalls != 1 { + t.Errorf("Complete calls = %d, want 1", fake.completeCalls) + } + verifyCompletedParts(t, fake.completedSet, 4) + + id := uploadIdentity(abs, "b", "k") + if cp, _ := loadCheckpoint(id); cp != nil { + t.Errorf("checkpoint should be deleted after resumed success, got %+v", cp) + } +} + +func TestResumableUpload_FileChanged_AbortsAndRestarts(t *testing.T) { + withTempVerdaHome(t) + abs, size, mtime := writeTempFile(t, 2*minPartSize+10) // 3 parts + id := uploadIdentity(abs, "b", "k") + + // Seed a checkpoint from an OLD upload with mismatched size/mtime. + stale := &checkpoint{ + UploadID: "stale-upload", + Bucket: "b", + Key: "k", + AbsPath: abs, + FileSize: size + 999, // differs -> file changed + MTime: mtime.Add(-time.Hour), + PartSize: minPartSize, + CreatedAt: time.Now().UTC(), + Parts: []checkpointPart{{N: 1, ETag: "\"old\""}}, + } + if err := saveCheckpoint(id, stale); err != nil { + t.Fatalf("seed checkpoint: %v", err) + } + + fake := newFakeMPUploadAPI() + if err := resumableUpload(context.Background(), fake, optsFor(abs, size, mtime, minPartSize, 1)); err != nil { + t.Fatalf("resumableUpload: %v", err) + } + if fake.abortCalls != 1 { + t.Errorf("Abort calls = %d, want 1 (stale upload aborted)", fake.abortCalls) + } + if len(fake.abortUploadIDs) != 1 || fake.abortUploadIDs[0] != "stale-upload" { + t.Errorf("aborted upload IDs = %v, want [stale-upload]", fake.abortUploadIDs) + } + if fake.createCalls != 1 { + t.Errorf("Create calls = %d, want 1 (fresh after abort)", fake.createCalls) + } + if fake.completeCalls != 1 { + t.Errorf("Complete calls = %d, want 1", fake.completeCalls) + } + verifyCompletedParts(t, fake.completedSet, 3) +} + +func TestResumableUpload_NoResume_AbortsAndRestarts(t *testing.T) { + withTempVerdaHome(t) + abs, size, mtime := writeTempFile(t, minPartSize+10) // 2 parts + id := uploadIdentity(abs, "b", "k") + + // Valid checkpoint (size+mtime match) — only --no-resume forces fresh. + cp := &checkpoint{ + UploadID: "prev-upload", + Bucket: "b", + Key: "k", + AbsPath: abs, + FileSize: size, + MTime: mtime, + PartSize: minPartSize, + CreatedAt: time.Now().UTC(), + Parts: []checkpointPart{{N: 1, ETag: "\"e1\""}}, + } + if err := saveCheckpoint(id, cp); err != nil { + t.Fatalf("seed: %v", err) + } + + fake := newFakeMPUploadAPI() + o := optsFor(abs, size, mtime, minPartSize, 1) + o.NoResume = true + if err := resumableUpload(context.Background(), fake, o); err != nil { + t.Fatalf("resumableUpload: %v", err) + } + if fake.abortCalls != 1 || fake.abortUploadIDs[0] != "prev-upload" { + t.Errorf("Abort = %d (%v), want 1 [prev-upload]", fake.abortCalls, fake.abortUploadIDs) + } + if fake.listPartsCalls != 0 { + t.Errorf("ListParts calls = %d, want 0 (--no-resume skips reconcile)", fake.listPartsCalls) + } + if fake.createCalls != 1 || fake.completeCalls != 1 { + t.Errorf("Create/Complete = %d/%d, want 1/1", fake.createCalls, fake.completeCalls) + } +} + +// TestResumableUpload_PartSizeChanged_AbortsAndRestarts guards C-1: when the +// file is unchanged (size+mtime match) but --part-size differs from the stored +// checkpoint, resuming would assemble parts at mismatched boundaries into a +// corrupt object. The run MUST abort the old upload and start fresh instead. +func TestResumableUpload_PartSizeChanged_AbortsAndRestarts(t *testing.T) { + withTempVerdaHome(t) + abs, size, mtime := writeTempFile(t, 2*minPartSize+10) // 3 parts @ minPartSize + id := uploadIdentity(abs, "b", "k") + + // Valid checkpoint at the OLD part size, with a part already "uploaded". + stale := &checkpoint{ + UploadID: "old-upload", + Bucket: "b", + Key: "k", + AbsPath: abs, + FileSize: size, + MTime: mtime, + PartSize: minPartSize, + CreatedAt: time.Now().UTC(), + Parts: []checkpointPart{{N: 1, ETag: "\"old1\""}}, + } + if err := saveCheckpoint(id, stale); err != nil { + t.Fatalf("seed checkpoint: %v", err) + } + + // Resume with a DIFFERENT part size on the unchanged file. + fake := newFakeMPUploadAPI() + o := optsFor(abs, size, mtime, 2*minPartSize, 1) + if err := resumableUpload(context.Background(), fake, o); err != nil { + t.Fatalf("resumableUpload: %v", err) + } + if fake.abortCalls != 1 || fake.abortUploadIDs[0] != "old-upload" { + t.Errorf("Abort = %d (%v), want 1 [old-upload] (part-size change must abort the old upload)", fake.abortCalls, fake.abortUploadIDs) + } + if fake.listPartsCalls != 0 { + t.Errorf("ListParts calls = %d, want 0 (part-size mismatch must not reconcile/resume)", fake.listPartsCalls) + } + if fake.createCalls != 1 { + t.Errorf("Create calls = %d, want 1 (fresh upload at the new part size)", fake.createCalls) + } + // 2*minPartSize over a (2*minPartSize+10)-byte file => exactly 2 parts. + verifyCompletedParts(t, fake.completedSet, 2) +} + +func TestResumableUpload_ListPartsNoSuchUpload_GracefulFresh(t *testing.T) { + withTempVerdaHome(t) + abs, size, mtime := writeTempFile(t, minPartSize+10) // 2 parts + id := uploadIdentity(abs, "b", "k") + + cp := &checkpoint{ + UploadID: "expired-upload", + Bucket: "b", + Key: "k", + AbsPath: abs, + FileSize: size, + MTime: mtime, + PartSize: minPartSize, + CreatedAt: time.Now().UTC(), + Parts: []checkpointPart{{N: 1, ETag: "\"e1\""}}, + } + if err := saveCheckpoint(id, cp); err != nil { + t.Fatalf("seed: %v", err) + } + + fake := newFakeMPUploadAPI() + fake.listPartsErr = &fakeSmithyError{code: "NoSuchUpload", message: "no such upload"} + + if err := resumableUpload(context.Background(), fake, optsFor(abs, size, mtime, minPartSize, 1)); err != nil { + t.Fatalf("resumableUpload should gracefully restart on NoSuchUpload, got: %v", err) + } + if fake.listPartsCalls != 1 { + t.Errorf("ListParts calls = %d, want 1", fake.listPartsCalls) + } + if fake.createCalls != 1 { + t.Errorf("Create calls = %d, want 1 (fresh after NoSuchUpload)", fake.createCalls) + } + if fake.completeCalls != 1 { + t.Errorf("Complete calls = %d, want 1", fake.completeCalls) + } + verifyCompletedParts(t, fake.completedSet, 2) +} + +func TestResumableUpload_Concurrency_OrderedComplete(t *testing.T) { + withTempVerdaHome(t) + abs, size, mtime := writeTempFile(t, 7*minPartSize+5) // 8 parts + fake := newFakeMPUploadAPI() + + if err := resumableUpload(context.Background(), fake, optsFor(abs, size, mtime, minPartSize, 4)); err != nil { + t.Fatalf("resumableUpload: %v", err) + } + if fake.uploadCalls != 8 { + t.Errorf("UploadPart calls = %d, want 8", fake.uploadCalls) + } + // Despite concurrent uploads (possibly out of order), Complete's part set + // must be ascending and contain all 8. + verifyCompletedParts(t, fake.completedSet, 8) +} + +// TestResumableUpload_Resume_BeyondFirstPage seeds a server with more than one +// ListParts page worth of parts (>1000) and verifies the resumed run uploads +// ZERO parts. A non-paginated ListParts would only see parts 1..1000, mark +// 1001+ as missing, and re-upload them — wasting the entire resume value on the +// feature's primary multi-GB workload. The file is never read on disk because +// every part is already on the server, so a fabricated FileSize is safe here. +func TestResumableUpload_Resume_BeyondFirstPage(t *testing.T) { + withTempVerdaHome(t) + + const totalParts = 1001 + abs, _, mtime := writeTempFile(t, 1) // tiny: never opened (no missing parts) + fileSize := int64(totalParts) * minPartSize + id := uploadIdentity(abs, "b", "k") + + // Server already holds every part across multiple pages. + fake := newFakeMPUploadAPI() + fake.partsPageSize = 1000 + parts := make([]checkpointPart, 0, totalParts) + for n := int32(1); n <= totalParts; n++ { + etag := "\"etag-" + strconv.Itoa(int(n)) + "\"" + fake.uploadedParts[n] = etag + parts = append(parts, checkpointPart{N: n, ETag: etag}) + } + + // Local checkpoint matches size+mtime so resume (not fresh) is chosen. + cp := &checkpoint{ + UploadID: fake.createUploadID, + Bucket: "b", + Key: "k", + AbsPath: abs, + FileSize: fileSize, + MTime: mtime, + PartSize: minPartSize, + CreatedAt: time.Now().UTC(), + Parts: parts, + } + if err := saveCheckpoint(id, cp); err != nil { + t.Fatalf("seed checkpoint: %v", err) + } + + o := optsFor(abs, fileSize, mtime, minPartSize, 1) + if err := resumableUpload(context.Background(), fake, o); err != nil { + t.Fatalf("resumableUpload: %v", err) + } + if fake.uploadCalls != 0 { + t.Errorf("resume UploadPart calls = %d, want 0 (all %d parts already on server)", fake.uploadCalls, totalParts) + } + if fake.createCalls != 0 { + t.Errorf("Create calls = %d, want 0 (resume must not re-create)", fake.createCalls) + } + if fake.listPartsCalls < 2 { + t.Errorf("ListParts calls = %d, want >= 2 (must paginate past the 1000-part page)", fake.listPartsCalls) + } + if fake.completeCalls != 1 { + t.Errorf("Complete calls = %d, want 1", fake.completeCalls) + } + verifyCompletedParts(t, fake.completedSet, totalParts) + + if leftover, _ := loadCheckpoint(id); leftover != nil { + t.Errorf("checkpoint should be deleted after resumed success, got %+v", leftover) + } +} + +func TestComputePartSize(t *testing.T) { + t.Parallel() + cases := []struct { + name string + fileSize int64 + requested int64 + wantMin int64 + wantExact int64 // 0 = don't assert an exact size + }{ + {"below floor bumps to 5MiB", 100 * 1024 * 1024, 1024, minPartSize, 0}, + {"zero requests auto", 100 * 1024 * 1024, 0, minPartSize, 0}, + {"just over maxParts*floor scales up", maxParts*minPartSize + 1, minPartSize, minPartSize * 2, minPartSize * 2}, + // Exact multiple of maxParts*floor needs exactly maxParts parts, which + // is allowed — it must NOT be bumped (regression for the off-by-one). + {"exact maxParts multiple stays at floor", maxParts * minPartSize, minPartSize, minPartSize, minPartSize}, + } + for _, tc := range cases { + got := computePartSize(tc.fileSize, tc.requested) + if got < minPartSize { + t.Errorf("%s: part size %d below floor", tc.name, got) + } + // Correct ceil check: the file must split into at most maxParts parts. + if (tc.fileSize+got-1)/got > maxParts { + t.Errorf("%s: part size %d yields > maxParts parts", tc.name, got) + } + if got < tc.wantMin { + t.Errorf("%s: part size = %d, want >= %d", tc.name, got, tc.wantMin) + } + if tc.wantExact != 0 && got != tc.wantExact { + t.Errorf("%s: part size = %d, want exactly %d", tc.name, got, tc.wantExact) + } + } +} + +func TestPartRange(t *testing.T) { + t.Parallel() + const ps = minPartSize + fileSize := 2*ps + 123 + wantRanges := [][2]int64{ + {0, ps}, + {ps, 2 * ps}, + {2 * ps, fileSize}, + } + for i := range wantRanges { + n := int32(i + 1) //nolint:gosec // G115: test loop index + start, end := partRange(n, fileSize, ps) + if start != wantRanges[i][0] || end != wantRanges[i][1] { + t.Errorf("part %d range = [%d,%d), want [%d,%d)", n, start, end, wantRanges[i][0], wantRanges[i][1]) + } + } +} diff --git a/internal/verda-cli/cmd/s3/mv.go b/internal/verda-cli/cmd/s3/mv.go index 38d7e43..871a183 100644 --- a/internal/verda-cli/cmd/s3/mv.go +++ b/internal/verda-cli/cmd/s3/mv.go @@ -69,10 +69,27 @@ func NewCmdMv(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { # Preview a recursive move verda s3 mv s3://my-bucket/logs/ ./logs --recursive --dryrun + + # Move/rename an object interactively (S3 -> S3) + verda s3 mv `), - Args: cobra.ExactArgs(2), + // 2 args = direct move. On a TTY, fewer args (none, or a single s3:// URI) + // launch the S3->S3 move/rename wizard. Local moves still need both args. + Args: cobra.MaximumNArgs(2), RunE: func(cmd *cobra.Command, args []string) error { - return runMv(cmd, f, ioStreams, opts, args[0], args[1]) + if len(args) == 2 { + return runMv(cmd, f, ioStreams, opts, args[0], args[1]) + } + if f.AgentMode() { + return cmdutil.NewMissingFlagsError([]string{"", ""}) + } + if !interactiveTTY(f) { + return cmd.Help() + } + if len(args) == 1 && !IsS3URI(args[0]) { + return cmdutil.UsageErrorf(cmd, "mv requires a source and destination: verda s3 mv ") + } + return runMoveWizard(cmd, f, ioStreams, opts, args) }, } @@ -296,7 +313,7 @@ func downloadMoveOne(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.I sub := newCpPayload(false) quietStreams := cmdutil.IOStreams{In: ioStreams.In, Out: discardWriter{}, ErrOut: ioStreams.ErrOut} - if err := downloadOne(ctx, f, quietStreams, tr, src, localPath, rel, opts, &sub); err != nil { + if err := downloadOne(ctx, f, quietStreams, tr, client, src, localPath, rel, opts, &sub); err != nil { return err } diff --git a/internal/verda-cli/cmd/s3/mv_test.go b/internal/verda-cli/cmd/s3/mv_test.go index d1b5c06..228d48e 100644 --- a/internal/verda-cli/cmd/s3/mv_test.go +++ b/internal/verda-cli/cmd/s3/mv_test.go @@ -20,15 +20,198 @@ import ( "errors" "os" "path/filepath" + "sort" "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) +// keysFromDeletes / keysFromCopies / keysFromUploads collect+sort keys for +// order-independent assertions in the recursive mv tests. +func keysFromDeletes(ins []*s3.DeleteObjectInput) []string { + out := make([]string, 0, len(ins)) + for _, in := range ins { + out = append(out, aws.ToString(in.Key)) + } + sort.Strings(out) + return out +} + +func keysFromCopies(ins []*s3.CopyObjectInput) []string { + out := make([]string, 0, len(ins)) + for _, in := range ins { + out = append(out, aws.ToString(in.Key)) + } + sort.Strings(out) + return out +} + +func keysFromUploads(ins []*s3.PutObjectInput) []string { + out := make([]string, 0, len(ins)) + for _, in := range ins { + out = append(out, aws.ToString(in.Key)) + } + sort.Strings(out) + return out +} + +// TestMv_RecursiveUpload exercises uploadMoveTree: every file under the local +// dir is uploaded with its relative path preserved, then removed from disk. +func TestMv_RecursiveUpload(t *testing.T) { + // no t.Parallel + tmp := t.TempDir() + if err := os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("A"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(tmp, "sub"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(tmp, "sub", "c.txt"), []byte("C"), 0o600); err != nil { + t.Fatal(err) + } + + fakeT := &cpFakeTransporter{} + restoreT := withFakeTransporter(fakeT) + defer restoreT() + restore := withFakeClient(&mvFakeAPI{}) + defer restore() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdMv(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{tmp, "s3://my-bucket/prefix/", "--recursive"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + gotKeys := keysFromUploads(fakeT.uploads) + want := []string{"prefix/a.txt", "prefix/sub/c.txt"} + if len(gotKeys) != len(want) { + t.Fatalf("uploaded keys = %v, want %v", gotKeys, want) + } + for i := range want { + if gotKeys[i] != want[i] { + t.Errorf("uploaded keys[%d] = %q, want %q (all=%v)", i, gotKeys[i], want[i], gotKeys) + } + } + // Both local sources must be gone after a successful move. + for _, p := range []string{filepath.Join(tmp, "a.txt"), filepath.Join(tmp, "sub", "c.txt")} { + if _, err := os.Stat(p); !errors.Is(err, os.ErrNotExist) { + t.Errorf("source %s still present after recursive mv (err=%v)", p, err) + } + } +} + +// TestMv_RecursiveDownload exercises downloadMoveTree: every listed object is +// downloaded under the dest dir with its relative path preserved, then deleted +// from the bucket. +func TestMv_RecursiveDownload(t *testing.T) { + // no t.Parallel + tmp := t.TempDir() + fakeAPI := &mvFakeAPI{cpFakeAPI: cpFakeAPI{ + listObjectsPages: []*s3.ListObjectsV2Output{ + { + Contents: []s3types.Object{ + {Key: aws.String("data/a.txt"), Size: aws.Int64(1)}, + {Key: aws.String("data/sub/b.txt"), Size: aws.Int64(1)}, + }, + IsTruncated: aws.Bool(false), + }, + }, + }} + restore := withFakeClient(fakeAPI) + defer restore() + fakeT := &cpFakeTransporter{downloadWrite: []byte("X")} + restoreT := withFakeTransporter(fakeT) + defer restoreT() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdMv(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"s3://my-bucket/data/", tmp, "--recursive"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fakeT.downloads) != 2 { + t.Fatalf("Download calls = %d, want 2", len(fakeT.downloads)) + } + for _, p := range []string{filepath.Join(tmp, "a.txt"), filepath.Join(tmp, "sub", "b.txt")} { + if _, err := os.Stat(p); err != nil { + t.Errorf("expected downloaded file at %s: %v", p, err) + } + } + // Each source object is deleted after its download succeeds. + gotDel := keysFromDeletes(fakeAPI.deleteInputs) + want := []string{"data/a.txt", "data/sub/b.txt"} + if len(gotDel) != len(want) { + t.Fatalf("deleted source keys = %v, want %v", gotDel, want) + } + for i := range want { + if gotDel[i] != want[i] { + t.Errorf("deleted keys[%d] = %q, want %q (all=%v)", i, gotDel[i], want[i], gotDel) + } + } +} + +// TestMv_RecursiveS3ToS3 exercises s3MoveTree: copy each key to the dest prefix +// then delete the source key. +func TestMv_RecursiveS3ToS3(t *testing.T) { + // no t.Parallel + fakeAPI := &mvFakeAPI{cpFakeAPI: cpFakeAPI{ + listObjectsPages: []*s3.ListObjectsV2Output{ + { + Contents: []s3types.Object{ + {Key: aws.String("src/a.txt"), Size: aws.Int64(1)}, + {Key: aws.String("src/b.txt"), Size: aws.Int64(1)}, + }, + IsTruncated: aws.Bool(false), + }, + }, + }} + restore := withFakeClient(fakeAPI) + defer restore() + restoreT := withFakeTransporter(&cpFakeTransporter{}) + defer restoreT() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdMv(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"s3://src-bucket/src/", "s3://dst-bucket/dst/", "--recursive"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + gotCopy := keysFromCopies(fakeAPI.copyInputs) + wantCopy := []string{"dst/a.txt", "dst/b.txt"} + if len(gotCopy) != len(wantCopy) { + t.Fatalf("copied keys = %v, want %v", gotCopy, wantCopy) + } + for i := range wantCopy { + if gotCopy[i] != wantCopy[i] { + t.Errorf("copied keys[%d] = %q, want %q (all=%v)", i, gotCopy[i], wantCopy[i], gotCopy) + } + } + gotDel := keysFromDeletes(fakeAPI.deleteInputs) + wantDel := []string{"src/a.txt", "src/b.txt"} + if len(gotDel) != len(wantDel) { + t.Fatalf("deleted source keys = %v, want %v", gotDel, wantDel) + } + for i := range wantDel { + if gotDel[i] != wantDel[i] { + t.Errorf("deleted keys[%d] = %q, want %q (all=%v)", i, gotDel[i], wantDel[i], gotDel) + } + } +} + // mvFakeAPI extends cpFakeAPI with DeleteObject recording so mv tests can // assert the post-transfer source cleanup. type mvFakeAPI struct { diff --git a/internal/verda-cli/cmd/s3/picker.go b/internal/verda-cli/cmd/s3/picker.go new file mode 100644 index 0000000..e69a548 --- /dev/null +++ b/internal/verda-cli/cmd/s3/picker.go @@ -0,0 +1,219 @@ +// 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 s3 + +import ( + "context" + "fmt" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// objectPickerCap bounds the flat object picker so a huge bucket can't produce +// an unusable Select. The user is told when the list is truncated. +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) { + return client.ListBuckets(ctx, &s3.ListBucketsInput{}) + }) + if err != nil { + return "", translateError(err) + } + if len(out.Buckets) == 0 { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "No buckets found.") + return "", nil + } + + labels := make([]string, len(out.Buckets)) + 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)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", nil + } + return "", err + } + return aws.ToString(out.Buckets[idx].Name), nil +} + +// resolveBucketArg returns the s3:// argument a bucket-targeting command should +// act on, implementing the dual-mode contract: +// - explicit positional arg -> returned unchanged (param mode) +// - omitted, --agent -> structured MISSING_REQUIRED_FLAGS error +// - omitted, non-TTY/piped -> command help (no silent prompt in scripts) +// - omitted, interactive TTY -> bucket picker, returns "s3://" +// +// A clean cancel (Ctrl+C/Esc, or no buckets) returns ("", nil); callers should +// treat an empty string with a nil error as "exit cleanly, nothing to do". +func resolveBucketArg(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, args []string) (string, error) { + if len(args) > 0 { + return args[0], nil + } + if f.AgentMode() { + return "", cmdutil.NewMissingFlagsError([]string{"s3://bucket"}) + } + // interactiveTTY also guards OutputFormat: `-o json` on a TTY must not launch + // the picker, or a scripted caller gets an interactive session. + if !interactiveTTY(f) { + return "", cmd.Help() + } + + ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + client, err := buildClient(ctx, f, ClientOverrides{}) + if err != nil { + return "", err + } + bucket, err := selectBucket(ctx, f, ioStreams, client) + if err != nil { + return "", err + } + if bucket == "" { + return "", nil + } + return "s3://" + bucket, nil +} + +// resolveNewBucketArg returns the s3:// argument for a bucket-CREATING command. +// Like resolveBucketArg, but prompts for a NEW name (TextInput) rather than +// listing existing buckets. A clean cancel / empty input returns ("", nil) so +// callers treat it as "exit cleanly, nothing to do". +func resolveNewBucketArg(cmd *cobra.Command, f cmdutil.Factory, args []string) (string, error) { + if len(args) > 0 { + return args[0], nil + } + if f.AgentMode() { + return "", cmdutil.NewMissingFlagsError([]string{"s3://bucket"}) + } + if !interactiveTTY(f) { + return "", cmd.Help() + } + name, err := promptNewBucketName(cmd.Context(), f) + if err != nil || name == "" { + return "", err + } + return "s3://" + name, nil +} + +// promptNewBucketName asks for a bucket name and returns it trimmed. A clean +// cancel or empty input returns ("", nil). +func promptNewBucketName(ctx context.Context, f cmdutil.Factory) (string, error) { + name, err := f.Prompter().TextInput(ctx, "New bucket name") + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", nil + } + return "", err + } + return strings.TrimSpace(name), nil +} + +// selectObjectKey lists object keys in bucket (paginated, capped at +// objectPickerCap) and prompts for one. Returns ("", nil) on a clean cancel or +// an empty bucket. +func selectObjectKey(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, bucket string) (string, error) { + res, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading objects...", func() (cappedKeys, error) { + k, truncated, e := listKeysCapped(ctx, client, bucket, objectPickerCap) + return cappedKeys{keys: k, truncated: truncated}, e + }) + if err != nil { + return "", err + } + if len(res.keys) == 0 { + _, _ = fmt.Fprintf(ioStreams.ErrOut, "No objects in s3://%s.\n", bucket) + return "", nil + } + if res.truncated { + _, _ = fmt.Fprintf(ioStreams.ErrOut, "Showing the first %d objects of s3://%s; pass an explicit key for the rest.\n", objectPickerCap, bucket) + } + // Raw error on cancel so a wizard caller can tell Esc (go back) from Ctrl+C + // (exit). A non-wizard caller can still use cmdutil.IsPromptCancel. + idx, err := f.Prompter().Select(ctx, "Select object in s3://"+bucket, res.keys, tui.WithShowHints(true)) + if err != nil { + return "", err + } + return res.keys[idx], nil +} + +// pickSourceBucket lists existing buckets and returns the chosen name, surfacing +// the raw prompter error on cancel (so a wizard can distinguish Esc from Ctrl+C). +// Returns ("", nil) when there are no buckets. +func pickSourceBucket(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) { + return client.ListBuckets(ctx, &s3.ListBucketsInput{}) + }) + if err != nil { + return "", translateError(err) + } + if len(out.Buckets) == 0 { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "No buckets found.") + return "", nil + } + labels := make([]string, len(out.Buckets)) + for i := range out.Buckets { + labels[i] = aws.ToString(out.Buckets[i].Name) + } + idx, err := f.Prompter().Select(ctx, "Select source bucket", labels, tui.WithShowHints(true)) + if err != nil { + return "", err + } + return aws.ToString(out.Buckets[idx].Name), nil +} + +type cappedKeys struct { + keys []string + truncated bool +} + +// listKeysCapped flat-lists up to limit object keys under bucket, returning +// truncated=true if the cap was hit before the listing finished. +func listKeysCapped(ctx context.Context, client API, bucket string, limit int) (keys []string, truncated bool, err error) { + var token *string + for { + in := &s3.ListObjectsV2Input{Bucket: aws.String(bucket)} + if token != nil { + in.ContinuationToken = token + } + out, err := client.ListObjectsV2(ctx, in) + if err != nil { + return nil, false, translateError(err) + } + for i := range out.Contents { + keys = append(keys, aws.ToString(out.Contents[i].Key)) + if len(keys) >= limit { + // Only truncated if more keys exist beyond this one (rest of this + // page, or another page) — a bucket of exactly `limit` is complete. + more := i < len(out.Contents)-1 || aws.ToBool(out.IsTruncated) + return keys, more, nil + } + } + if !aws.ToBool(out.IsTruncated) || out.NextContinuationToken == nil || *out.NextContinuationToken == "" { + return keys, false, nil + } + token = out.NextContinuationToken + } +} diff --git a/internal/verda-cli/cmd/s3/picker_test.go b/internal/verda-cli/cmd/s3/picker_test.go new file mode 100644 index 0000000..1e4fce0 --- /dev/null +++ b/internal/verda-cli/cmd/s3/picker_test.go @@ -0,0 +1,84 @@ +// 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 s3 + +import ( + "bytes" + "context" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/spf13/cobra" + tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +func TestSelectBucket_PicksChosen(t *testing.T) { + // no t.Parallel — clientBuilder/prompter state + fake := &fakeS3API{buckets: []s3types.Bucket{ + {Name: aws.String("alpha")}, + {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) + if err != nil { + t.Fatalf("selectBucket: %v", err) + } + if got != "beta" { + t.Errorf("chosen bucket = %q, want beta", got) + } +} + +func TestSelectBucket_EmptyReturnsBlank(t *testing.T) { + // no t.Parallel + fake := &fakeS3API{} + f := cmdutil.NewTestFactory(tuitest.New()) + errOut := &bytes.Buffer{} + got, err := selectBucket(context.Background(), f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: errOut}, fake) + if err != nil { + t.Fatalf("selectBucket: %v", err) + } + if got != "" { + t.Errorf("got %q, want empty (no buckets)", got) + } +} + +func TestResolveBucketArg_ExplicitPassthrough(t *testing.T) { + t.Parallel() + f := cmdutil.NewTestFactory(nil) + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + got, err := resolveBucketArg(cmd, f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, []string{"s3://my-bucket/key"}) + if err != nil { + t.Fatalf("resolveBucketArg: %v", err) + } + if got != "s3://my-bucket/key" { + t.Errorf("got %q, want passthrough of explicit arg", got) + } +} + +func TestResolveBucketArg_AgentModeMissing(t *testing.T) { + t.Parallel() + f := cmdutil.NewTestFactory(nil) + f.AgentModeOverride = true + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + _, err := resolveBucketArg(cmd, f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, nil) + if err == nil { + t.Fatal("expected a missing-arg error in agent mode with no bucket") + } +} diff --git a/internal/verda-cli/cmd/s3/rb.go b/internal/verda-cli/cmd/s3/rb.go index c6112f9..57c92c9 100644 --- a/internal/verda-cli/cmd/s3/rb.go +++ b/internal/verda-cli/cmd/s3/rb.go @@ -66,9 +66,15 @@ func NewCmdRb(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { # Skip confirmation prompt verda s3 rb s3://my-bucket --yes `), - Args: cobra.ExactArgs(1), + // 0 args on a TTY launches the bucket picker; an explicit s3://bucket + // runs directly. --agent/non-TTY with no arg errors or shows help. + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runRb(cmd, f, ioStreams, opts, args[0]) + arg, err := resolveBucketArg(cmd, f, ioStreams, args) + if err != nil || arg == "" { + return err + } + return runRb(cmd, f, ioStreams, opts, arg) }, } @@ -100,17 +106,11 @@ func runRb(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o // Interactive confirmation (TTY path). if !opts.Yes && !f.AgentMode() { - warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) - _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n", - warnStyle.Render(fmt.Sprintf("This will permanently delete bucket %q", uri.Bucket))) - if opts.Force { - _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n", - warnStyle.Render("...and ALL objects it contains")) + proceed, cerr := confirmRbDeletion(ctx, f, ioStreams, uri.Bucket, opts.Force) + if cerr != nil { + return cerr } - _, _ = fmt.Fprintln(ioStreams.ErrOut) - - confirmed, confirmErr := f.Prompter().Confirm(ctx, fmt.Sprintf("Delete bucket %q?", uri.Bucket)) - if confirmErr != nil || !confirmed { + if !proceed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } @@ -150,6 +150,29 @@ func runRb(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o return nil } +// confirmRbDeletion renders the red destructive warning and asks the user to +// confirm. proceed is false on a "no" answer; a clean cancel (Esc/Ctrl+C) +// returns (false, nil); a real prompter failure propagates. +func confirmRbDeletion(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, bucket string, force bool) (proceed bool, err error) { + warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) + _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s\n", + warnStyle.Render(fmt.Sprintf("This will permanently delete bucket %q", bucket))) + if force { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n", + warnStyle.Render("...and ALL objects it contains")) + } + _, _ = fmt.Fprintln(ioStreams.ErrOut) + + confirmed, cerr := f.Prompter().Confirm(ctx, fmt.Sprintf("Delete bucket %q?", bucket)) + if cerr != nil { + if cmdutil.IsPromptCancel(cerr) { + return false, nil + } + return false, cerr + } + return confirmed, nil +} + // emptyBucket paginates through all objects in a bucket and deletes them in // batches of up to maxDeleteBatch. Returns the total number of objects deleted. func emptyBucket(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, bucket string) (int, error) { @@ -176,6 +199,13 @@ func emptyBucket(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStr } cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), fmt.Sprintf("DeleteObjects response: batch of %d", len(batch)), out) + // Quiet=true suppresses the success list but still reports per-object + // failures; fail fast so the deleted count stays accurate and the user + // learns why (the subsequent DeleteBucket would otherwise fail opaquely). + if len(out.Errors) > 0 { + return fmt.Errorf("could not delete %s: %s", + aws.ToString(out.Errors[0].Key), aws.ToString(out.Errors[0].Message)) + } _, _ = fmt.Fprintf(ioStreams.ErrOut, "Emptied batch of %d objects\n", len(batch)) deleted += len(batch) batch = batch[:0] diff --git a/internal/verda-cli/cmd/s3/resume_uploads.go b/internal/verda-cli/cmd/s3/resume_uploads.go new file mode 100644 index 0000000..34b0dbc --- /dev/null +++ b/internal/verda-cli/cmd/s3/resume_uploads.go @@ -0,0 +1,210 @@ +// 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 s3 + +import ( + "context" + "fmt" + "os" + "path" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/verda-cloud/verdagostack/pkg/tui" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// inferPartSize recovers the original part size from the server's existing +// parts: every full part is the same size, so the largest is the part size. +// Returns 0 when no parts are present (caller falls back to the auto size). +func inferPartSize(parts []s3types.Part) int64 { + var largest int64 + for i := range parts { + largest = max(largest, aws.ToInt64(parts[i].Size)) + } + return largest +} + +// findCheckpointByUploadID scans the local checkpoint store for one whose +// UploadID matches. Returns (nil, nil) when none is found (e.g. the upload was +// started on another machine or the checkpoint was pruned). +func findCheckpointByUploadID(uploadID string) (*checkpoint, error) { + dir, err := checkpointDir() + if err != nil { + return nil, err + } + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + for i := range entries { + name := entries[i].Name() + if entries[i].IsDir() || !strings.HasSuffix(name, ".json") { + continue + } + cp, lerr := loadCheckpoint(strings.TrimSuffix(name, ".json")) + if lerr != nil || cp == nil { + continue + } + if cp.UploadID == uploadID { + return cp, nil + } + } + return nil, nil +} + +// promptResumeFromUploads lets the user pick an in-progress upload and resume +// it. Each row is annotated with whether a local checkpoint was found; when it +// wasn't, the user is asked for the source file. ctx must be unbounded (the +// resume runs a full upload), so callers pass cmd.Context(), not a timeout ctx. +func promptResumeFromUploads(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, bucket string, uploads []uploadEntry) error { + checkpoints := make([]*checkpoint, len(uploads)) + labels := make([]string, 0, len(uploads)+1) + for i := range uploads { + cp, _ := findCheckpointByUploadID(uploads[i].UploadID) + checkpoints[i] = cp + mark := "(no local file — will prompt)" + if cp != nil { + mark = "✓ resumable" + } + labels = append(labels, fmt.Sprintf("%-44s %9s %s", uploads[i].Key, humanBytes(uploads[i].Size), mark)) + } + labels = append(labels, "Exit") + + idx, err := f.Prompter().Select(ctx, "Select an upload to resume", labels, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil + } + return err + } + if idx == len(uploads) { // Exit + return nil + } + + sel := uploads[idx] + absPath := "" + if cp := checkpoints[idx]; cp != nil { + if _, statErr := os.Stat(cp.AbsPath); statErr == nil { + absPath = cp.AbsPath + } + } + if absPath == "" { + absPath, err = promptResumeSource(ctx, f, ioStreams, sel.Key) + if err != nil || absPath == "" { + return err + } + } + return resumeServerUpload(ctx, f, ioStreams, client, bucket, sel.Key, sel.UploadID, absPath) +} + +// promptResumeSource asks for the local file backing an upload with no +// checkpoint, re-prompting until it exists. ("", nil) on cancel/empty. +func promptResumeSource(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, key string) (string, error) { + for { + p, err := f.Prompter().TextInput(ctx, "Local file for "+key) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return "", nil + } + return "", err + } + p = strings.TrimSpace(p) + if p == "" { + return "", nil + } + if _, statErr := os.Stat(p); statErr != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %v — try again.\n", statErr) + continue + } + return p, nil + } +} + +// 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). +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 { + return fmt.Errorf("source %q: %w", absPath, err) + } + if info.IsDir() { + return fmt.Errorf("source %q is a directory; resume expects the original file", absPath) + } + + parts, err := listAllParts(ctx, client, bucket, key, uploadID) + if err != nil { + if isNoSuchUpload(err) { + return fmt.Errorf("upload %s no longer exists on the server (expired or aborted)", uploadID) + } + return translateError(err) + } + + partSize := inferPartSize(parts) + if partSize > 0 { + var maxN int32 + for i := range parts { + maxN = max(maxN, aws.ToInt32(parts[i].PartNumber)) + } + 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())) + } + } + + storedPartSize := partSize + if storedPartSize == 0 { + storedPartSize = computePartSize(info.Size(), 0) + } + cpParts := make([]checkpointPart, 0, len(parts)) + for i := range parts { + cpParts = append(cpParts, checkpointPart{N: aws.ToInt32(parts[i].PartNumber), ETag: aws.ToString(parts[i].ETag)}) + } + identity := uploadIdentity(absPath, bucket, key) + if err := saveCheckpoint(identity, &checkpoint{ + UploadID: uploadID, + Bucket: bucket, + Key: key, + AbsPath: absPath, + FileSize: info.Size(), + MTime: info.ModTime(), + PartSize: storedPartSize, + CreatedAt: time.Now().UTC(), + Parts: cpParts, + }); err != nil { + return err + } + + ropts := &resumableOptions{ + AbsPath: absPath, + Bucket: bucket, + Key: key, + ContentType: inferContentType(absPath, ""), + FileSize: info.Size(), + MTime: info.ModTime(), + PartSize: partSize, // 0 -> uploader auto-sizes (only when no parts exist yet) + Concurrency: defaultConcurrency, + } + return runResumable(ctx, f, ioStreams, client, ropts, path.Base(key)) +} diff --git a/internal/verda-cli/cmd/s3/resume_uploads_test.go b/internal/verda-cli/cmd/s3/resume_uploads_test.go new file mode 100644 index 0000000..cd0ae62 --- /dev/null +++ b/internal/verda-cli/cmd/s3/resume_uploads_test.go @@ -0,0 +1,131 @@ +// 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. + +//go:build !windows + +package s3 + +import ( + "bytes" + "context" + "sort" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +func TestInferPartSize(t *testing.T) { + t.Parallel() + if got := inferPartSize(nil); got != 0 { + t.Errorf("empty = %d, want 0", got) + } + parts := []s3types.Part{ + {Size: aws.Int64(minPartSize)}, + {Size: aws.Int64(minPartSize)}, + {Size: aws.Int64(123)}, // short final part + } + if got := inferPartSize(parts); got != minPartSize { + t.Errorf("inferPartSize = %d, want %d (largest)", got, minPartSize) + } +} + +func TestFindCheckpointByUploadID(t *testing.T) { + withTempVerdaHome(t) + cp1 := &checkpoint{UploadID: "u1", Bucket: "b", Key: "k1", AbsPath: "/tmp/a", MTime: time.Now().UTC()} + cp2 := &checkpoint{UploadID: "u2", Bucket: "b", Key: "k2", AbsPath: "/tmp/b", MTime: time.Now().UTC()} + if err := saveCheckpoint(uploadIdentity("/tmp/a", "b", "k1"), cp1); err != nil { + t.Fatal(err) + } + if err := saveCheckpoint(uploadIdentity("/tmp/b", "b", "k2"), cp2); err != nil { + t.Fatal(err) + } + + got, err := findCheckpointByUploadID("u2") + if err != nil { + t.Fatalf("find: %v", err) + } + if got == nil || got.AbsPath != "/tmp/b" { + t.Errorf("got %+v, want the u2 checkpoint (/tmp/b)", got) + } + if miss, _ := findCheckpointByUploadID("nope"); miss != nil { + t.Errorf("unexpected match for unknown upload id: %+v", miss) + } +} + +// resumeFakeAPI serves a fixed set of pre-existing parts and records uploads / +// completion. CreateMultipartUpload must NOT be called (resume adopts the id). +type resumeFakeAPI struct { + API + existing []s3types.Part + createCalls int + uploaded []int32 + completed []s3types.CompletedPart +} + +func (r *resumeFakeAPI) CreateMultipartUpload(ctx context.Context, in *s3.CreateMultipartUploadInput, opts ...func(*s3.Options)) (*s3.CreateMultipartUploadOutput, error) { + r.createCalls++ + return &s3.CreateMultipartUploadOutput{UploadId: aws.String("should-not-happen")}, nil +} + +func (r *resumeFakeAPI) ListParts(ctx context.Context, in *s3.ListPartsInput, opts ...func(*s3.Options)) (*s3.ListPartsOutput, error) { + return &s3.ListPartsOutput{Parts: r.existing, IsTruncated: aws.Bool(false)}, nil +} + +func (r *resumeFakeAPI) UploadPart(ctx context.Context, in *s3.UploadPartInput, opts ...func(*s3.Options)) (*s3.UploadPartOutput, error) { + n := aws.ToInt32(in.PartNumber) + r.uploaded = append(r.uploaded, n) + 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.completed = in.MultipartUpload.Parts + } + return &s3.CompleteMultipartUploadOutput{}, nil +} + +// TestResumeServerUpload resumes a 4-part object that already has parts 1-2 on +// the server: it must adopt the UploadId (no Create), upload only 3-4, and +// complete with the full ordered set. +func TestResumeServerUpload(t *testing.T) { + withTempVerdaHome(t) + abs, _, _ := writeTempFile(t, 3*minPartSize+100) // 4 parts at 5 MiB + + fake := &resumeFakeAPI{existing: []s3types.Part{ + {PartNumber: aws.Int32(1), Size: aws.Int64(minPartSize), ETag: aws.String("\"e1\"")}, + {PartNumber: aws.Int32(2), Size: aws.Int64(minPartSize), 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) + } + sort.Slice(fake.uploaded, func(i, j int) bool { return fake.uploaded[i] < fake.uploaded[j] }) + if len(fake.uploaded) != 2 || fake.uploaded[0] != 3 || fake.uploaded[1] != 4 { + t.Errorf("uploaded parts = %v, want [3 4] (only the missing ones)", fake.uploaded) + } + if len(fake.completed) != 4 { + t.Errorf("completed with %d parts, want 4", len(fake.completed)) + } +} diff --git a/internal/verda-cli/cmd/s3/rm.go b/internal/verda-cli/cmd/s3/rm.go index 83d339b..cc88d21 100644 --- a/internal/verda-cli/cmd/s3/rm.go +++ b/internal/verda-cli/cmd/s3/rm.go @@ -93,10 +93,34 @@ func NewCmdRm(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { # Skip confirmation verda s3 rm s3://my-bucket/obj --yes + + # Browse and tick files to delete interactively + verda s3 rm `), - Args: cobra.ExactArgs(1), + // 0 args on a TTY launches the folder browser (drill in, tick files to + // delete); an explicit URI runs directly. --agent/non-TTY needs the URI. + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - return runRm(cmd, f, ioStreams, opts, args[0]) + if len(args) == 1 { + return runRm(cmd, f, ioStreams, opts, args[0]) + } + if f.AgentMode() { + return cmdutil.NewMissingFlagsError([]string{"s3://bucket/key"}) + } + if !interactiveTTY(f) { + return cmd.Help() + } + // The browser ignores rm's flags; refuse rather than silently drop them + // (a user expecting --dryrun to preview would otherwise get real deletes). + if opts.Recursive || opts.Dryrun || opts.Yes || len(opts.Include) > 0 || len(opts.Exclude) > 0 { + return cmdutil.UsageErrorf(cmd, "--recursive/--dryrun/--yes/--include/--exclude require an explicit s3://bucket/key; run 'verda s3 rm' with no flags to browse interactively") + } + ctx := cmd.Context() + client, err := buildClient(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + return runRmBrowser(ctx, f, ioStreams, client) }, } @@ -141,7 +165,14 @@ func runRm(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o // Interactive confirmation (TTY path). if !opts.Yes && !f.AgentMode() { confirmed, confirmErr := confirmRm(ctx, f, ioStreams, uri, targets, opts.Recursive) - 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 } diff --git a/internal/verda-cli/cmd/s3/rm_browse.go b/internal/verda-cli/cmd/s3/rm_browse.go new file mode 100644 index 0000000..9385158 --- /dev/null +++ b/internal/verda-cli/cmd/s3/rm_browse.go @@ -0,0 +1,193 @@ +// 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 s3 + +import ( + "context" + "fmt" + + "github.com/verda-cloud/verdagostack/pkg/tui" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// runRmBrowser is the interactive deleter launched by `verda s3 rm` with no +// argument on a terminal. It shares the download browser's folder navigation +// (buckets -> prefixes -> objects, one delimiter level at a time) but each level +// offers a multi-select delete instead of download. Esc ascends a level; Ctrl+C +// exits. Deletes still go through the confirm + executeRm path, so the red +// warning + preview are identical to the flag-driven `rm`. +func runRmBrowser(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API) error { + cur := URI{} // empty Bucket == the bucket list + for { + if cur.Bucket == "" { + next, exit, err := browseBuckets(ctx, f, ioStreams, client) + if err != nil || exit { + return err + } + if next != "" { + cur = URI{Bucket: next} + } + continue + } + + again, err := rmBrowseLevel(ctx, f, ioStreams, client, &cur) + if err != nil { + return err + } + if !again { + return nil + } + } +} + +// rmBrowseLevel lists one delimiter level under cur and handles the selection. +// 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) { + return collectObjects(ctx, f, ioStreams, client, *cur, "/") + }) + if err != nil { + return false, err + } + + rows := buildRmRows(*cur, payload) + labels := make([]string, len(rows)) + for i := range rows { + labels[i] = rows[i].label + } + + idx, err := f.Prompter().Select(ctx, browseBreadcrumb(*cur), labels, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptInterrupt(err) { + return false, nil // Ctrl+C exits the browser + } + if cmdutil.IsPromptBack(err) { + ascend(cur) // Esc goes up one level + return true, nil + } + return false, err + } + + switch row := rows[idx]; row.kind { + case rowUp: + ascend(cur) + case rowExit: + return false, nil + case rowFolder: + cur.Key = row.value + case rowDeleteMulti: + if err := rmBrowseDeleteMulti(ctx, f, ioStreams, client, *cur, payload); err != nil { + return false, err + } + case rowObject: + if err := rmBrowseDeleteOne(ctx, f, ioStreams, client, URI{Bucket: cur.Bucket, Key: row.value}); err != nil { + return false, err + } + } + return true, nil +} + +// buildRmRows orders the rows: up, [delete-multi], folders, objects, exit. +func buildRmRows(cur URI, payload objectsPayload) []browseRow { + objRows := make([]browseRow, 0, len(payload.Objects)) + for i := range payload.Objects { + o := &payload.Objects[i] + name := relName(cur.Key, o.Key) + if name == "" { + continue // the prefix placeholder object, if any + } + objRows = append(objRows, browseRow{ + kind: rowObject, + label: fmt.Sprintf("🗑 %-40s %10s %s", name, humanBytes(o.Size), o.Modified.UTC().Format(timestampLayout)), + value: o.Key, + size: o.Size, + }) + } + + rows := make([]browseRow, 0, len(payload.CommonPrefixes)+len(objRows)+3) + rows = append(rows, browseRow{kind: rowUp, label: "↑ .."}) + if len(objRows) > 0 { + rows = append(rows, browseRow{kind: rowDeleteMulti, label: "🗑 Delete files here…"}) + } + for _, p := range payload.CommonPrefixes { + rows = append(rows, browseRow{kind: rowFolder, label: "📁 " + relName(cur.Key, p), value: p}) + } + rows = append(rows, objRows...) + rows = append(rows, browseRow{kind: rowExit, label: "Exit"}) + return rows +} + +// rmBrowseDeleteMulti multi-selects objects at the current level and deletes the +// ticked set through the shared confirm + executeRm path. Objects only (folders +// are drilled into, not bulk-deleted here). +func rmBrowseDeleteMulti(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, cur URI, payload objectsPayload) error { + keys := make([]string, 0, len(payload.Objects)) + labels := make([]string, 0, len(payload.Objects)) + for i := range payload.Objects { + name := relName(cur.Key, payload.Objects[i].Key) + if name == "" { + continue + } + keys = append(keys, payload.Objects[i].Key) + labels = append(labels, fmt.Sprintf("%s (%s)", name, humanBytes(payload.Objects[i].Size))) + } + if len(keys) == 0 { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "No files to delete at this level.") + return nil + } + + idxs, err := f.Prompter().MultiSelect(ctx, "Select files to delete (space to toggle)", labels, tui.WithMultiSelectShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil + } + return err + } + if len(idxs) == 0 { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Nothing selected.") + return nil + } + + targets := make([]string, 0, len(idxs)) + for _, ix := range idxs { + targets = append(targets, keys[ix]) + } + return confirmAndDelete(ctx, f, ioStreams, client, URI{Bucket: cur.Bucket, Key: cur.Key}, targets, true) +} + +// rmBrowseDeleteOne deletes a single chosen object via the shared confirm path. +func rmBrowseDeleteOne(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, obj URI) error { + return confirmAndDelete(ctx, f, ioStreams, client, obj, []string{obj.Key}, false) +} + +// confirmAndDelete runs the red-warning confirm and, on approval, the delete. +// A clean cancel (Esc/Ctrl+C/no) returns nil so the browser stays open. +func confirmAndDelete(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, uri URI, targets []string, recursive bool) error { + confirmed, cerr := confirmRm(ctx, f, ioStreams, uri, targets, recursive) + if cerr != nil { + if cmdutil.IsPromptCancel(cerr) { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return cerr + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return executeRm(ctx, f, ioStreams, client, uri, targets, recursive) +} diff --git a/internal/verda-cli/cmd/s3/rm_test.go b/internal/verda-cli/cmd/s3/rm_test.go index a56dc4a..8bf06a5 100644 --- a/internal/verda-cli/cmd/s3/rm_test.go +++ b/internal/verda-cli/cmd/s3/rm_test.go @@ -25,6 +25,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) @@ -294,6 +295,104 @@ func TestRm_MissingKeyWithoutRecursive(t *testing.T) { } } +// TestRm_InteractiveConfirm_Yes drives the TTY confirmation path (no --yes, not +// agent mode): the prompter approves, so the object is deleted. +func TestRm_InteractiveConfirm_Yes(t *testing.T) { + // no t.Parallel — clientBuilder mutation + fake := &rmFakeAPI{} + restore := withFakeClient(fake) + defer restore() + + mock := tuitest.New().AddConfirm(true) + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + f := cmdutil.NewTestFactory(mock) + cmd := NewCmdRm(f, cmdutil.IOStreams{Out: out, ErrOut: errOut}) + cmd.SetArgs([]string{"s3://my-bucket/path/obj.txt"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if fake.deleteObjectCalls != 1 { + t.Fatalf("DeleteObject calls = %d, want 1 (confirmed)", fake.deleteObjectCalls) + } + if !strings.Contains(errOut.String(), "permanently delete") { + t.Errorf("expected the destructive warning on stderr:\n%s", errOut.String()) + } +} + +// TestRm_InteractiveConfirm_No verifies that declining the prompt aborts with no +// delete and a clean (nil) exit. +func TestRm_InteractiveConfirm_No(t *testing.T) { + // no t.Parallel + fake := &rmFakeAPI{} + restore := withFakeClient(fake) + defer restore() + + mock := tuitest.New().AddConfirm(false) + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + f := cmdutil.NewTestFactory(mock) + cmd := NewCmdRm(f, cmdutil.IOStreams{Out: out, ErrOut: errOut}) + cmd.SetArgs([]string{"s3://my-bucket/path/obj.txt"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute should not error on a declined confirmation: %v", err) + } + if fake.deleteObjectCalls != 0 { + t.Errorf("DeleteObject should not be called when the user declines, got %d", fake.deleteObjectCalls) + } + if !strings.Contains(errOut.String(), "Canceled") { + t.Errorf("expected 'Canceled.' on stderr after declining:\n%s", errOut.String()) + } +} + +// TestRm_InteractiveConfirm_Recursive_Yes drives the recursive branch of +// confirmRm: the prefix preview is printed and, on approval, every matching +// object is batch-deleted. +func TestRm_InteractiveConfirm_Recursive_Yes(t *testing.T) { + // no t.Parallel + fake := &rmFakeAPI{ + listObjectsPages: []*s3.ListObjectsV2Output{ + { + Contents: []s3types.Object{ + {Key: aws.String("logs/a.txt")}, + {Key: aws.String("logs/b.txt")}, + }, + IsTruncated: aws.Bool(false), + }, + }, + } + restore := withFakeClient(fake) + defer restore() + + mock := tuitest.New().AddConfirm(true) + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + f := cmdutil.NewTestFactory(mock) + cmd := NewCmdRm(f, cmdutil.IOStreams{Out: out, ErrOut: errOut}) + cmd.SetArgs([]string{"s3://my-bucket/logs/", "--recursive"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if fake.deleteObjectsCalls != 1 { + t.Fatalf("DeleteObjects calls = %d, want 1 (confirmed recursive)", fake.deleteObjectsCalls) + } + keys := batchKeys(fake.deleteObjectsIns[0]) + want := []string{"logs/a.txt", "logs/b.txt"} + if len(keys) != len(want) { + t.Fatalf("deleted keys = %v, want %v", keys, want) + } + // The recursive preview lists the count + at least one key on stderr. + if !strings.Contains(errOut.String(), "permanently delete 2 object(s)") { + t.Errorf("expected recursive preview header on stderr:\n%s", errOut.String()) + } +} + func TestRm_InvalidURI(t *testing.T) { // no t.Parallel restore := withFakeClient(&rmFakeAPI{}) diff --git a/internal/verda-cli/cmd/s3/s3.go b/internal/verda-cli/cmd/s3/s3.go index 3fe6c47..ff86dd2 100644 --- a/internal/verda-cli/cmd/s3/s3.go +++ b/internal/verda-cli/cmd/s3/s3.go @@ -25,11 +25,6 @@ func NewCmdS3(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { cmd := &cobra.Command{ Use: "s3", Short: "Manage S3 object storage", - // Pre-release: hide from `verda --help`. Removal is a one-line change - // when S3 ships GA. The env-var gate in cmd/cmd.go decides whether - // the command is even registered; this Hidden flag covers the case - // where it is registered (testers with VERDA_S3_ENABLED set). - Hidden: true, Long: cmdutil.LongDesc(` Manage S3-compatible object storage credentials and operations. @@ -46,9 +41,11 @@ func NewCmdS3(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { } cmd.AddCommand( + NewCmdAbortUploads(f, ioStreams), NewCmdConfigure(f, ioStreams), NewCmdCp(f, ioStreams), NewCmdLs(f, ioStreams), + NewCmdLsUploads(f, ioStreams), NewCmdMb(f, ioStreams), NewCmdMv(f, ioStreams), NewCmdPresign(f, ioStreams), diff --git a/internal/verda-cli/cmd/s3/show.go b/internal/verda-cli/cmd/s3/show.go index 99340cc..55ddf5d 100644 --- a/internal/verda-cli/cmd/s3/show.go +++ b/internal/verda-cli/cmd/s3/show.go @@ -51,7 +51,7 @@ func NewCmdShow(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { } if profile == "" { - profile = f.Options().AuthOptions.Profile + profile = options.ActiveProfile(f.Options().AuthOptions.Profile) } if profile == "" { profile = "default" diff --git a/internal/verda-cli/cmd/s3/show_test.go b/internal/verda-cli/cmd/s3/show_test.go new file mode 100644 index 0000000..54134ab --- /dev/null +++ b/internal/verda-cli/cmd/s3/show_test.go @@ -0,0 +1,149 @@ +// 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 s3 + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + clioptions "github.com/verda-cloud/verda-cli/internal/verda-cli/options" +) + +// writeCredsFile writes an INI credentials file under t.TempDir and returns its +// path. content is the raw INI body. +func writeCredsFile(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "credentials") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write creds file: %v", err) + } + return path +} + +func runShow(t *testing.T, path string, args ...string) (stdout, stderr string) { + t.Helper() + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + // Mirror production wiring: clioptions.New() always sets AuthOptions, which + // show reads to default the profile. The bare TestFactory leaves it nil. + f.OptionsOverride = &clioptions.Options{ + Server: "https://test.verda.com/v1", + Timeout: 10 * time.Second, + Output: "table", + AuthOptions: &clioptions.AuthOptions{}, + } + cmd := NewCmdShow(f, cmdutil.IOStreams{Out: out, ErrOut: errOut}) + full := append([]string{"--credentials-file", path}, args...) + cmd.SetArgs(full) + cmd.SetContext(context.Background()) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + return out.String(), errOut.String() +} + +func TestShow_Configured(t *testing.T) { + // no t.Parallel — NewTestFactory + shared package state kept consistent with siblings + path := writeCredsFile(t, `[default] +verda_s3_access_key = AKIA123 +verda_s3_secret_key = secret456 +verda_s3_endpoint = https://objects.example.com +verda_s3_region = eu-north-1 +`) + stdout, stderr := runShow(t, path) + + for _, want := range []string{ + "profile: default", + "access_key_loaded: true", + "secret_key_loaded: true", + "https://objects.example.com", + "eu-north-1", + } { + if !strings.Contains(stdout, want) { + t.Errorf("stdout missing %q:\n%s", want, stdout) + } + } + if strings.Contains(stderr, "Missing") { + t.Errorf("did not expect a 'Missing' warning for fully-configured creds:\n%s", stderr) + } +} + +// TestShow_PartialMissingEndpoint exercises the HasCredentials()==false branch +// and valueOrDash: with no endpoint/region set, both render as "-" and a +// "Missing" hint is printed to stderr. +func TestShow_PartialMissingEndpoint(t *testing.T) { + // no t.Parallel + path := writeCredsFile(t, `[default] +verda_s3_access_key = AKIA123 +verda_s3_secret_key = secret456 +`) + stdout, stderr := runShow(t, path) + + if !strings.Contains(stdout, "endpoint: -") { + t.Errorf("expected endpoint rendered as '-' (valueOrDash):\n%s", stdout) + } + if !strings.Contains(stdout, "region: -") { + t.Errorf("expected region rendered as '-' (valueOrDash):\n%s", stdout) + } + if !strings.Contains(stderr, "Missing") || !strings.Contains(stderr, "endpoint") { + t.Errorf("expected a 'Missing: endpoint' hint on stderr:\n%s", stderr) + } +} + +func TestShow_NotConfigured(t *testing.T) { + // no t.Parallel + missing := filepath.Join(t.TempDir(), "does-not-exist") + stdout, stderr := runShow(t, missing) + + if !strings.Contains(stdout, "s3_configured") || !strings.Contains(stdout, "false") { + t.Errorf("expected 's3_configured: false' for a missing creds file:\n%s", stdout) + } + if !strings.Contains(stderr, "No S3 credentials found") { + t.Errorf("expected guidance on stderr when not configured:\n%s", stderr) + } +} + +func TestShow_CustomProfile(t *testing.T) { + // no t.Parallel + path := writeCredsFile(t, `[default] +verda_s3_access_key = default-key +verda_s3_secret_key = default-secret +verda_s3_endpoint = https://default.example.com + +[staging] +verda_s3_access_key = staging-key +verda_s3_secret_key = staging-secret +verda_s3_endpoint = https://staging.example.com +verda_s3_region = eu-west-1 +`) + stdout, _ := runShow(t, path, "--profile", "staging") + + if !strings.Contains(stdout, "profile: staging") { + t.Errorf("expected the staging profile to be shown:\n%s", stdout) + } + if !strings.Contains(stdout, "https://staging.example.com") { + t.Errorf("expected the staging endpoint, not default's:\n%s", stdout) + } + if strings.Contains(stdout, "https://default.example.com") { + t.Errorf("staging show leaked the default profile's endpoint:\n%s", stdout) + } +} diff --git a/internal/verda-cli/cmd/s3/sync.go b/internal/verda-cli/cmd/s3/sync.go index 6de7a20..8b64a2b 100644 --- a/internal/verda-cli/cmd/s3/sync.go +++ b/internal/verda-cli/cmd/s3/sync.go @@ -187,7 +187,7 @@ func runSyncUpload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOS payload := newSyncPayload(opts.Dryrun) payload.Skipped = plan.skipped started := time.Now() - cpOpts := &cpOptions{Dryrun: opts.Dryrun} + cpOpts := &cpOptions{Dryrun: opts.Dryrun, quietProgress: true} // Copies: upload each planned entry. for _, rel := range plan.toCopy { @@ -262,7 +262,7 @@ func runSyncDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.I } sub := newCpPayload(opts.Dryrun) quiet := quietStreams(ioStreams) - if err := downloadOne(ctx, f, quiet, transporter, URI{Bucket: src.Bucket, Key: key}, localPath, rel, cpOpts, &sub); err != nil { + if err := downloadOne(ctx, f, quiet, transporter, apiClient, URI{Bucket: src.Bucket, Key: key}, localPath, rel, cpOpts, &sub); err != nil { return err } appendCopied(ioStreams, f, payload, sub, rel, opts.Dryrun, "downloaded") diff --git a/internal/verda-cli/cmd/s3/transfer.go b/internal/verda-cli/cmd/s3/transfer.go index 389ce8d..a9ef203 100644 --- a/internal/verda-cli/cmd/s3/transfer.go +++ b/internal/verda-cli/cmd/s3/transfer.go @@ -21,8 +21,11 @@ import ( "mime" "os" "path/filepath" + "strconv" "strings" + "time" + "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -55,7 +58,14 @@ func defaultTransporterBuilder(ctx context.Context, f cmdutil.Factory, ov Client //nolint:staticcheck // feature/s3/manager is deprecated in favor of transfermanager, // but transfermanager is not yet part of any tagged module release. Switch when available. return &sdkTransporter{ - up: manager.NewUploader(sdkClient), + // The Uploader keeps its OWN RequestChecksumCalculation (defaulting to + // WhenSupported) independent of the s3 client's. Left at the default it + // adds a CRC32 trailer (aws-chunked / STREAMING-UNSIGNED-PAYLOAD-TRAILER) + // to every UploadPart, which Ceph RGW rejects with 400 + // XAmzContentSHA256Mismatch. Force WhenRequired to match NewClient. + up: manager.NewUploader(sdkClient, func(u *manager.Uploader) { + u.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired + }), down: manager.NewDownloader(sdkClient), }, nil } @@ -107,3 +117,66 @@ func inferContentType(path, override string) string { } return "application/octet-stream" } + +// byteUnits maps a size suffix to its multiplier. Both binary (MiB) and the +// loose decimal-looking forms (MB, M) are accepted and treated as binary, since +// part sizes are inherently power-of-two oriented and users rarely mean exactly +// 10^6 here. +var byteUnits = []struct { + suffix string + mult int64 +}{ + {"GiB", 1 << 30}, {"MiB", 1 << 20}, {"KiB", 1 << 10}, + {"GB", 1 << 30}, {"MB", 1 << 20}, {"KB", 1 << 10}, + {"G", 1 << 30}, {"M", 1 << 20}, {"K", 1 << 10}, + {"B", 1}, +} + +// parseByteSize parses a human size like "32MiB", "8M", or "1073741824" into +// bytes. An empty string returns 0 (caller treats 0 as "auto"). Suffixes are +// case-insensitive. +func parseByteSize(s string) (int64, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, nil + } + upper := strings.ToUpper(s) + for i := range byteUnits { + u := strings.ToUpper(byteUnits[i].suffix) + if strings.HasSuffix(upper, u) { + num := strings.TrimSpace(upper[:len(upper)-len(u)]) + v, err := strconv.ParseInt(num, 10, 64) + if err != nil || v < 0 { + return 0, fmt.Errorf("invalid size %q", s) + } + return v * byteUnits[i].mult, nil + } + } + v, err := strconv.ParseInt(upper, 10, 64) + if err != nil || v < 0 { + return 0, fmt.Errorf("invalid size %q", s) + } + return v, nil +} + +// parseOlderThan parses a coarse age like "7d", "12h", "30m" into a Duration. +// It extends time.ParseDuration with a "d" (days) unit, which the stdlib does +// not support. An empty string returns 0 (caller treats 0 as "no age filter"). +func parseOlderThan(s string) (time.Duration, error) { + s = strings.TrimSpace(s) + if s == "" { + return 0, nil + } + if strings.HasSuffix(s, "d") { + days, err := strconv.ParseFloat(strings.TrimSuffix(s, "d"), 64) + if err != nil || days < 0 { + return 0, fmt.Errorf("invalid duration %q", s) + } + return time.Duration(days * float64(24*time.Hour)), nil + } + d, err := time.ParseDuration(s) + if err != nil || d < 0 { + return 0, fmt.Errorf("invalid duration %q", s) + } + return d, nil +} diff --git a/internal/verda-cli/cmd/s3/transfer_progress.go b/internal/verda-cli/cmd/s3/transfer_progress.go new file mode 100644 index 0000000..21fa7fa --- /dev/null +++ b/internal/verda-cli/cmd/s3/transfer_progress.go @@ -0,0 +1,108 @@ +// 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 s3 + +import ( + "fmt" + "io" + "strings" + "sync" + "sync/atomic" + "time" +) + +const ( + // progressBarWidth is the cell width of the in-line transfer bar. + progressBarWidth = 24 + // progressMinInterval throttles redraws (download writes can fire thousands + // of times per second from concurrent ranged GETs). + progressMinInterval = 100 * time.Millisecond +) + +// transferProgress renders a single in-place line (overwritten via \r) showing +// a bar, percentage, and live transfer rate for an upload or download. The tui +// progress component accepts only a percentage and can't carry a rate, so the +// transfer paths render their own line. The rate is measured over bytes moved +// this session (baseline-relative), so a resumed upload reports its true +// throughput. update is safe to call concurrently (download writers do). +type transferProgress struct { + w io.Writer + verb string // "Uploading" / "Downloading" + name string + total int64 + start time.Time + + mu sync.Mutex + baseline int64 + baseSet bool + lastDraw time.Time +} + +func newTransferProgress(w io.Writer, verb, name string, total int64, start time.Time) *transferProgress { + return &transferProgress{w: w, verb: verb, name: name, total: total, start: start} +} + +// update redraws the line for the cumulative bytes transferred so far. +func (p *transferProgress) update(done int64) { + p.mu.Lock() + defer p.mu.Unlock() + if !p.baseSet { + p.baseline, p.baseSet = done, true + } + now := time.Now() + final := p.total > 0 && done >= p.total + if !final && !p.lastDraw.IsZero() && now.Sub(p.lastDraw) < progressMinInterval { + return + } + p.lastDraw = now + + rate := "" + if moved := done - p.baseline; moved > 0 { + if secs := now.Sub(p.start).Seconds(); secs > 0 { + rate = humanBytes(int64(float64(moved)/secs)) + "/s" + } + } + if p.total > 0 { + pct := float64(done) / float64(p.total) + filled := min(int(pct*progressBarWidth), progressBarWidth) + bar := strings.Repeat("█", filled) + strings.Repeat("░", progressBarWidth-filled) + _, _ = fmt.Fprintf(p.w, "\r %s %s %s %3.0f%% %-11s", p.verb, p.name, bar, pct*100, rate) + return + } + // Unknown total (e.g. HeadObject unavailable): show bytes + rate, no bar. + _, _ = fmt.Fprintf(p.w, "\r %s %s %s %-11s", p.verb, p.name, humanBytes(done), rate) +} + +// finish clears the progress line so the final result line prints cleanly. +func (p *transferProgress) finish() { + _, _ = fmt.Fprint(p.w, "\r\033[K") +} + +// countingWriterAt wraps an io.WriterAt and reports the running total of bytes +// written via onWrite. The transfer manager writes ranges concurrently, so the +// counter is atomic; onWrite must be safe for concurrent calls. +type countingWriterAt struct { + w io.WriterAt + n atomic.Int64 + onWrite func(total int64) +} + +func (c *countingWriterAt) WriteAt(p []byte, off int64) (int, error) { + written, err := c.w.WriteAt(p, off) + if written > 0 && c.onWrite != nil { + c.onWrite(c.n.Add(int64(written))) + } + return written, err +} diff --git a/internal/verda-cli/cmd/s3/transfer_progress_test.go b/internal/verda-cli/cmd/s3/transfer_progress_test.go new file mode 100644 index 0000000..e27ce64 --- /dev/null +++ b/internal/verda-cli/cmd/s3/transfer_progress_test.go @@ -0,0 +1,74 @@ +// 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 s3 + +import ( + "bytes" + "strings" + "testing" + "time" +) + +func TestTransferProgress_BarPercentRate(t *testing.T) { + t.Parallel() + buf := &bytes.Buffer{} + p := newTransferProgress(buf, "Downloading", "model.bin", 100, time.Now().Add(-2*time.Second)) + p.update(0) // first call sets baseline + buf.Reset() + p.update(100) // reaching total forces a render despite the throttle + + out := buf.String() + for _, want := range []string{"Downloading", "model.bin", "100%", "/s", "█", "\r"} { + if !strings.Contains(out, want) { + t.Errorf("progress line missing %q:\n%q", want, out) + } + } +} + +func TestTransferProgress_UnknownTotalNoBar(t *testing.T) { + t.Parallel() + buf := &bytes.Buffer{} + p := newTransferProgress(buf, "Downloading", "blob.bin", 0, time.Now()) + p.update(2048) + + out := buf.String() + if !strings.Contains(out, "blob.bin") { + t.Errorf("missing name: %q", out) + } + if strings.Contains(out, "%") || strings.Contains(out, "█") { + t.Errorf("unknown total should render no bar/percent: %q", out) + } +} + +// trackingWriterAt is a no-op io.WriterAt used to exercise countingWriterAt. +type trackingWriterAt struct{} + +func (trackingWriterAt) WriteAt(p []byte, _ int64) (int, error) { return len(p), nil } + +func TestCountingWriterAt_AccumulatesAndReports(t *testing.T) { + t.Parallel() + var totals []int64 + c := &countingWriterAt{w: trackingWriterAt{}, onWrite: func(total int64) { totals = append(totals, total) }} + + if n, err := c.WriteAt([]byte("hello"), 0); n != 5 || err != nil { + t.Fatalf("WriteAt = (%d, %v)", n, err) + } + if n, err := c.WriteAt([]byte("ab"), 5); n != 2 || err != nil { + t.Fatalf("WriteAt = (%d, %v)", n, err) + } + if len(totals) != 2 || totals[0] != 5 || totals[1] != 7 { + t.Errorf("cumulative totals = %v, want [5 7]", totals) + } +} diff --git a/internal/verda-cli/cmd/s3/tui_interactive_test.go b/internal/verda-cli/cmd/s3/tui_interactive_test.go new file mode 100644 index 0000000..3062c33 --- /dev/null +++ b/internal/verda-cli/cmd/s3/tui_interactive_test.go @@ -0,0 +1,263 @@ +// 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 s3 + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/verda-cloud/verdagostack/pkg/tui" + tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// ----- mb: interactive name prompt --------------------------------------- + +func TestPromptNewBucketName_TrimsInput(t *testing.T) { + // no t.Parallel — prompter state + f := cmdutil.NewTestFactory(tuitest.New().AddTextInput(" my-bucket ")) + got, err := promptNewBucketName(context.Background(), f) + if err != nil { + t.Fatalf("promptNewBucketName: %v", err) + } + if got != "my-bucket" { + t.Errorf("name = %q, want my-bucket (trimmed)", got) + } +} + +func TestPromptNewBucketName_EmptyIsCleanCancel(t *testing.T) { + f := cmdutil.NewTestFactory(tuitest.New().AddTextInput(" ")) + got, err := promptNewBucketName(context.Background(), f) + if err != nil || got != "" { + t.Errorf("got (%q, %v), want empty/no-error for blank input", got, err) + } +} + +// ----- picker: flat object selection (mv source) ------------------------- + +func TestSelectObjectKey_PicksChosen(t *testing.T) { + fake := &fakeS3API{objects: []s3types.Object{ + {Key: aws.String("a.txt")}, + {Key: aws.String("b.txt")}, + }} + f := cmdutil.NewTestFactory(tuitest.New().AddSelect(1)) + got, err := selectObjectKey(context.Background(), f, ioBufs(), fake, "bucket") + if err != nil { + t.Fatalf("selectObjectKey: %v", err) + } + if got != "b.txt" { + t.Errorf("chosen key = %q, want b.txt", got) + } +} + +func TestSelectObjectKey_EmptyBucketReturnsBlank(t *testing.T) { + fake := &fakeS3API{} + f := cmdutil.NewTestFactory(tuitest.New()) + errOut := &bytes.Buffer{} + got, err := selectObjectKey(context.Background(), f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: errOut}, fake, "bucket") + if err != nil || got != "" { + t.Errorf("got (%q, %v), want empty for empty bucket", got, err) + } + if !strings.Contains(errOut.String(), "No objects") { + t.Errorf("missing empty-bucket note: %q", errOut.String()) + } +} + +// ----- rm: interactive folder browser delete ----------------------------- + +// rmBrowseFake is prefix-aware (root exposes data/, data/ exposes one object) +// and records DeleteObjects keys, dropping deleted keys from later listings. +type rmBrowseFake struct { + API + deleted map[string]bool +} + +func (r *rmBrowseFake) ListBuckets(ctx context.Context, in *s3.ListBucketsInput, opts ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { + return &s3.ListBucketsOutput{Buckets: []s3types.Bucket{{Name: aws.String("b")}}}, nil +} + +func (r *rmBrowseFake) ListObjectsV2(ctx context.Context, in *s3.ListObjectsV2Input, opts ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { + prefix := aws.ToString(in.Prefix) + out := &s3.ListObjectsV2Output{IsTruncated: aws.Bool(false)} + switch prefix { + case "": + out.CommonPrefixes = []s3types.CommonPrefix{{Prefix: aws.String("data/")}} + case "data/": + if !r.deleted["data/file.txt"] { + out.Contents = []s3types.Object{{Key: aws.String("data/file.txt"), Size: aws.Int64(10)}} + } + } + return out, nil +} + +func (r *rmBrowseFake) DeleteObjects(ctx context.Context, in *s3.DeleteObjectsInput, opts ...func(*s3.Options)) (*s3.DeleteObjectsOutput, error) { + for i := range in.Delete.Objects { + r.deleted[aws.ToString(in.Delete.Objects[i].Key)] = true + } + return &s3.DeleteObjectsOutput{}, nil +} + +func TestRmBrowser_DrillInMultiDelete(t *testing.T) { + // no t.Parallel — prompter state + fake := &rmBrowseFake{deleted: map[string]bool{}} + + // bucket b(0) -> folder data/(1) -> Delete-files-here(1) -> [tick 0] -> + // confirm(true) -> re-list data/ (now empty: up, exit) -> Exit(1). + mock := tuitest.New(). + AddSelect(0).AddSelect(1).AddSelect(1). + AddMultiSelect([]int{0}). + AddConfirm(true). + AddSelect(1) + + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + f := cmdutil.NewTestFactory(mock) + + if err := runRmBrowser(context.Background(), f, cmdutil.IOStreams{Out: out, ErrOut: errOut}, fake); err != nil { + t.Fatalf("runRmBrowser: %v", err) + } + if !fake.deleted["data/file.txt"] { + t.Errorf("data/file.txt was not deleted; deleted=%v", fake.deleted) + } + if !strings.Contains(out.String(), "deleted") { + t.Errorf("missing delete confirmation on stdout:\n%s", out.String()) + } + if !strings.Contains(errOut.String(), "permanently delete") { + t.Errorf("missing destructive warning on stderr:\n%s", errOut.String()) + } +} + +// ----- mv: interactive S3->S3 move wizard -------------------------------- + +type mvWizardFake struct { + API + buckets []string + objects []string + copiedSrc string + copiedDst string + deleted string +} + +func (m *mvWizardFake) ListBuckets(ctx context.Context, in *s3.ListBucketsInput, opts ...func(*s3.Options)) (*s3.ListBucketsOutput, error) { + bs := make([]s3types.Bucket, 0, len(m.buckets)) + for _, b := range m.buckets { + bs = append(bs, s3types.Bucket{Name: aws.String(b)}) + } + return &s3.ListBucketsOutput{Buckets: bs}, nil +} + +func (m *mvWizardFake) ListObjectsV2(ctx context.Context, in *s3.ListObjectsV2Input, opts ...func(*s3.Options)) (*s3.ListObjectsV2Output, error) { + objs := make([]s3types.Object, 0, len(m.objects)) + for _, k := range m.objects { + objs = append(objs, s3types.Object{Key: aws.String(k)}) + } + return &s3.ListObjectsV2Output{Contents: objs, IsTruncated: aws.Bool(false)}, nil +} + +func (m *mvWizardFake) CopyObject(ctx context.Context, in *s3.CopyObjectInput, opts ...func(*s3.Options)) (*s3.CopyObjectOutput, error) { + m.copiedSrc = aws.ToString(in.CopySource) + m.copiedDst = aws.ToString(in.Bucket) + "/" + aws.ToString(in.Key) + return &s3.CopyObjectOutput{}, nil +} + +func (m *mvWizardFake) DeleteObject(ctx context.Context, in *s3.DeleteObjectInput, opts ...func(*s3.Options)) (*s3.DeleteObjectOutput, error) { + m.deleted = aws.ToString(in.Bucket) + "/" + aws.ToString(in.Key) + return &s3.DeleteObjectOutput{}, nil +} + +func TestMoveWizard_S3ToS3(t *testing.T) { + // no t.Parallel — clientBuilder/prompter state + fake := &mvWizardFake{buckets: []string{"src", "dst"}, objects: []string{"a.txt"}} + restore := withFakeClient(fake) + defer restore() + + // src bucket(0) -> object a.txt(0) -> dst bucket(1) -> dest key -> confirm. + mock := tuitest.New(). + AddSelect(0).AddSelect(0).AddSelect(1). + AddTextInput("renamed.txt"). + AddConfirm(true) + + f := cmdutil.NewTestFactory(mock) + cmd := NewCmdMv(f, ioBufs()) + + errOut := &bytes.Buffer{} + io := cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: errOut} + if err := runMoveWizard(cmd, f, io, &cpOptions{}, nil); err != nil { + t.Fatalf("runMoveWizard: %v", err) + } + if !strings.Contains(errOut.String(), "Move / rename an S3 object") { + t.Errorf("missing wizard intro banner:\n%s", errOut.String()) + } + if fake.deleted != "src/a.txt" { + t.Errorf("source deleted = %q, want src/a.txt", fake.deleted) + } + if fake.copiedDst != "dst/renamed.txt" { + t.Errorf("copy dest = %q, want dst/renamed.txt", fake.copiedDst) + } + if !strings.Contains(fake.copiedSrc, "src") || !strings.Contains(fake.copiedSrc, "a.txt") { + t.Errorf("copy source = %q, want it to reference src/a.txt", fake.copiedSrc) + } +} + +// TestNavIdx covers the wizard index navigation that makes Esc=back work: +// success advances and applies, Esc steps back, Ctrl+C/real errors terminate. +func TestNavIdx(t *testing.T) { + t.Parallel() + boom := errors.New("io failure") + cases := []struct { + name string + err error + wantNext int + wantErr error + wantApply bool + }{ + {"success advances + applies", nil, 3, nil, true}, + {"esc steps back", context.Canceled, 1, nil, false}, + {"ctrl+c terminates", tui.ErrInterrupted, -1, nil, false}, + {"real error propagates", boom, 2, boom, false}, + } + for _, tc := range cases { + applied := false + next, out := navIdx(2, tc.err, func() { applied = true }) + if next != tc.wantNext || !errors.Is(out, tc.wantErr) || applied != tc.wantApply { + t.Errorf("%s: navIdx = (next=%d err=%v applied=%v), want (%d %v %v)", + tc.name, next, out, applied, tc.wantNext, tc.wantErr, tc.wantApply) + } + } +} + +func TestSelectStep_EmptyValueExits(t *testing.T) { + t.Parallel() + applied := false + next, err := selectStep(0, "", nil, func() { applied = true }) + if next != -1 || err != nil || applied { + t.Errorf("selectStep(empty) = (next=%d err=%v applied=%v), want (-1, nil, false)", next, err, applied) + } + // Non-empty success still advances + applies. + if next, _ := selectStep(0, "bucket", nil, func() { applied = true }); next != 1 || !applied { + t.Errorf("selectStep(value) next=%d applied=%v, want 1/true", next, applied) + } +} + +func ioBufs() cmdutil.IOStreams { + return cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}} +} diff --git a/internal/verda-cli/cmd/s3/upload_wizard.go b/internal/verda-cli/cmd/s3/upload_wizard.go new file mode 100644 index 0000000..f72aad5 --- /dev/null +++ b/internal/verda-cli/cmd/s3/upload_wizard.go @@ -0,0 +1,330 @@ +// 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 s3 + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +const ( + uploadBucketRootLabel = "(bucket root)" + uploadNewBucketLabel = "+ Create new bucket…" + uploadNewFolderLabel = "+ New folder…" +) + +// upload wizard steps. +const ( + stepSource = iota + stepBucket + stepLocation + stepConfirm +) + +// runUploadWizard guides an interactive upload: source (validated to exist) -> +// destination bucket (pick or create) -> destination folder (root / existing / +// new) -> confirm. It then runs the normal upload path so large files still get +// the resumable multipart uploader; --recursive is inferred from the source. +// +// Navigation honors the hint bar: Esc steps BACK one question, Ctrl+C exits. +// The pickers return the raw prompter error so this loop can distinguish the two +// (cmdutil.IsPromptBack vs IsPromptInterrupt). +func runUploadWizard(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *cpOptions, args []string) error { + // Unbounded: interactive prompts (user think-time) and the upload itself + // must not hit the short per-request --timeout. Ctrl+C cancels. + ctx := cmd.Context() + + client, err := buildClient(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + + // When the source is supplied as an arg it is fixed (the source step just + // returns it), so Esc on the bucket step exits rather than re-prompting. + sourceFromArg := len(args) == 1 + + var ( + source string + isDir bool + bucket string + prefix string + ) + step := stepSource + for { + switch step { + case stepSource: + source, isDir, err = resolveUploadSource(ctx, f, ioStreams, args) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil // first step: any cancel exits + } + return err + } + if source == "" { + return nil + } + step = stepBucket + + case stepBucket: + bucket, err = selectBucketOrCreate(ctx, f, ioStreams, client) + if back, exit, fatal := classifyNav(err, sourceFromArg); fatal != nil { + return fatal + } else if exit { + return nil + } else if back { + step = stepSource + continue + } + step = stepLocation + + case stepLocation: + suggested := "" + if isDir { + suggested = filepath.Base(source) + } + prefix, err = selectUploadLocation(ctx, f, ioStreams, client, bucket, suggested) + if back, exit, fatal := classifyNav(err, false); fatal != nil { + return fatal + } else if exit { + return nil + } else if back { + step = stepBucket + continue + } + step = stepConfirm + + case stepConfirm: + back, cerr := confirmAndRunUpload(ctx, cmd, f, ioStreams, source, isDir, URI{Bucket: bucket, Key: prefix}, opts) + if cerr != nil { + return cerr + } + if back { + step = stepLocation + continue + } + return nil + } + } +} + +// confirmAndRunUpload previews the planned cp, confirms (default Yes), and runs +// the upload. back=true means Esc -> return to the folder step; Ctrl+C or "no" +// is a clean exit (back=false, err=nil). +func confirmAndRunUpload(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, source string, isDir bool, dstURI URI, opts *cpOptions) (back bool, err error) { + opts.Recursive = isDir + destDisplay := dstURI.String() + if !strings.HasSuffix(destDisplay, "/") { + destDisplay += "/" + } + preview := "verda s3 cp " + source + " " + destDisplay + if isDir { + preview += " --recursive" + } + _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n Will run: %s\n\n", preview) + + confirmed, cerr := f.Prompter().Confirm(ctx, "Proceed with upload? (esc to go back)", tui.WithConfirmDefault(true)) + if cerr != nil { + if cmdutil.IsPromptBack(cerr) { + return true, nil + } + if cmdutil.IsPromptInterrupt(cerr) { + return false, nil + } + return false, cerr + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return false, nil + } + return false, runUpload(ctx, cmd, f, ioStreams, source, dstURI, opts) +} + +// classifyNav maps a picker's returned error into back/exit/real outcomes. +// Esc (IsPromptBack) is a step-back unless this is the first interactive step, +// in which case it exits; Ctrl+C (IsPromptInterrupt) always exits. A non-prompt +// error is "real" and propagates. A nil error means advance. +func classifyNav(err error, firstStep bool) (back, exit bool, fatal error) { + switch { + case err == nil: + return false, false, nil + case cmdutil.IsPromptInterrupt(err): + return false, true, nil + case cmdutil.IsPromptBack(err): + if firstStep { + return false, true, nil + } + return true, false, nil + default: + return false, false, err + } +} + +// resolveUploadSource returns the local path to upload (from args[0] if given, +// else a prompt) and whether it is a directory. The path must exist; a bad +// explicit arg errors, a bad typed path re-prompts. On Esc/Ctrl+C it returns the +// raw prompter error; on empty input it returns ("", false, nil). +func resolveUploadSource(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, args []string) (srcPath string, isDir bool, err error) { + if len(args) == 1 { + info, err := os.Stat(args[0]) + if err != nil { + return "", false, fmt.Errorf("source %q: %w", args[0], err) + } + return args[0], info.IsDir(), nil + } + + for { + path, err := f.Prompter().TextInput(ctx, "Local file or folder to upload") + if err != nil { + return "", false, err + } + path = strings.TrimSpace(path) + if path == "" { + return "", false, nil + } + info, statErr := os.Stat(path) + if statErr != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %v — try again.\n", statErr) + continue + } + return path, info.IsDir(), nil + } +} + +// selectBucketOrCreate lists buckets with a trailing "create new" choice and +// returns the chosen/created bucket name. Returns the raw prompter error if the +// 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) { + return client.ListBuckets(ctx, &s3.ListBucketsInput{}) + }) + if err != nil { + return "", translateError(err) + } + + for { + labels := make([]string, 0, len(out.Buckets)+1) + for i := range out.Buckets { + labels = append(labels, "📦 "+aws.ToString(out.Buckets[i].Name)) + } + labels = append(labels, uploadNewBucketLabel) + + idx, serr := f.Prompter().Select(ctx, "Destination bucket", labels, tui.WithShowHints(true)) + if serr != nil { + return "", serr // raw: caller distinguishes Esc (back) from Ctrl+C (exit) + } + if idx != len(out.Buckets) { + return aws.ToString(out.Buckets[idx].Name), nil + } + // Create new: Esc on the name prompt returns to the bucket list. + name, cerr := createBucketInteractive(ctx, f, ioStreams, client) + if cerr != nil { + if cmdutil.IsPromptInterrupt(cerr) { + return "", cerr + } + continue // Esc / empty -> back to the bucket list + } + if name != "" { + return name, nil + } + } +} + +// createBucketInteractive prompts for a name and creates the bucket. A canceled +// or empty prompt returns ("", err) / ("", nil) for the caller to loop on. +func createBucketInteractive(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API) (string, error) { + name, err := f.Prompter().TextInput(ctx, "New bucket name") + if err != nil { + return "", err + } + name = strings.TrimSpace(name) + if name == "" { + return "", nil + } + _, err = cmdutil.WithSpinner(ctx, f.Status(), "Creating bucket...", func() (*s3.CreateBucketOutput, error) { + return client.CreateBucket(ctx, &s3.CreateBucketInput{Bucket: aws.String(name)}) + }) + if err != nil { + return "", translateError(err) + } + _, _ = fmt.Fprintf(ioStreams.ErrOut, " Created bucket %s\n", name) + return name, nil +} + +// selectUploadLocation returns the destination prefix: "" for the bucket root, +// an existing top-level folder, or a newly typed folder. Returns the raw +// 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) { + return collectObjects(ctx, f, ioStreams, client, URI{Bucket: bucket}, "/") + }) + if err != nil { + return "", err + } + + for { + labels := make([]string, 0, len(payload.CommonPrefixes)+2) + labels = append(labels, uploadBucketRootLabel) + labels = append(labels, payload.CommonPrefixes...) + labels = append(labels, uploadNewFolderLabel) + + idx, serr := f.Prompter().Select(ctx, "Destination folder in s3://"+bucket, labels, tui.WithShowHints(true)) + if serr != nil { + return "", serr + } + switch { + case idx == 0: // bucket root + return "", nil + case idx == len(labels)-1: // new folder + name, nerr := newFolderInteractive(ctx, f, suggested) + if nerr != nil { + if cmdutil.IsPromptInterrupt(nerr) { + return "", nerr + } + continue // Esc / empty -> back to the folder list + } + if name != "" { + return name, nil + } + default: + return payload.CommonPrefixes[idx-1], nil + } + } +} + +// newFolderInteractive prompts for a new prefix segment, normalized to end in a +// single slash. Empty input returns "" (caller loops back to the folder list). +func newFolderInteractive(ctx context.Context, f cmdutil.Factory, suggested string) (string, error) { + name, err := f.Prompter().TextInput(ctx, "New folder name", tui.WithDefault(suggested)) + if err != nil { + return "", err + } + name = strings.Trim(strings.TrimSpace(name), "/") + if name == "" { + return "", nil + } + return name + "/", nil +} diff --git a/internal/verda-cli/cmd/s3/upload_wizard_test.go b/internal/verda-cli/cmd/s3/upload_wizard_test.go new file mode 100644 index 0000000..84c6a5a --- /dev/null +++ b/internal/verda-cli/cmd/s3/upload_wizard_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 s3 + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" + tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// TestUploadWizard_SingleFileToRoot runs the wizard with the source given as an +// arg, picks the existing bucket, the bucket root, confirms, and verifies the +// upload lands at the file's basename. +func TestUploadWizard_SingleFileToRoot(t *testing.T) { + // no t.Parallel — clientBuilder/transporter/prompter state + tmp := t.TempDir() + src := filepath.Join(tmp, "report.csv") + if err := os.WriteFile(src, []byte("a,b,c\n"), 0o600); err != nil { + t.Fatal(err) + } + + restore := withFakeClient(&fakeS3API{buckets: []s3types.Bucket{{Name: aws.String("b")}}}) + defer restore() + fakeT := &cpFakeTransporter{} + restoreT := withFakeTransporter(fakeT) + defer restoreT() + + // bucket(0) -> location root(0) -> confirm + mock := tuitest.New().AddSelect(0).AddSelect(0).AddConfirm(true) + + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + f := cmdutil.NewTestFactory(mock) + opts := &cpOptions{Concurrency: defaultConcurrency} + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + if err := runUploadWizard(cmd, f, cmdutil.IOStreams{Out: out, ErrOut: errOut}, opts, []string{src}); err != nil { + t.Fatalf("runUploadWizard: %v", err) + } + + if len(fakeT.uploads) != 1 { + t.Fatalf("Upload calls = %d, want 1", len(fakeT.uploads)) + } + if k := aws.ToString(fakeT.uploads[0].Key); k != "report.csv" { + t.Errorf("upload key = %q, want report.csv", k) + } + if opts.Recursive { + t.Errorf("Recursive should be false for a single file") + } +} + +// TestSelectUploadLocation_NewFolder verifies the '+ New folder…' path returns +// the typed prefix with a normalized trailing slash. +func TestSelectUploadLocation_NewFolder(t *testing.T) { + // no t.Parallel + fake := &fakeS3API{} // no objects -> labels: [root, + New folder…] + mock := tuitest.New().AddSelect(1).AddTextInput("models") + f := cmdutil.NewTestFactory(mock) + + prefix, err := selectUploadLocation(context.Background(), f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, fake, "b", "data") + if err != nil { + t.Fatalf("selectUploadLocation: %v", err) + } + if prefix != "models/" { + t.Errorf("prefix = %q, want models/", prefix) + } +} + +// TestSelectUploadLocation_Root returns an empty prefix for the bucket-root choice. +func TestSelectUploadLocation_Root(t *testing.T) { + // no t.Parallel + fake := &fakeS3API{} + mock := tuitest.New().AddSelect(0) + f := cmdutil.NewTestFactory(mock) + + prefix, err := selectUploadLocation(context.Background(), f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}, fake, "b", "") + if err != nil { + t.Fatalf("unexpected: %v", err) + } + if prefix != "" { + t.Errorf("prefix = %q, want empty (root)", prefix) + } +} + +// TestClassifyNav covers the Esc=back / Ctrl+C=exit mapping the wizard relies on +// (tuitest can't synthesize cancel errors, so the nav logic is tested directly). +func TestClassifyNav(t *testing.T) { + t.Parallel() + boom := errors.New("boom") + cases := []struct { + name string + err error + firstStep bool + back bool + exit bool + fatal error + }{ + {"nil advances", nil, false, false, false, nil}, + {"ctrl+c exits", tui.ErrInterrupted, false, false, true, nil}, + {"esc backs", context.Canceled, false, true, false, nil}, + {"esc on first step exits", context.Canceled, true, false, true, nil}, + {"real error propagates", boom, false, false, false, boom}, + } + for _, tc := range cases { + back, exit, fatal := classifyNav(tc.err, tc.firstStep) + if back != tc.back || exit != tc.exit || !errors.Is(fatal, tc.fatal) { + t.Errorf("%s: classifyNav = (back=%v exit=%v fatal=%v), want (%v %v %v)", + tc.name, back, exit, fatal, tc.back, tc.exit, tc.fatal) + } + } +} diff --git a/internal/verda-cli/cmd/s3/uploads_test.go b/internal/verda-cli/cmd/s3/uploads_test.go new file mode 100644 index 0000000..e20f6e2 --- /dev/null +++ b/internal/verda-cli/cmd/s3/uploads_test.go @@ -0,0 +1,451 @@ +// 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 s3 + +import ( + "bytes" + "context" + "errors" + "sort" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" + s3types "github.com/aws/aws-sdk-go-v2/service/s3/types" + "github.com/spf13/cobra" + tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// TestUploadsCmds_NoArgShowsHelp verifies the cleanup commands render full help +// (not a terse "accepts 1 arg(s)") and exit cleanly when the bucket is omitted. +func TestUploadsCmds_NoArgShowsHelp(t *testing.T) { + for _, tc := range []struct { + name string + make func(cmdutil.Factory, cmdutil.IOStreams) *cobra.Command + }{ + {"ls-uploads", NewCmdLsUploads}, + {"abort-uploads", NewCmdAbortUploads}, + } { + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := tc.make(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{}) + cmd.SetContext(context.Background()) + cmd.SetOut(out) + if err := cmd.Execute(); err != nil { + t.Errorf("%s: no-arg should show help, not error: %v", tc.name, err) + } + if !strings.Contains(out.String(), "Usage:") { + t.Errorf("%s: expected help with Usage:, got:\n%s", tc.name, out.String()) + } + } +} + +// uploadsFakeAPI serves ListMultipartUploads / ListParts and records +// AbortMultipartUpload calls for the cleanup-command tests. +type uploadsFakeAPI struct { + API + + listUploadsPages []*s3.ListMultipartUploadsOutput + listUploadsCalls int + listUploadsIns []*s3.ListMultipartUploadsInput + + // partsByUpload maps an UploadId to its parts (for ListParts size sums). + partsByUpload map[string][]s3types.Part + + abortIns []*s3.AbortMultipartUploadInput + + listUploadsErr error +} + +func (u *uploadsFakeAPI) ListMultipartUploads(ctx context.Context, in *s3.ListMultipartUploadsInput, opts ...func(*s3.Options)) (*s3.ListMultipartUploadsOutput, error) { + if u.listUploadsErr != nil { + return nil, u.listUploadsErr + } + u.listUploadsIns = append(u.listUploadsIns, in) + if u.listUploadsCalls >= len(u.listUploadsPages) { + return &s3.ListMultipartUploadsOutput{IsTruncated: aws.Bool(false)}, nil + } + page := u.listUploadsPages[u.listUploadsCalls] + u.listUploadsCalls++ + return page, nil +} + +func (u *uploadsFakeAPI) ListParts(ctx context.Context, in *s3.ListPartsInput, opts ...func(*s3.Options)) (*s3.ListPartsOutput, error) { + id := aws.ToString(in.UploadId) + parts := u.partsByUpload[id] + return &s3.ListPartsOutput{Parts: parts, IsTruncated: aws.Bool(false)}, nil +} + +func (u *uploadsFakeAPI) AbortMultipartUpload(ctx context.Context, in *s3.AbortMultipartUploadInput, opts ...func(*s3.Options)) (*s3.AbortMultipartUploadOutput, error) { + u.abortIns = append(u.abortIns, in) + return &s3.AbortMultipartUploadOutput{}, nil +} + +func uploadPage(uploads ...s3types.MultipartUpload) *s3.ListMultipartUploadsOutput { + return &s3.ListMultipartUploadsOutput{Uploads: uploads, IsTruncated: aws.Bool(false)} +} + +func mpu(key, id string, initiated time.Time) s3types.MultipartUpload { + return s3types.MultipartUpload{Key: aws.String(key), UploadId: aws.String(id), Initiated: aws.Time(initiated)} +} + +func TestLsUploads_ListsWithSize(t *testing.T) { + // no t.Parallel — clientBuilder mutation + now := time.Now() + fake := &uploadsFakeAPI{ + listUploadsPages: []*s3.ListMultipartUploadsOutput{ + uploadPage( + mpu("a.bin", "u-a", now.Add(-time.Hour)), + mpu("b.bin", "u-b", now.Add(-2*time.Hour)), + ), + }, + partsByUpload: map[string][]s3types.Part{ + "u-a": {{PartNumber: aws.Int32(1), Size: aws.Int64(100)}, {PartNumber: aws.Int32(2), Size: aws.Int64(50)}}, + "u-b": {{PartNumber: aws.Int32(1), Size: aws.Int64(7)}}, + }, + } + restore := withFakeClient(fake) + defer restore() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdLsUploads(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"s3://my-bucket"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + stdout := out.String() + for _, want := range []string{"a.bin", "u-a", "b.bin", "2 in-progress upload(s)"} { + if !strings.Contains(stdout, want) { + t.Errorf("stdout missing %q:\n%s", want, stdout) + } + } +} + +func TestLsUploads_JSON(t *testing.T) { + // no t.Parallel + now := time.Now() + fake := &uploadsFakeAPI{ + listUploadsPages: []*s3.ListMultipartUploadsOutput{ + uploadPage(mpu("a.bin", "u-a", now)), + }, + partsByUpload: map[string][]s3types.Part{ + "u-a": {{PartNumber: aws.Int32(1), Size: aws.Int64(123)}}, + }, + } + restore := withFakeClient(fake) + defer restore() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + f.OutputFormatOverride = "json" + cmd := NewCmdLsUploads(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"s3://my-bucket"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + stdout := out.String() + for _, want := range []string{`"upload_id": "u-a"`, `"size": 123`, `"key": "a.bin"`} { + if !strings.Contains(stdout, want) { + t.Errorf("json missing %q:\n%s", want, stdout) + } + } +} + +func TestLsUploads_Empty(t *testing.T) { + // no t.Parallel + fake := &uploadsFakeAPI{} + restore := withFakeClient(fake) + defer restore() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdLsUploads(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"s3://my-bucket"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if !strings.Contains(out.String(), "No in-progress multipart uploads") { + t.Errorf("expected empty message:\n%s", out.String()) + } +} + +// TestLsUploads_TruncatedEmptyMarkerNoLoop guards against the IsTruncated-with- +// empty-marker pagination loop: the command must stop and mark truncated. +func TestLsUploads_TruncatedEmptyMarkerNoLoop(t *testing.T) { + // no t.Parallel + fake := &uploadsFakeAPI{ + listUploadsPages: []*s3.ListMultipartUploadsOutput{ + { + Uploads: []s3types.MultipartUpload{mpu("a.bin", "u-a", time.Now())}, + IsTruncated: aws.Bool(true), // truncated... + // ...but no NextKeyMarker / NextUploadIdMarker -> must not loop. + }, + }, + partsByUpload: map[string][]s3types.Part{"u-a": {{Size: aws.Int64(1)}}}, + } + restore := withFakeClient(fake) + defer restore() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdLsUploads(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"s3://my-bucket"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if fake.listUploadsCalls != 1 { + t.Errorf("ListMultipartUploads calls = %d, want 1 (must not loop on empty marker)", fake.listUploadsCalls) + } +} + +func TestAbortUploads_OlderThanFilter(t *testing.T) { + // no t.Parallel + now := time.Now() + fake := &uploadsFakeAPI{ + listUploadsPages: []*s3.ListMultipartUploadsOutput{ + uploadPage( + mpu("old.bin", "u-old", now.Add(-10*24*time.Hour)), // 10 days old -> abort + mpu("new.bin", "u-new", now.Add(-1*time.Hour)), // 1 hour old -> keep + ), + }, + partsByUpload: map[string][]s3types.Part{}, + } + restore := withFakeClient(fake) + defer restore() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdAbortUploads(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"s3://my-bucket", "--older-than", "7d", "--yes"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fake.abortIns) != 1 { + t.Fatalf("Abort calls = %d, want 1 (only the 10-day-old upload)", len(fake.abortIns)) + } + if got := aws.ToString(fake.abortIns[0].UploadId); got != "u-old" { + t.Errorf("aborted UploadId = %q, want u-old", got) + } +} + +func TestAbortUploads_KeyFilter(t *testing.T) { + // no t.Parallel + now := time.Now() + fake := &uploadsFakeAPI{ + listUploadsPages: []*s3.ListMultipartUploadsOutput{ + uploadPage( + mpu("a.bin", "u-a", now), + mpu("b.bin", "u-b", now), + ), + }, + partsByUpload: map[string][]s3types.Part{}, + } + restore := withFakeClient(fake) + defer restore() + + out := &bytes.Buffer{} + f := cmdutil.NewTestFactory(nil) + cmd := NewCmdAbortUploads(f, cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"s3://my-bucket", "--key", "b.bin", "--yes"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fake.abortIns) != 1 || aws.ToString(fake.abortIns[0].UploadId) != "u-b" { + ids := make([]string, 0, len(fake.abortIns)) + for _, in := range fake.abortIns { + ids = append(ids, aws.ToString(in.UploadId)) + } + sort.Strings(ids) + t.Fatalf("aborted = %v, want [u-b]", ids) + } +} + +func TestAbortUploads_AgentModeNeedsYes(t *testing.T) { + // no t.Parallel + fake := &uploadsFakeAPI{} + restore := withFakeClient(fake) + defer restore() + + f := cmdutil.NewTestFactory(nil) + f.AgentModeOverride = true + cmd := NewCmdAbortUploads(f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}}) + cmd.SetArgs([]string{"s3://my-bucket"}) + cmd.SetContext(context.Background()) + cmd.SilenceUsage = true + cmd.SilenceErrors = true + + err := cmd.Execute() + if err == nil { + t.Fatal("expected CONFIRMATION_REQUIRED in agent mode without --yes") + } + var ae *cmdutil.AgentError + if !errors.As(err, &ae) { + t.Fatalf("expected *AgentError, got %T: %v", err, err) + } + if ae.Code != "CONFIRMATION_REQUIRED" { + t.Errorf("Code = %q, want CONFIRMATION_REQUIRED", ae.Code) + } + if len(fake.abortIns) != 0 { + t.Errorf("Abort should not be called when confirmation required, got %d", len(fake.abortIns)) + } +} + +// TestAbortUploads_InteractiveConfirm_Yes drives the TTY confirmation path. +func TestAbortUploads_InteractiveConfirm_Yes(t *testing.T) { + // no t.Parallel + now := time.Now() + fake := &uploadsFakeAPI{ + listUploadsPages: []*s3.ListMultipartUploadsOutput{ + uploadPage(mpu("a.bin", "u-a", now)), + }, + partsByUpload: map[string][]s3types.Part{}, + } + restore := withFakeClient(fake) + defer restore() + + mock := tuitest.New().AddConfirm(true) + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + f := cmdutil.NewTestFactory(mock) + cmd := NewCmdAbortUploads(f, cmdutil.IOStreams{Out: out, ErrOut: errOut}) + cmd.SetArgs([]string{"s3://my-bucket"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if len(fake.abortIns) != 1 { + t.Fatalf("Abort calls = %d, want 1 (confirmed)", len(fake.abortIns)) + } + if !strings.Contains(errOut.String(), "abort 1 in-progress upload(s)") { + t.Errorf("expected destructive warning on stderr:\n%s", errOut.String()) + } +} + +// TestAbortUploads_InteractiveConfirm_No verifies declining aborts nothing. +func TestAbortUploads_InteractiveConfirm_No(t *testing.T) { + // no t.Parallel + now := time.Now() + fake := &uploadsFakeAPI{ + listUploadsPages: []*s3.ListMultipartUploadsOutput{ + uploadPage(mpu("a.bin", "u-a", now)), + }, + partsByUpload: map[string][]s3types.Part{}, + } + restore := withFakeClient(fake) + defer restore() + + mock := tuitest.New().AddConfirm(false) + out := &bytes.Buffer{} + errOut := &bytes.Buffer{} + f := cmdutil.NewTestFactory(mock) + cmd := NewCmdAbortUploads(f, cmdutil.IOStreams{Out: out, ErrOut: errOut}) + cmd.SetArgs([]string{"s3://my-bucket"}) + cmd.SetContext(context.Background()) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute should not error on declined confirmation: %v", err) + } + if len(fake.abortIns) != 0 { + t.Errorf("Abort should not be called when declined, got %d", len(fake.abortIns)) + } + if !strings.Contains(errOut.String(), "Canceled") { + t.Errorf("expected 'Canceled.' on stderr:\n%s", errOut.String()) + } +} + +func TestParseByteSize(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want int64 + wantErr bool + }{ + {"", 0, false}, + {"32MiB", 32 << 20, false}, + {"8M", 8 << 20, false}, + {"5mib", 5 << 20, false}, + {"1073741824", 1073741824, false}, + {"1GiB", 1 << 30, false}, + {"bad", 0, true}, + {"-5MiB", 0, true}, + } + for _, tc := range cases { + got, err := parseByteSize(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("parseByteSize(%q) = %d, want error", tc.in, got) + } + continue + } + if err != nil { + t.Errorf("parseByteSize(%q) error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("parseByteSize(%q) = %d, want %d", tc.in, got, tc.want) + } + } +} + +func TestParseOlderThan(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want time.Duration + wantErr bool + }{ + {"", 0, false}, + {"7d", 7 * 24 * time.Hour, false}, + {"12h", 12 * time.Hour, false}, + {"30m", 30 * time.Minute, false}, + {"1.5d", 36 * time.Hour, false}, + {"bad", 0, true}, + } + for _, tc := range cases { + got, err := parseOlderThan(tc.in) + if tc.wantErr { + if err == nil { + t.Errorf("parseOlderThan(%q) = %v, want error", tc.in, got) + } + continue + } + if err != nil { + t.Errorf("parseOlderThan(%q) error: %v", tc.in, err) + continue + } + if got != tc.want { + t.Errorf("parseOlderThan(%q) = %v, want %v", tc.in, got, tc.want) + } + } +} diff --git a/internal/verda-cli/cmd/serverless/CLAUDE.md b/internal/verda-cli/cmd/serverless/CLAUDE.md new file mode 100644 index 0000000..a9f2439 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/CLAUDE.md @@ -0,0 +1,143 @@ +# Serverless Command Knowledge + +> Go house style lives in the root `CLAUDE.md` § "Go House Style". This file carries serverless-specific idioms only — see below for the two-SDK-service split, scaling-preset math, the "spot = container-only" invariant, and the fixed-storage contract that do not apply elsewhere. + +## ⚠️ Wire-Format Tests Must Stay in Sync (HARD RULE) + +**Any change to the create-request payload must come with a matching update to `wire_format_test.go`.** That file is the only test layer that catches bugs the SDK's `ValidateCreate*DeploymentRequest` does not (e.g. the `type:"shared"` → `volume_id` 400 we hit in production). `opts.request()` unit tests check field assembly in Go; the wire-format tests check the JSON the API actually receives. + +What "change to the create-request payload" covers: +- Adding/removing/renaming any field on `containerCreateOptions` or `batchjobCreateOptions` that flows into `request()`. +- Changing `buildVolumeMounts`, `buildEnvVars`, `buildContainerScaling`, or any helper that contributes to the request body. +- Changing mount-type constants in `shared.go`, the SDK type tags, or the API path. +- Adding new flags whose values appear in the request. + +What to do when you change the flow: +1. Update `wire_format_test.go` so the assertions describe the *new* expected JSON, not the old one. Don't relax an assertion just to make it pass — that defeats the test. +2. If you added a flag, add a focused wire-format case for the non-default value (like `TestContainerCreate_SecretMountWireFormat` does for `--secret-mount`). +3. Run `make test`. If wire-format tests fail and you can't explain *why* the new JSON is correct, the production API will reject the request — go back and fix the flow before touching the test. + +This rule applies equally to Claude, Codex, Cursor, or a human editing the package. Reviewers should reject any PR that changes the create flow without also changing the wire-format tests. + +## Quick Reference + +- **Two top-level commands**, both registered in `cmd/cmd.go` under the "Serverless Commands" group: + - `verda container` → `/container-deployments` (continuous endpoints, supports spot) + - `verda batchjob` → `/job-deployments` (one-shot jobs, deadline-based, **no spot**) +- There is **no** `verda serverless` parent command — the two trees were promoted to root for shorter invocations. They still share this Go package because they share wizard step factories, validators, and the API cache. +- Verbs (both trees): `create`, `list` (alias `ls`), `describe` (aliases `get`, `show`), `delete` (aliases `rm`, `del`), `pause`, `resume`, `purge-queue`. Container also has `restart`. +- Files: + - `container.go`, `batchjob.go` — Top-level command builders. `NewCmdContainer` and `NewCmdBatchjob` are exported and called directly from `cmd/cmd.go`. **Pre-release, two-layer gated like registry:** the Serverless command group is only registered when `VERDA_SERVERLESS_ENABLED=1` (`serverlessEnabled()` in `cmd/cmd.go`), and both parents set `Hidden: true` so they stay out of `verda --help` even when a tester flips the env var on. Drop the env gate + the two `Hidden: true` flags when serverless ships GA. + - `container_create.go` — `containerCreateOptions`, flags, `request()`, validate(), wizard entry point. + - `container_list.go` — `GetDeployments` + tabwriter + structured output. + - `container_describe.go` — `GetDeploymentByName` + `GetDeploymentStatus` (best-effort) + `selectContainerDeployment` picker. + - `container_delete.go` — `DeleteDeployment(timeoutMs)` + destructive confirm. + - `container_actions.go` — Data-driven action factory `newContainerActionCmd` (pause/resume/restart/purge-queue). + - `batchjob_create.go` — `batchjobCreateOptions` (simpler: no spot, deadline required). + - `batchjob_list.go`, `batchjob_describe.go`, `batchjob_delete.go`, `batchjob_actions.go` — Same shape as container, trimmed. + - `shared.go` — `validateDeploymentName` (RFC-1123 subset), `rejectLatestTag`, `parseEnvFlag`, `parseSecretMountFlag`, `confirmDestructive`, `statusColor`, `mountType*` + `envType*` constants. + - `wizard_shared.go` — Step builders shared by both create wizards: `stepName`, `stepImage`, `stepCompute`, `stepComputeSize`, `stepRegistryCreds`, `stepPort`, `stepEnvVars`, `stepMaxReplicas`, `stepRequestTTL`, `stepSecretMounts`. Plus the generic `durationStep` helper and three int validators. Each takes a `*T` pointer to the field it mutates, so the same step definition drives both `containerCreateOptions` and `batchjobCreateOptions`. + - `wizard.go` — `buildContainerCreateFlow` + container-only steps: spot/compute-type, healthcheck on/off + path (port is NOT prompted; it always defaults to the exposed port in `request()`), min-replicas, concurrency, queue-load preset + custom override, CPU/GPU util triggers, scale-up/down delays. 21 total steps in the container flow. + - `wizard_batchjob.go` — `buildBatchjobCreateFlow` + `stepBatchjobDeadline`. 11 total steps: 10 reused from `wizard_shared.go` + the batchjob-only deadline. Jobs have no spot, no min replicas, no scaling triggers, no healthcheck, no concurrency — all of those steps are simply absent from the flow. + - `wizard_cache.go` — `apiCache` with lazy loaders for compute resources, registry creds, secrets, file secrets. Shared across wizard passes so back-navigation doesn't re-hit the API. Used by both container and batchjob wizards. + - `wizard_subflows.go` — `promptEnvVar`, `promptSecretMount` for the two loop-add steps. + - `wizard_summary.go` — `renderContainerSummary` + `renderBatchjobSummary`. Printed from `runContainerCreate` / `runBatchjobCreate` after the wizard returns, immediately before the final deploy confirm. **Not** a wizard step — runs outside the engine so the review card gets full terminal width. + - `*_test.go` alongside each file. + +## Domain-Specific Logic + +### Two SDK services, two subcommands + +The web UI's "Deployment type: Continuous | Job" radio maps to two separate SDK services and two separate HTTP paths: + +- `ContainerDeploymentsService` at `/container-deployments` — full `ContainerScalingOptions` (min/max replicas, ScalingTriggers with QueueLoad + CPU/GPU util, scale-up/down policies, request TTL, concurrent requests). Supports `IsSpot: true`. +- `ServerlessJobsService` at `/job-deployments` — thin `JobScalingOptions` (`MaxReplicaCount`, `QueueMessageTTLSeconds`, `DeadlineSeconds`). No min replicas, no triggers, no scale-up/down policies, no spot option. + +Consequence: **the CLI never re-asks** deployment type inside the wizard. Pick the subcommand, get the right API shape. + +### Scaling preset mapping (CRITICAL) + +`queue-preset` → `ScalingTriggers.QueueLoad.Threshold`: + +| Preset | Threshold | +|--------|-----------| +| `instant` | 1 | +| `balanced` (default) | 3 | +| `cost-saver` | 6 | +| `custom` | value of `--queue-load` (1..1000) | + +Setting `--queue-load N` alone (without `--queue-preset custom`) is also accepted and behaves as custom. The preset name is NOT persisted server-side — on describe, the CLI reverses the mapping for display (threshold 1/3/6 → the named preset, else "custom: N"). See `resolveQueueLoad` in `container_create.go`. + +Aliases accepted for the "cost-saver" preset: `cost_saver`, `costsaver` — underscore and camel-case forms show up in copy-pasted configs, so we normalize. + +### :latest tag rejection + +Both `container create` and `batchjob create` call `verda.IsLatestTag(image)` via `rejectLatestTag` before the API call. The SDK also rejects in `ValidateCreate*DeploymentRequest`, but we fail fast with a friendly error before spinner. Tests: `TestRejectLatestTag`, `TestContainerRequest_RejectsLatest`, `TestBatchjobRequest_RejectsLatest`. + +### Deployment name format + +`[a-z0-9]([-a-z0-9]*[a-z0-9])?`, max 63 chars (RFC-1123 subset, URL-safe). Becomes part of `https://containers.datacrunch.io/`. Immutable after create — the server refuses updates. `validateDeploymentName` enforces; tests cover edge cases (uppercase, underscore, leading/trailing hyphen, too long, empty). + +### Storage is server-allocated scratch + +Every create request includes exactly one `scratch` volume mount at `/data`. The CLI sends `{type: "scratch", mount_path: "/data"}` with no `size_in_mb` — the server allocates and sizes the scratch volume. `/dev/shm` is provided by the runtime; the CLI does not send a mount for it. + +There is **no** `--general-storage-size` or `--shm-size` flag. They were removed after the API rejected sized mounts with `volume_mounts.0.volume_id should not be null or undefined`: a sized mount must be `type: "shared"`, which references a named persistent volume by `volume_id` — a feature the CLI does not yet expose. + +Mount types in `ContainerVolumeMount.Type` that the CLI currently sends: + +- `"scratch"` — auto-allocated `/data`; no `SizeInMB`, no `VolumeID`. See `buildVolumeMounts` in `container_create.go`. +- `"secret"` — from `--secret-mount NAME:PATH`; `SecretName` set. + +`"shared"` (named persistent volume) is intentionally unused — it requires `volume_id`, which has no flag yet. If the CLI gains a `verda volume` integration for serverless deployments, this is the wiring point. + +### Batchjob cannot use spot + +`batchjobCreateOptions` has no `Spot` field and no `--spot` flag. `CreateJobDeploymentRequest` has no `IsSpot`. The user asked for this invariant up front — if the web UI ever adds spot to jobs, revisit both structs and the wizard at the same time. + +### Deadline is required for batchjob + +`JobScalingOptions.DeadlineSeconds` must be `> 0` — enforced in `batchjobCreateOptions.request()`, in the SDK via `ValidateCreateJobDeploymentRequest`, and listed in `missingBatchjobCreateFlags`. The batchjob wizard (when implemented — see Gotchas) must include a deadline prompt as a required field. + +### Action-command factory pattern + +`newContainerActionCmd` / `newBatchjobActionCmd` build a `*cobra.Command` from `(verb, short, spinner, successMsg, destructive, fn)`. This avoids five nearly-identical files per subcommand. If you need to add an action (e.g. a future `scaling get`), add a new call site with the right SDK method. If you need per-action flags beyond `--yes`, you'll have to step out of the factory — acceptable if one action grows special, not if two do. + +### Destructive confirms + +`restart` and `purge-queue` are marked destructive (they break in-flight requests); `pause` and `resume` are not. In agent mode, destructive actions require `--yes` and return `CONFIRMATION_REQUIRED` otherwise. Non-agent TTY uses `confirmDestructive` from `shared.go` (red warning + "cannot be undone" line + `prompter.Confirm`). + +### Status color and card rendering + +`statusColor(status)` in `shared.go` heuristically picks green (running/active/healthy), red (error/failed), dim (paused/stopped/offline), yellow (transitional) by substring match. There's no SDK enum for deployment status — the server returns free-form strings today. Keep the matcher lenient. + +Describe cards (`renderContainerDeploymentCard`, `renderJobDeploymentCard`) print one `Label value` line per section, using color-6 bold for labels. Env var VALUES are intentionally not printed — only names — since values may contain secrets. + +## Gotchas & Edge Cases + +- **Wizard omits the healthcheck-path step when healthcheck is Off.** The `healthcheck-path` step has `ShouldSkip: c["healthcheck"] == "off"`; the engine wires the skip gate via `DependsOn`. There is intentionally no `healthcheck-port` wizard step — `request()` always defaults the probe port to the exposed port (`hcPort = o.Port` when `HealthcheckPort == 0`). The `--healthcheck-port` flag still works for the rare case where the probe must hit a different port than the public listener. +- **`registryPublicValue = "__public__"` sentinel.** The registry-creds step's loader prepends a "Public (no credentials)" choice with this sentinel as its Value. The Setter maps the sentinel back to `opts.RegistryCreds = ""`. If you rename the sentinel, grep both sides — the Setter reads the string literal. +- **`compute-size` is a separate step from `compute`.** VM's wizard combines resource + count in a single step via in-Loader prompting; serverless keeps them separate so users can go back and change the size without re-picking the resource. Lower engineering cost, same UX. +- **Util triggers off by default, but wizard asks anyway.** The CPU/GPU util steps accept empty ("off"), "off", or `1..100`. Setter maps empty/"off" to 0 (trigger disabled). Users should be able to Enter-through both without setting them. +- **Custom queue-load is a separate step.** `queue-load-custom` has `ShouldSkip: c["queue-preset"] != "custom"`. If the user goes back and changes preset to a named one, the engine's reset logic clears the custom value via `Resetter`. +- **No `+ Create new` for registry creds in the wizard.** v1 intentionally omits the inline create-new sub-flow for registry credentials — users pick existing or Public. Adding new creds requires `verda registry configure` out-of-band, or a future top-level `verda registry-creds` command. The design doc notes this as future work. +- **Confirm is NOT a wizard step.** `runContainerCreate` prints the summary + runs `prompter.Confirm` after `engine.Run` returns. Keeps the review card at full terminal width and lets us pipe through `--yes` cleanly. If you move it into the wizard, you lose layout control. +- **Agent mode + create = flag-only.** In `--agent`, if any of `--name/--image/--compute` is missing we return `MISSING_REQUIRED_FLAGS` immediately. The wizard is never launched under `--agent`, even without credentials — that would be an interactive prompt, which is blocked. +- **Batchjob wizard shares 10 steps with container.** The split lives in `wizard_shared.go` (shared factories taking `*T` pointers) vs `wizard.go` (container-only) vs `wizard_batchjob.go` (batchjob-only). Adding a new shared field: put the step factory in `wizard_shared.go` and wire it into both flows. Adding a field that only one subcommand needs: put it directly in `wizard.go` or `wizard_batchjob.go`. +- **Scaling preset + legacy rows on describe.** When a deployment was created via the web UI with a custom queue-load (say 10), our CLI shows "custom: 10" rather than a named preset. Don't try to round-trip it back to a named preset — exact threshold wins. +- **`ContainerDeployment.Status` is NOT in the `GetDeployments` list response.** The list endpoint returns `ContainerDeployment` without status. We call `GetDeploymentStatus(name)` per-row in `describe`, but NOT in `list` (would N+1). If the web UI grows a bulk status endpoint, wire it in. +- **Env-var name validation:** `^[A-Z_][A-Z0-9_]*$`. Lowercase or leading-digit names are rejected client-side in both `parseEnvFlag` and `promptEnvVar`. This is stricter than POSIX (which allows lowercase) but matches Verda's conventions. +- **Secret mount path must be absolute.** `parseSecretMountFlag` and `promptSecretMount` both check `strings.HasPrefix(path, "/")`. No trailing-slash normalization is applied. +- **`DeleteDeployment` timeout semantics:** `--timeout-ms` flag maps directly to the SDK's `timeoutMs` parameter. `-1` (default) uses the API default of 60s; `0` returns immediately; `>0` waits up to that many ms (capped at 300000 server-side). + +## Relationships + +- `cmdutil` (`internal/verda-cli/cmd/util`) — `Factory`, `IOStreams`, `WithSpinner`, `RunWithSpinner`, `DebugJSON`, `WriteStructured`, `NewMissingFlagsError`, `NewConfirmationRequiredError`, `UsageErrorf`, `LongDesc`, `Examples`, `DefaultSubCommandRun`. +- `verdagostack/pkg/tui/wizard` — `Flow`, `Step`, `Choice`, `Store`, `Engine`, `NewEngine`, `StaticChoices`, `WithOutput`, `WithExitConfirmation`, prompt-type enums. +- `verdagostack/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`. + - `ServerlessJobsService` — `GetJobDeployments`, `CreateJobDeployment`, `GetJobDeploymentByName`, `DeleteJobDeployment`, `GetJobDeploymentStatus`, `PauseJobDeployment`, `ResumeJobDeployment`, `PurgeJobDeploymentQueue`, `ValidateCreateJobDeploymentRequest`. + - Types: `ContainerDeployment`, `JobDeployment`, `JobDeploymentShortInfo`, `CreateDeploymentRequest`, `CreateJobDeploymentRequest`, `ContainerScalingOptions`, `JobScalingOptions`, `ScalingTriggers`, `QueueLoadTrigger`, `UtilizationTrigger`, `ScalingPolicy`, `ContainerCompute`, `ContainerRegistrySettings`, `RegistryCredentialsRef`, `ContainerEnvVar`, `ContainerVolumeMount`, `ContainerHealthcheck`, `ContainerEntrypointOverrides`, `ComputeResource`, `Secret`, `FileSecret`, `RegistryCredentials`. +- `charm.land/lipgloss/v2` — status color styles + describe-card labels. +- `github.com/spf13/cobra` — command plumbing. diff --git a/internal/verda-cli/cmd/serverless/README.md b/internal/verda-cli/cmd/serverless/README.md new file mode 100644 index 0000000..3a61764 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/README.md @@ -0,0 +1,168 @@ +# `verda container` / `verda batchjob` + +Manage serverless container deployments (always-on endpoints) and batch-job deployments (one-shot runs) on Verda Cloud. + +``` +verda container # → /container-deployments (continuous; supports spot) +verda batchjob # → /job-deployments (one-shot; deadline-based; no spot) +``` + +Both command trees live in the `serverless` Go package (they share wizard step factories, validators, and the API cache), but are registered as top-level commands so users type `verda container ...` rather than `verda serverless container ...`. + +## Container deployments + +### Create + +Interactive wizard (launches when any of `--name`/`--image`/`--compute` is missing): + +```bash +verda container create +``` + +Non-interactive: + +```bash +verda container create \ + --name my-endpoint \ + --image ghcr.io/ai-dock/comfyui:cpu-22.04 \ + --compute RTX4500Ada --compute-size 1 +``` + +With private registry + env + custom scaling: + +```bash +verda container create \ + --name my-api --image ghcr.io/me/llm:v1.2 \ + --compute RTX4500Ada --compute-size 1 \ + --registry-creds my-ghcr \ + --env HF_HOME=/data/.huggingface \ + --env-secret API_TOKEN=prod-token \ + --min-replicas 1 --max-replicas 10 \ + --queue-preset cost-saver \ + --scale-down-delay 600s +``` + +**Required flags** (agent mode): `--name`, `--image`, `--compute`. Interactive mode launches the wizard if any are missing. + +**Images must use a specific tag.** `:latest` (explicit or implicit) is rejected before the API call. + +**Deployment names** are URL slugs (`[a-z0-9]([-a-z0-9]*[a-z0-9])?`, max 63 chars). They become part of `https://containers.datacrunch.io/` and are **immutable** after create. + +### Scaling presets + +`--queue-preset` maps to a queue-load threshold written into `ScalingTriggers.QueueLoad`: + +| Preset | Queue load | When to use | +|--------|-----------|-------------| +| `instant` | 1 | Scale up on any waiting request. Minimizes time in queue. | +| `balanced` (default) | 3 | Short queue wait before scaling up. Good for most APIs. | +| `cost-saver` | 6 | Fewer replicas; requests may wait longer in queue. | +| `custom` | `--queue-load ` | Specify a threshold yourself (1..1000). | + +`--queue-load ` without an explicit `--queue-preset` is treated as custom. + +### Other scaling flags + +- `--min-replicas` (default `0`, scale-to-zero) / `--max-replicas` (default `3`) +- `--concurrency` (default `1` — set higher for LLMs, 1 for image generation) +- `--cpu-util `, `--gpu-util ` — enable the corresponding trigger (blank = off) +- `--scale-up-delay`, `--scale-down-delay` (default `5m`) — hysteresis before scaling +- `--request-ttl` (default `5m`) — how long a pending request may live before the queue drops it + +### Healthcheck + +- `--healthcheck-off` disables probing — requests route immediately +- `--healthcheck-port` (default = exposed port) +- `--healthcheck-path` (default `/health`) + +### Storage + +- `--secret-mount SECRET:/path` (repeatable) — mount a project secret as a file +- Every deployment gets a `scratch` mount at `/data` automatically; the server allocates and sizes it. `/dev/shm` is provided by the runtime. There are no flags to resize either — the API does not yet accept client-provided sizes. + +### Lifecycle + +```bash +verda container list +verda container describe my-endpoint +verda container pause my-endpoint # stop serving requests +verda container resume my-endpoint +verda container restart my-endpoint # destructive; requires --yes in agent mode +verda container purge-queue my-endpoint # destructive; requires --yes in agent mode +verda container delete my-endpoint # destructive; requires --yes in agent mode +``` + +`list`, `describe`, `delete`, `pause`, `resume`, `restart`, `purge-queue` all support: + +- `-o json|yaml` for structured output +- No positional arg → interactive picker (non-agent only) +- Positional `` works in agent mode + +## Batch-job deployments + +### Create + +Interactive wizard (launches when any of `--name`/`--image`/`--compute`/`--deadline` is missing): + +```bash +verda batchjob create +``` + +Non-interactive: + +```bash +verda batchjob create \ + --name nightly-embed \ + --image ghcr.io/me/embedder:v1 \ + --compute RTX4500Ada --compute-size 1 \ + --deadline 30m +``` + +**Required flags** (agent mode): `--name`, `--image`, `--compute`, `--deadline`. + +**Batch jobs cannot use spot compute.** There is no `--spot` flag; the underlying API has no `IsSpot` field for jobs. This is intentional. + +**Deadline is required** and must be `> 0s`. Each queued request gets up to `--deadline` to complete; missing or zero deadline fails validation client-side and server-side. + +### Other scaling flags + +- `--max-replicas` (default `3`) — worker pool cap +- `--request-ttl` (default `5m`) — how long a pending request may live in the queue before the server drops it + +### Lifecycle + +Identical shape to container, minus `restart` (not supported by the job-deployment API): + +```bash +verda batchjob list +verda batchjob describe nightly-embed +verda batchjob pause nightly-embed +verda batchjob resume nightly-embed +verda batchjob purge-queue nightly-embed # destructive +verda batchjob delete nightly-embed # destructive +``` + +## Agent mode + +Every destructive verb (`delete`, `restart`, `purge-queue`) requires `--yes` in agent mode — otherwise the command returns `CONFIRMATION_REQUIRED` with exit code 2. Structured JSON envelopes on stderr for errors; JSON result documents on stdout for successful operations. No prompts, ever. + +```bash +verda --agent container create \ + --name api --image ghcr.io/org/app:v1 \ + --compute RTX4500Ada --compute-size 1 -o json + +verda --agent container delete api --yes -o json +``` + +## Environment variables + +- `--env KEY=VALUE` (repeatable) — plain env var +- `--env-secret KEY=SECRET_NAME` (repeatable) — env resolved from a project secret at runtime + +Env names must match `^[A-Z_][A-Z0-9_]*$` (uppercase alphanumerics + underscore, no leading digit). Lowercase or leading-digit names are rejected client-side. + +## See also + +- `docs/plans/2026-04-24-serverless-container-design.md` — full design: wizard flow, SDK mapping, validation rules, v1-omissions list +- `CLAUDE.md` in this directory — domain knowledge + gotchas for future Claude sessions +- `verda registry` — manage registry credentials that `--registry-creds` references diff --git a/internal/verda-cli/cmd/serverless/batchjob.go b/internal/verda-cli/cmd/serverless/batchjob.go new file mode 100644 index 0000000..945b788 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob.go @@ -0,0 +1,50 @@ +// 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 serverless + +import ( + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// NewCmdBatchjob creates the top-level `verda batchjob` command tree. +func NewCmdBatchjob(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "batchjob", + Short: "Manage serverless batch-job deployments (one-shot runs)", + // Pre-release: hidden from `verda --help`. See NewCmdContainer for the + // gating rationale; both drop when serverless ships GA. + Hidden: true, + Long: cmdutil.LongDesc(` + Create and manage one-shot batch-job deployments. Jobs accept queued + requests, run each to completion within a deadline, and scale the + worker pool up to a maximum replica count. Batch jobs cannot use + spot compute. + `), + Run: cmdutil.DefaultSubCommandRun(ioStreams.Out), + } + + cmd.AddCommand( + newCmdBatchjobCreate(f, ioStreams), + newCmdBatchjobList(f, ioStreams), + newCmdBatchjobDescribe(f, ioStreams), + newCmdBatchjobDelete(f, ioStreams), + newCmdBatchjobPause(f, ioStreams), + newCmdBatchjobResume(f, ioStreams), + newCmdBatchjobPurgeQueue(f, ioStreams), + ) + return cmd +} diff --git a/internal/verda-cli/cmd/serverless/batchjob_actions.go b/internal/verda-cli/cmd/serverless/batchjob_actions.go new file mode 100644 index 0000000..37b28d5 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_actions.go @@ -0,0 +1,121 @@ +// 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 serverless + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +type batchjobActionFn func(ctx context.Context, client *verda.Client, name string) error + +// newBatchjobActionCmd mirrors newContainerActionCmd for batch-job deployments. +// detailMsg is optional fmt template with one %q for the deployment name (destructive confirms only). +func newBatchjobActionCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, verb, short, spinner, successMsg, detailMsg string, destructive bool, fn batchjobActionFn) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: verb + " ", + Short: short, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name, err := resolveBatchjobName(cmd, f, ioStreams, args) + if err != nil || name == "" { + return err + } + return runBatchjobAction(cmd, f, ioStreams, name, verb, spinner, successMsg, detailMsg, destructive, yes, fn) + }, + } + if destructive { + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation (required in agent mode)") + } + return cmd +} + +func runBatchjobAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, name, verb, spinner, successMsg, detailMsg string, destructive, yes bool, fn batchjobActionFn) error { + client, err := f.VerdaClient() + if err != nil { + return err + } + + if destructive && f.AgentMode() && !yes { + return cmdutil.NewConfirmationRequiredError(verb) + } + if destructive && !yes { + confirmed, err := confirmDestructive(cmd.Context(), ioStreams, f.Prompter(), + verb+" batch-job deployment", + fmt.Sprintf(detailMsg, name), + fmt.Sprintf("%s %s?", verb, name), + ) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + } + + 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 { + return fn(ctx, client, name) + }) + if err != nil { + return err + } + + if f.AgentMode() { + _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), map[string]string{ + "name": name, "action": verb, "status": "completed", + }) + return nil + } + _, _ = fmt.Fprintf(ioStreams.Out, successMsg+"\n", name) + return nil +} + +func newCmdBatchjobPause(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + return newBatchjobActionCmd(f, ioStreams, + "pause", "Pause a batch-job deployment", "Pausing", "Paused deployment %q", "", false, + func(ctx context.Context, c *verda.Client, name string) error { + return c.ServerlessJobs.PauseJobDeployment(ctx, name) + }, + ) +} + +func newCmdBatchjobResume(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + return newBatchjobActionCmd(f, ioStreams, + "resume", "Resume a paused batch-job deployment", "Resuming", "Resumed deployment %q", "", false, + func(ctx context.Context, c *verda.Client, name string) error { + return c.ServerlessJobs.ResumeJobDeployment(ctx, name) + }, + ) +} + +func newCmdBatchjobPurgeQueue(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + return newBatchjobActionCmd(f, ioStreams, + "purge-queue", "Purge the pending-request queue for a batch-job deployment", "Purging queue for", "Purged queue for deployment %q", + "Queue for deployment %q will be purged.", true, + func(ctx context.Context, c *verda.Client, name string) error { + return c.ServerlessJobs.PurgeJobDeploymentQueue(ctx, name) + }, + ) +} diff --git a/internal/verda-cli/cmd/serverless/batchjob_create.go b/internal/verda-cli/cmd/serverless/batchjob_create.go new file mode 100644 index 0000000..423dbb3 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_create.go @@ -0,0 +1,257 @@ +// 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 serverless + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// batchjobCreateOptions: like containerCreateOptions minus spot/scaling knobs; deadline required. +type batchjobCreateOptions struct { + Name string + Image string + + Compute string + ComputeSize int + + RegistryCreds string + + Port int + Env []string + EnvSecret []string + Entrypoint []string + Cmd []string + + MaxReplicas int + Deadline time.Duration + RequestTTL time.Duration + + SecretMounts []string + + Yes bool +} + +func newCmdBatchjobCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + opts := &batchjobCreateOptions{ + Port: defaultExposedPort, + MaxReplicas: defaultMaxReplicas, + RequestTTL: defaultRequestTTL, + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a serverless batch-job deployment", + Long: cmdutil.LongDesc(` + Create a serverless batch-job deployment. Jobs accept queued + requests and run each to completion within a deadline. Batch jobs + cannot use spot compute; --deadline is required. + `), + Example: cmdutil.Examples(` + verda batchjob create \ + --name nightly-embed \ + --image ghcr.io/me/embedder:v1 \ + --compute RTX4500Ada --compute-size 1 \ + --deadline 30m + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runBatchjobCreate(cmd, f, ioStreams, opts) + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.Name, "name", "", "Deployment name (URL slug; immutable after create)") + flags.StringVar(&opts.Image, "image", "", "Container image reference (must not be ':latest')") + flags.StringVar(&opts.Compute, "compute", "", "Compute resource name (e.g. RTX4500Ada, CPUNode)") + flags.IntVar(&opts.ComputeSize, "compute-size", 1, "Number of GPUs or vCPU cores per replica") + flags.StringVar(&opts.RegistryCreds, "registry-creds", "", "Registry credentials name (empty = public)") + + flags.IntVar(&opts.Port, "port", opts.Port, "Exposed HTTP port") + flags.StringArrayVar(&opts.Env, "env", nil, "Environment variable KEY=VALUE (KEY uppercase); repeat for multiple") + flags.StringArrayVar(&opts.EnvSecret, "env-secret", nil, "Secret-backed env KEY=SECRET_NAME (KEY uppercase); repeat for multiple") + flags.StringArrayVar(&opts.Entrypoint, "entrypoint", nil, "Override image ENTRYPOINT; repeat for multiple args") + flags.StringArrayVar(&opts.Cmd, "cmd", nil, "Override image CMD; repeat for multiple args") + + flags.IntVar(&opts.MaxReplicas, "max-replicas", opts.MaxReplicas, "Maximum worker replica count") + flags.DurationVar(&opts.Deadline, "deadline", 0, "Per-request deadline (required; > 0)") + flags.DurationVar(&opts.RequestTTL, "request-ttl", opts.RequestTTL, "How long a pending request may live before deletion") + + flags.StringArrayVar(&opts.SecretMounts, "secret-mount", nil, "Secret mount SECRET:MOUNT_PATH; repeat for multiple") + + flags.BoolVarP(&opts.Yes, "yes", "y", false, "Skip confirmation (required in agent mode)") + + return cmd +} + +func runBatchjobCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *batchjobCreateOptions) error { + // Same agent-mode ordering as container create (flags before client). + if f.AgentMode() { + if missing := missingBatchjobCreateFlags(opts); len(missing) > 0 { + return cmdutil.NewMissingFlagsError(missing) + } + } else if opts.Name == "" || opts.Image == "" || opts.Compute == "" || opts.Deadline <= 0 { + if err := runBatchjobWizard(cmd.Context(), f, ioStreams, opts); err != nil { + return err + } + } + + client, err := f.VerdaClient() + if err != nil { + return err + } + + req, err := opts.request() + if err != nil { + return cmdutil.UsageErrorf(cmd, "%v", err) + } + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Request payload:", req) + + if !f.AgentMode() && !opts.Yes { + renderBatchjobSummary(ioStreams.ErrOut, opts) + confirmed, err := f.Prompter().Confirm(cmd.Context(), fmt.Sprintf("Deploy %s?", opts.Name)) + if err != nil && !isPromptCancel(err) { + return err + } + if err != nil || !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + } + + 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) { + return client.ServerlessJobs.CreateJobDeployment(ctx, req) + }) + if err != nil { + return err + } + + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), deployment); wrote { + return werr + } + + _, _ = fmt.Fprintf(ioStreams.Out, "Created batch-job deployment %q\n", deployment.Name) + _, _ = fmt.Fprintf(ioStreams.Out, "Endpoint: %s\n", deployment.EndpointBaseURL) + return nil +} + +// runBatchjobWizard fills gaps; shares most steps with container create (+deadline). +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()) + return engine.Run(ctx, flow) +} + +func missingBatchjobCreateFlags(opts *batchjobCreateOptions) []string { + var missing []string + if opts.Name == "" { + missing = append(missing, "--name") + } + if opts.Image == "" { + missing = append(missing, "--image") + } + if opts.Compute == "" { + missing = append(missing, "--compute") + } + if opts.Deadline <= 0 { + missing = append(missing, "--deadline") + } + return missing +} + +func (o *batchjobCreateOptions) request() (*verda.CreateJobDeploymentRequest, error) { + if err := validateDeploymentName(o.Name); err != nil { + return nil, err + } + if err := rejectLatestTag(o.Image); err != nil { + return nil, err + } + if o.ComputeSize < 1 { + return nil, errors.New("--compute-size must be >= 1") + } + if o.MaxReplicas < 1 { + return nil, errors.New("--max-replicas must be >= 1") + } + if o.Deadline <= 0 { + return nil, errors.New("--deadline must be > 0") + } + if o.Port < 1 || o.Port > 65535 { + return nil, errors.New("--port must be in 1..65535") + } + + env, err := buildEnvVars(o.Env, o.EnvSecret) + if err != nil { + return nil, err + } + mounts, err := buildVolumeMounts(o.SecretMounts) + if err != nil { + return nil, err + } + + entrypoint := (*verda.ContainerEntrypointOverrides)(nil) + if len(o.Entrypoint) > 0 || len(o.Cmd) > 0 { + entrypoint = &verda.ContainerEntrypointOverrides{ + Enabled: true, + Entrypoint: append([]string(nil), o.Entrypoint...), + Cmd: append([]string(nil), o.Cmd...), + } + } + + // Nil omits registry JSON for public pulls (job requests use a pointer field). + registry := (*verda.ContainerRegistrySettings)(nil) + if o.RegistryCreds != "" { + registry = &verda.ContainerRegistrySettings{ + IsPrivate: true, + Credentials: &verda.RegistryCredentialsRef{Name: o.RegistryCreds}, + } + } + + req := &verda.CreateJobDeploymentRequest{ + Name: o.Name, + ContainerRegistrySettings: registry, + Compute: &verda.ContainerCompute{Name: o.Compute, Size: o.ComputeSize}, + Scaling: &verda.JobScalingOptions{ + MaxReplicaCount: o.MaxReplicas, + DeadlineSeconds: int(o.Deadline.Seconds()), + QueueMessageTTLSeconds: int(o.RequestTTL.Seconds()), + }, + Containers: []verda.CreateDeploymentContainer{{ + Image: o.Image, + ExposedPort: o.Port, + EntrypointOverrides: entrypoint, + Env: env, + VolumeMounts: mounts, + }}, + } + + if err := verda.ValidateCreateJobDeploymentRequest(req); err != nil { + return nil, err + } + return req, nil +} diff --git a/internal/verda-cli/cmd/serverless/batchjob_create_test.go b/internal/verda-cli/cmd/serverless/batchjob_create_test.go new file mode 100644 index 0000000..dda29d8 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_create_test.go @@ -0,0 +1,118 @@ +// 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 serverless + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +func validJobOpts() *batchjobCreateOptions { + return &batchjobCreateOptions{ + Name: "nightly-embed", + Image: "ghcr.io/org/embedder:v1", + Compute: "RTX4500Ada", + ComputeSize: 1, + Port: 80, + MaxReplicas: 3, + Deadline: 30 * time.Minute, + RequestTTL: 300 * time.Second, + } +} + +func TestBatchjobRequest_HappyPath(t *testing.T) { + opts := validJobOpts() + req, err := opts.request() + if err != nil { + t.Fatalf("request: %v", err) + } + if req.Name != "nightly-embed" { + t.Errorf("name: got %q", req.Name) + } + if req.Compute == nil || req.Compute.Name != "RTX4500Ada" { + t.Errorf("compute: got %+v", req.Compute) + } + if req.Scaling == nil || req.Scaling.DeadlineSeconds != int((30*time.Minute).Seconds()) { + t.Errorf("deadline: got %+v, want %d", req.Scaling, int((30 * time.Minute).Seconds())) + } + if req.Scaling.MaxReplicaCount != 3 { + t.Errorf("max replicas: got %d", req.Scaling.MaxReplicaCount) + } +} + +func TestBatchjobRequest_RejectsLatest(t *testing.T) { + opts := validJobOpts() + opts.Image = "nginx:latest" + _, err := opts.request() + if err == nil || !strings.Contains(err.Error(), "latest") { + t.Fatalf("expected :latest rejection, got %v", err) + } +} + +func TestBatchjobRequest_RequiresDeadline(t *testing.T) { + opts := validJobOpts() + opts.Deadline = 0 + _, err := opts.request() + if err == nil || !strings.Contains(err.Error(), "deadline") { + t.Fatalf("expected deadline error, got %v", err) + } +} + +func TestBatchjobMissingFlags(t *testing.T) { + opts := &batchjobCreateOptions{} + missing := missingBatchjobCreateFlags(opts) + want := []string{"--name", "--image", "--compute", "--deadline"} + if len(missing) != len(want) { + t.Fatalf("missing: got %v, want %v", missing, want) + } + for i, w := range want { + if missing[i] != w { + t.Errorf("missing[%d]: got %q, want %q", i, missing[i], w) + } + } +} + +// TestBatchjobCreate_AgentMode_NoCredsReturnsMissingFlags ensures agent mode validates flags before auth. +func TestBatchjobCreate_AgentMode_NoCredsReturnsMissingFlags(t *testing.T) { + f := cmdutil.NewTestFactory(nil) + f.AgentModeOverride = true + + ioStreams := cmdutil.IOStreams{In: nil, Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}} + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runBatchjobCreate(cmd, f, ioStreams, &batchjobCreateOptions{}) + if err == nil { + t.Fatal("expected error, got nil") + } + var agentErr *cmdutil.AgentError + if !errors.As(err, &agentErr) { + t.Fatalf("expected *cmdutil.AgentError, got %T: %v", err, err) + } + if agentErr.Code != "MISSING_REQUIRED_FLAGS" { + t.Fatalf("expected MISSING_REQUIRED_FLAGS, got code=%q msg=%q", agentErr.Code, agentErr.Message) + } + if errors.Is(err, cmdutil.ErrNoClient) { + t.Fatalf("auth error leaked through — agent-mode flag check must run before VerdaClient: %v", err) + } +} diff --git a/internal/verda-cli/cmd/serverless/batchjob_delete.go b/internal/verda-cli/cmd/serverless/batchjob_delete.go new file mode 100644 index 0000000..ecc9b27 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_delete.go @@ -0,0 +1,90 @@ +// 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 serverless + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +func newCmdBatchjobDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + var yes bool + var timeoutMs int + + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm", "del"}, + Short: "Delete a batch-job deployment", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name, err := resolveBatchjobName(cmd, f, ioStreams, args) + if err != nil || name == "" { + return err + } + return runBatchjobDelete(cmd, f, ioStreams, name, yes, timeoutMs) + }, + } + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation (required in agent mode)") + cmd.Flags().IntVar(&timeoutMs, "timeout-ms", -1, "Server-side wait timeout in ms (0 to skip wait; negative uses API default)") + return cmd +} + +func runBatchjobDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, name string, yes bool, timeoutMs int) error { + client, err := f.VerdaClient() + if err != nil { + return err + } + + if f.AgentMode() && !yes { + return cmdutil.NewConfirmationRequiredError("delete") + } + if !yes { + confirmed, err := confirmDestructive( + cmd.Context(), ioStreams, f.Prompter(), + "Delete batch-job deployment", + fmt.Sprintf("Deployment %q will stop accepting jobs immediately.", name), + fmt.Sprintf("Delete %s?", name), + ) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + } + + ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + + err = cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("Deleting %s...", name), func() error { + return client.ServerlessJobs.DeleteJobDeployment(ctx, name, timeoutMs) + }) + if err != nil { + return err + } + + if f.AgentMode() { + result := map[string]string{"name": name, "action": "delete", "status": "completed"} + _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), result) + return nil + } + _, _ = fmt.Fprintf(ioStreams.Out, "Deleted deployment %q\n", name) + return nil +} diff --git a/internal/verda-cli/cmd/serverless/batchjob_describe.go b/internal/verda-cli/cmd/serverless/batchjob_describe.go new file mode 100644 index 0000000..ba09f5a --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_describe.go @@ -0,0 +1,180 @@ +// 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 serverless + +import ( + "context" + "fmt" + "io" + "strings" + + "charm.land/lipgloss/v2" + "github.com/spf13/cobra" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +func newCmdBatchjobDescribe(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe ", + Aliases: []string{"get", "show"}, + Short: "Show details of a batch-job deployment", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name, err := resolveBatchjobName(cmd, f, ioStreams, args) + if err != nil || name == "" { + return err + } + return runBatchjobDescribe(cmd, f, ioStreams, name) + }, + } + return cmd +} + +func resolveBatchjobName(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, args []string) (string, error) { + if len(args) > 0 { + return args[0], nil + } + if f.AgentMode() { + return "", cmdutil.NewMissingFlagsError([]string{""}) + } + return selectBatchjobDeployment(cmd.Context(), f, ioStreams) +} + +func selectBatchjobDeployment(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams) (string, error) { + client, err := f.VerdaClient() + if err != nil { + return "", err + } + + 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) { + return client.ServerlessJobs.GetJobDeployments(listCtx) + }) + if err != nil { + return "", err + } + if len(jobs) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No batch-job deployments found.") + return "", nil + } + + labels := make([]string, 0, len(jobs)+1) + for i := range jobs { + j := &jobs[i] + compute := "-" + if j.Compute != nil { + compute = fmt.Sprintf("%s x%d", j.Compute.Name, j.Compute.Size) + } + labels = append(labels, fmt.Sprintf("%s (%s)", j.Name, compute)) + } + labels = append(labels, "Cancel") + + idx, err := f.Prompter().Select(ctx, "Select batch-job deployment", labels, tui.WithShowHints(true)) + if err != nil { + if isPromptCancel(err) { + return "", nil + } + return "", err + } + if idx == len(jobs) { + return "", nil + } + return jobs[idx].Name, nil +} + +func runBatchjobDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, name string) error { + client, err := f.VerdaClient() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + + // Status fetched under the same spinner; best-effort with a short + // 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) { + d, derr := client.ServerlessJobs.GetJobDeploymentByName(ctx, name) + if derr != nil { + return nil, derr + } + statusCtx, statusCancel := context.WithTimeout(ctx, statusRPCTimeout) + defer statusCancel() + if s, statusErr := client.ServerlessJobs.GetJobDeploymentStatus(statusCtx, name); statusErr == nil && s != nil { + status = s.Status + } + return d, nil + }) + if err != nil { + return fmt.Errorf("fetching deployment: %w", err) + } + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployment:", job) + + // Same embedded Status caveat as container describe (see container_describe.go). + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), struct { + *verda.JobDeployment + Status string `json:"status,omitempty"` + }{job, status}); wrote { + return werr + } + + renderJobDeploymentCard(ioStreams.Out, job, status) + return nil +} + +func renderJobDeploymentCard(w io.Writer, j *verda.JobDeployment, status string) { + label := lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + _, _ = fmt.Fprintf(w, "\n %s\n", label.Render(j.Name)) + if status != "" { + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Status"), statusColor(status).Render(status)) + } + if j.Compute != nil { + _, _ = fmt.Fprintf(w, " %s %s x%d\n", label.Render("Compute"), j.Compute.Name, j.Compute.Size) + } + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Endpoint"), j.EndpointBaseURL) + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Created"), j.CreatedAt.Format("2006-01-02 15:04")) + if j.Scaling != nil { + _, _ = fmt.Fprintf(w, " %s max=%d deadline=%ds ttl=%ds\n", + label.Render("Scaling"), + j.Scaling.MaxReplicaCount, j.Scaling.DeadlineSeconds, j.Scaling.QueueMessageTTLSeconds) + } + + for i := range j.Containers { + c := &j.Containers[i] + _, _ = fmt.Fprintf(w, "\n %s\n", label.Render("Container")) + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Image"), c.Image.Image) + if c.ExposedPort > 0 { + _, _ = fmt.Fprintf(w, " %s %d\n", label.Render("Port"), c.ExposedPort) + } + if len(c.Env) > 0 { + names := make([]string, 0, len(c.Env)) + for _, e := range c.Env { + names = append(names, e.Name) + } + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Env"), dim.Render(strings.Join(names, ", "))) + } + } + _, _ = fmt.Fprintln(w) +} diff --git a/internal/verda-cli/cmd/serverless/batchjob_list.go b/internal/verda-cli/cmd/serverless/batchjob_list.go new file mode 100644 index 0000000..930d016 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_list.go @@ -0,0 +1,87 @@ +// 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 serverless + +import ( + "context" + "fmt" + "strconv" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +func newCmdBatchjobList(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List batch-job deployments", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runBatchjobList(cmd, f, ioStreams) + }, + } + return cmd +} + +func runBatchjobList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams) error { + client, err := f.VerdaClient() + if err != nil { + return err + } + + 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) { + return client.ServerlessJobs.GetJobDeployments(ctx) + }) + if err != nil { + return fmt.Errorf("fetching jobs: %w", err) + } + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Jobs:", jobs) + + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), jobs); wrote { + return werr + } + + if len(jobs) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No batch-job deployments found.") + return nil + } + + w := tabwriter.NewWriter(ioStreams.Out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "NAME\tCOMPUTE\tSIZE\tCREATED") + for i := range jobs { + j := &jobs[i] + compute := "-" + size := "-" + if j.Compute != nil { + compute = j.Compute.Name + size = strconv.Itoa(j.Compute.Size) + } + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", + j.Name, + compute, + size, + j.CreatedAt.Format("2006-01-02 15:04"), + ) + } + return w.Flush() +} diff --git a/internal/verda-cli/cmd/serverless/container.go b/internal/verda-cli/cmd/serverless/container.go new file mode 100644 index 0000000..16d5397 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container.go @@ -0,0 +1,52 @@ +// 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 serverless + +import ( + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// NewCmdContainer creates the top-level `verda container` command tree. +func NewCmdContainer(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "container", + Short: "Manage serverless container deployments (always-on endpoints)", + // Pre-release: hidden from `verda --help`. The env-var gate in + // cmd/cmd.go (serverlessEnabled) decides whether the command is even + // registered; this covers testers who flip VERDA_SERVERLESS_ENABLED on. + // Drop both when serverless ships GA. + Hidden: true, + Long: cmdutil.LongDesc(` + Create and manage always-on serverless container deployments. Each + deployment exposes an HTTPS endpoint that auto-scales based on queue + load, CPU/GPU utilization, or manual replica limits. + `), + Run: cmdutil.DefaultSubCommandRun(ioStreams.Out), + } + + cmd.AddCommand( + newCmdContainerCreate(f, ioStreams), + newCmdContainerList(f, ioStreams), + newCmdContainerDescribe(f, ioStreams), + newCmdContainerDelete(f, ioStreams), + newCmdContainerPause(f, ioStreams), + newCmdContainerResume(f, ioStreams), + newCmdContainerRestart(f, ioStreams), + newCmdContainerPurgeQueue(f, ioStreams), + ) + return cmd +} diff --git a/internal/verda-cli/cmd/serverless/container_actions.go b/internal/verda-cli/cmd/serverless/container_actions.go new file mode 100644 index 0000000..0db5046 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_actions.go @@ -0,0 +1,132 @@ +// 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 serverless + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// containerActionFn executes a lifecycle action on a named container deployment. +type containerActionFn func(ctx context.Context, client *verda.Client, name string) error + +// newContainerActionCmd builds container commands sharing resolve + confirm plumbing. +// detailMsg is optional fmt template with one %q for the deployment name (destructive confirms only). +func newContainerActionCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, verb, short, spinner, successMsg, detailMsg string, destructive bool, fn containerActionFn) *cobra.Command { + var yes bool + cmd := &cobra.Command{ + Use: verb + " ", + Short: short, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name, err := resolveContainerName(cmd, f, ioStreams, args) + if err != nil || name == "" { + return err + } + return runContainerAction(cmd, f, ioStreams, name, verb, spinner, successMsg, detailMsg, destructive, yes, fn) + }, + } + if destructive { + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation (required in agent mode)") + } + return cmd +} + +func runContainerAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, name, verb, spinner, successMsg, detailMsg string, destructive, yes bool, fn containerActionFn) error { + client, err := f.VerdaClient() + if err != nil { + return err + } + + if destructive && f.AgentMode() && !yes { + return cmdutil.NewConfirmationRequiredError(verb) + } + if destructive && !yes { + confirmed, err := confirmDestructive(cmd.Context(), ioStreams, f.Prompter(), + verb+" container deployment", + fmt.Sprintf(detailMsg, name), + fmt.Sprintf("%s %s?", verb, name), + ) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + } + + 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 { + return fn(ctx, client, name) + }) + if err != nil { + return err + } + + if f.AgentMode() { + _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), map[string]string{ + "name": name, "action": verb, "status": "completed", + }) + return nil + } + _, _ = fmt.Fprintf(ioStreams.Out, successMsg+"\n", name) + return nil +} + +func newCmdContainerPause(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + return newContainerActionCmd(f, ioStreams, + "pause", "Pause a container deployment", "Pausing", "Paused deployment %q", "", false, + func(ctx context.Context, c *verda.Client, name string) error { + return c.ContainerDeployments.PauseDeployment(ctx, name) + }, + ) +} + +func newCmdContainerResume(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + return newContainerActionCmd(f, ioStreams, + "resume", "Resume a paused container deployment", "Resuming", "Resumed deployment %q", "", false, + func(ctx context.Context, c *verda.Client, name string) error { + return c.ContainerDeployments.ResumeDeployment(ctx, name) + }, + ) +} + +func newCmdContainerRestart(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + return newContainerActionCmd(f, ioStreams, + "restart", "Restart a container deployment", "Restarting", "Restarted deployment %q", + "Deployment %q will be restarted.", true, + func(ctx context.Context, c *verda.Client, name string) error { + return c.ContainerDeployments.RestartDeployment(ctx, name) + }, + ) +} + +func newCmdContainerPurgeQueue(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + return newContainerActionCmd(f, ioStreams, + "purge-queue", "Purge the pending-request queue for a container deployment", "Purging queue for", "Purged queue for deployment %q", + "Queue for deployment %q will be purged.", true, + func(ctx context.Context, c *verda.Client, name string) error { + return c.ContainerDeployments.PurgeDeploymentQueue(ctx, name) + }, + ) +} diff --git a/internal/verda-cli/cmd/serverless/container_create.go b/internal/verda-cli/cmd/serverless/container_create.go new file mode 100644 index 0000000..a137fb3 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_create.go @@ -0,0 +1,435 @@ +// 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 serverless + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui/wizard" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// Queue-load presets map CLI names to ScalingTriggers.QueueLoad thresholds. +const ( + presetInstant = "instant" + presetBalanced = "balanced" + presetCostSaver = "cost-saver" + presetCustom = "custom" + + queueLoadInstant = 1 + queueLoadBalanced = 3 + queueLoadCostSaver = 6 + + // Server-provisioned scratch at /data (size omitted). Runtime supplies /dev/shm. + defaultGeneralStoragePath = "/data" + + defaultExposedPort = 80 + defaultHealthcheckPath = "/health" + defaultMaxReplicas = 3 + defaultConcurrency = 1 + defaultScaleDownDelay = 300 * time.Second + defaultRequestTTL = 300 * time.Second +) + +// containerCreateOptions holds flags/wizard state; request() builds the SDK payload. +type containerCreateOptions struct { + Name string + Image string + + Spot bool + + Compute string + ComputeSize int + + RegistryCreds string // empty = public + RegistryPublic bool // explicit --registry-public, just for clarity + + Port int + HealthcheckOff bool + HealthcheckPort int + HealthcheckPath string + Env []string // KEY=VALUE + EnvSecret []string // KEY=SECRET_NAME + Entrypoint []string + Cmd []string + + MinReplicas int + MaxReplicas int + Concurrency int + QueuePreset string + QueueLoad int // custom override; 0 = use preset + CPUUtil int // 0 = off; >0 = enable + threshold + GPUUtil int // 0 = off; >0 = enable + threshold + ScaleUpDelay time.Duration + ScaleDownDelay time.Duration + RequestTTL time.Duration + + SecretMounts []string // SECRET:PATH + + Yes bool +} + +func newCmdContainerCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + opts := &containerCreateOptions{ + Port: defaultExposedPort, + HealthcheckPath: defaultHealthcheckPath, + MinReplicas: 0, + MaxReplicas: defaultMaxReplicas, + Concurrency: defaultConcurrency, + QueuePreset: presetBalanced, + ScaleUpDelay: 0, + ScaleDownDelay: defaultScaleDownDelay, + RequestTTL: defaultRequestTTL, + } + + cmd := &cobra.Command{ + Use: "create", + Short: "Create a serverless container deployment", + Long: cmdutil.LongDesc(` + Create an always-on serverless container deployment. Without flags, + launches an interactive wizard (coming in a follow-up task). With + flags, builds the deployment request directly and submits it. + + Images must use a specific tag — ":latest" is rejected client-side. + `), + Example: cmdutil.Examples(` + # Minimal flag-driven + verda container create \ + --name my-endpoint \ + --image ghcr.io/ai-dock/comfyui:cpu-22.04 \ + --compute RTX4500Ada --compute-size 1 + + # With env vars and scaling preset + verda container create \ + --name my-api --image ghcr.io/me/llm:v1.2 \ + --compute RTX4500Ada --compute-size 1 \ + --env HF_HOME=/data/.huggingface \ + --max-replicas 5 --queue-preset cost-saver + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runContainerCreate(cmd, f, ioStreams, opts) + }, + } + + flags := cmd.Flags() + flags.StringVar(&opts.Name, "name", "", "Deployment name (URL slug; immutable after create)") + flags.StringVar(&opts.Image, "image", "", "Container image reference (must not be ':latest')") + + flags.BoolVar(&opts.Spot, "spot", false, "Use spot compute instead of on-demand") + + flags.StringVar(&opts.Compute, "compute", "", "Compute resource name (e.g. RTX4500Ada, CPUNode)") + flags.IntVar(&opts.ComputeSize, "compute-size", 1, "Number of GPUs or vCPU cores per replica") + + flags.StringVar(&opts.RegistryCreds, "registry-creds", "", "Registry credentials name (empty = public)") + flags.BoolVar(&opts.RegistryPublic, "registry-public", false, "Pull image anonymously (default)") + + flags.IntVar(&opts.Port, "port", opts.Port, "Exposed HTTP port") + flags.BoolVar(&opts.HealthcheckOff, "healthcheck-off", false, "Disable healthcheck (default: on at /health)") + flags.IntVar(&opts.HealthcheckPort, "healthcheck-port", 0, "Healthcheck HTTP port (defaults to --port)") + flags.StringVar(&opts.HealthcheckPath, "healthcheck-path", opts.HealthcheckPath, "Healthcheck HTTP path") + flags.StringArrayVar(&opts.Env, "env", nil, "Environment variable KEY=VALUE (KEY uppercase); repeat for multiple") + flags.StringArrayVar(&opts.EnvSecret, "env-secret", nil, "Secret-backed env KEY=SECRET_NAME (KEY uppercase); repeat for multiple") + flags.StringArrayVar(&opts.Entrypoint, "entrypoint", nil, "Override image ENTRYPOINT; repeat for multiple args") + flags.StringArrayVar(&opts.Cmd, "cmd", nil, "Override image CMD; repeat for multiple args") + + flags.IntVar(&opts.MinReplicas, "min-replicas", opts.MinReplicas, "Minimum replica count (0 = scale-to-zero)") + flags.IntVar(&opts.MaxReplicas, "max-replicas", opts.MaxReplicas, "Maximum replica count") + flags.IntVar(&opts.Concurrency, "concurrency", opts.Concurrency, "Concurrent requests per replica") + flags.StringVar(&opts.QueuePreset, "queue-preset", opts.QueuePreset, "Scaling preset: instant | balanced | cost-saver | custom") + flags.IntVar(&opts.QueueLoad, "queue-load", 0, "Custom queue-load threshold (1..1000); sets --queue-preset=custom when used") + flags.IntVar(&opts.CPUUtil, "cpu-util", 0, "CPU utilization trigger threshold % (1..100); 0 = off") + flags.IntVar(&opts.GPUUtil, "gpu-util", 0, "GPU utilization trigger threshold % (1..100); 0 = off") + flags.DurationVar(&opts.ScaleUpDelay, "scale-up-delay", opts.ScaleUpDelay, "Delay before scaling up") + flags.DurationVar(&opts.ScaleDownDelay, "scale-down-delay", opts.ScaleDownDelay, "Delay before scaling down") + flags.DurationVar(&opts.RequestTTL, "request-ttl", opts.RequestTTL, "How long a pending request may live before deletion") + + flags.StringArrayVar(&opts.SecretMounts, "secret-mount", nil, "Secret mount SECRET:MOUNT_PATH; repeat for multiple") + + flags.BoolVarP(&opts.Yes, "yes", "y", false, "Skip confirmation (required in agent mode)") + + return cmd +} + +func runContainerCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *containerCreateOptions) error { + // In --agent, validate required flags before VerdaClient() so MISSING_REQUIRED_FLAGS beats auth errors. + if f.AgentMode() { + if missing := missingContainerCreateFlags(opts); len(missing) > 0 { + return cmdutil.NewMissingFlagsError(missing) + } + } else if opts.Name == "" || opts.Image == "" || opts.Compute == "" { + if err := runContainerWizard(cmd.Context(), f, ioStreams, opts); err != nil { + return err + } + } + + client, err := f.VerdaClient() + if err != nil { + return err + } + + req, err := opts.request() + if err != nil { + return cmdutil.UsageErrorf(cmd, "%v", err) + } + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Request payload:", req) + + // Human confirm after summary; agent requires --yes. + if !f.AgentMode() && !opts.Yes { + renderContainerSummary(ioStreams.ErrOut, opts) + confirmed, err := f.Prompter().Confirm(cmd.Context(), fmt.Sprintf("Deploy %s?", opts.Name)) + if err != nil && !isPromptCancel(err) { + return err + } + if err != nil || !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + } + + 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) { + return client.ContainerDeployments.CreateDeployment(ctx, req) + }) + if err != nil { + return err + } + + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), deployment); wrote { + return werr + } + + _, _ = fmt.Fprintf(ioStreams.Out, "Created deployment %q\n", deployment.Name) + _, _ = fmt.Fprintf(ioStreams.Out, "Endpoint: %s\n", deployment.EndpointBaseURL) + return nil +} + +// runContainerWizard runs the interactive create flow into opts. +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()) + return engine.Run(ctx, flow) +} + +func missingContainerCreateFlags(opts *containerCreateOptions) []string { + var missing []string + if opts.Name == "" { + missing = append(missing, "--name") + } + if opts.Image == "" { + missing = append(missing, "--image") + } + if opts.Compute == "" { + missing = append(missing, "--compute") + } + return missing +} + +// validate checks opts; request() performs assembly and SDK validation. +func (o *containerCreateOptions) validate() error { + if err := validateDeploymentName(o.Name); err != nil { + return err + } + if err := rejectLatestTag(o.Image); err != nil { + return err + } + if o.ComputeSize < 1 { + return errors.New("--compute-size must be >= 1") + } + if o.MinReplicas < 0 { + return errors.New("--min-replicas must be >= 0") + } + if o.MaxReplicas < 1 || o.MaxReplicas < o.MinReplicas { + return errors.New("--max-replicas must be >= max(1, --min-replicas)") + } + if o.Concurrency < 1 { + return errors.New("--concurrency must be >= 1") + } + if o.Port < 1 || o.Port > 65535 { + return errors.New("--port must be in 1..65535") + } + if o.CPUUtil < 0 || o.CPUUtil > 100 { + return errors.New("--cpu-util must be in 0..100") + } + if o.GPUUtil < 0 || o.GPUUtil > 100 { + return errors.New("--gpu-util must be in 0..100") + } + return nil +} + +// request builds CreateDeploymentRequest after validate(); runs ValidateCreateDeploymentRequest. +func (o *containerCreateOptions) request() (*verda.CreateDeploymentRequest, error) { + if err := o.validate(); err != nil { + return nil, err + } + + queueLoad, err := resolveQueueLoad(o.QueuePreset, o.QueueLoad) + if err != nil { + return nil, err + } + + env, err := buildEnvVars(o.Env, o.EnvSecret) + if err != nil { + return nil, err + } + + mounts, err := buildVolumeMounts(o.SecretMounts) + if err != nil { + return nil, err + } + + healthcheck := (*verda.ContainerHealthcheck)(nil) + if !o.HealthcheckOff { + hcPort := o.HealthcheckPort + if hcPort == 0 { + hcPort = o.Port + } + healthcheck = &verda.ContainerHealthcheck{ + Enabled: true, + Port: hcPort, + Path: o.HealthcheckPath, + } + } + + entrypoint := (*verda.ContainerEntrypointOverrides)(nil) + if len(o.Entrypoint) > 0 || len(o.Cmd) > 0 { + entrypoint = &verda.ContainerEntrypointOverrides{ + Enabled: true, + Entrypoint: append([]string(nil), o.Entrypoint...), + Cmd: append([]string(nil), o.Cmd...), + } + } + + registry := verda.ContainerRegistrySettings{IsPrivate: false} + if o.RegistryCreds != "" { + registry = verda.ContainerRegistrySettings{ + IsPrivate: true, + Credentials: &verda.RegistryCredentialsRef{Name: o.RegistryCreds}, + } + } + + req := &verda.CreateDeploymentRequest{ + Name: o.Name, + IsSpot: o.Spot, + Compute: verda.ContainerCompute{Name: o.Compute, Size: o.ComputeSize}, + ContainerRegistrySettings: registry, + Scaling: buildContainerScaling(o, queueLoad), + Containers: []verda.CreateDeploymentContainer{{ + Image: o.Image, + ExposedPort: o.Port, + Healthcheck: healthcheck, + EntrypointOverrides: entrypoint, + Env: env, + VolumeMounts: mounts, + }}, + } + + if err := verda.ValidateCreateDeploymentRequest(req); err != nil { + return nil, err + } + return req, nil +} + +func resolveQueueLoad(preset string, custom int) (int, error) { + if custom > 0 { + if custom > 1000 { + return 0, errors.New("--queue-load must be in 1..1000") + } + return custom, nil + } + switch strings.ToLower(preset) { + case presetInstant: + return queueLoadInstant, nil + case presetBalanced, "": + return queueLoadBalanced, nil + case presetCostSaver, "cost_saver", "costsaver": + return queueLoadCostSaver, nil + case presetCustom: + return 0, errors.New("--queue-preset=custom requires --queue-load") + default: + return 0, fmt.Errorf("invalid --queue-preset %q: expected instant, balanced, cost-saver, or custom", preset) + } +} + +func buildEnvVars(plain, secret []string) ([]verda.ContainerEnvVar, error) { + total := len(plain) + len(secret) + if total == 0 { + return nil, nil + } + out := make([]verda.ContainerEnvVar, 0, total) + for _, e := range plain { + v, err := parseEnvFlag(e, envTypePlain) + if err != nil { + return nil, err + } + out = append(out, v) + } + for _, e := range secret { + v, err := parseEnvFlag(e, envTypeSecret) + if err != nil { + return nil, err + } + out = append(out, v) + } + return out, nil +} + +// buildVolumeMounts: always scratch /data plus optional --secret-mount entries (/dev/shm implicit). +func buildVolumeMounts(secretMounts []string) ([]verda.ContainerVolumeMount, error) { + mounts := []verda.ContainerVolumeMount{ + {Type: mountTypeScratch, MountPath: defaultGeneralStoragePath}, + } + for _, entry := range secretMounts { + m, err := parseSecretMountFlag(entry) + if err != nil { + return nil, err + } + mounts = append(mounts, m) + } + return mounts, nil +} + +func buildContainerScaling(o *containerCreateOptions, queueLoad int) verda.ContainerScalingOptions { + triggers := &verda.ScalingTriggers{ + QueueLoad: &verda.QueueLoadTrigger{Threshold: float64(queueLoad)}, + } + if o.CPUUtil > 0 { + triggers.CPUUtilization = &verda.UtilizationTrigger{Enabled: true, Threshold: o.CPUUtil} + } + if o.GPUUtil > 0 { + triggers.GPUUtilization = &verda.UtilizationTrigger{Enabled: true, Threshold: o.GPUUtil} + } + return verda.ContainerScalingOptions{ + MinReplicaCount: o.MinReplicas, + MaxReplicaCount: o.MaxReplicas, + ScaleDownPolicy: &verda.ScalingPolicy{DelaySeconds: int(o.ScaleDownDelay.Seconds())}, + ScaleUpPolicy: &verda.ScalingPolicy{DelaySeconds: int(o.ScaleUpDelay.Seconds())}, + QueueMessageTTLSeconds: int(o.RequestTTL.Seconds()), + ConcurrentRequestsPerReplica: o.Concurrency, + ScalingTriggers: triggers, + } +} diff --git a/internal/verda-cli/cmd/serverless/container_create_test.go b/internal/verda-cli/cmd/serverless/container_create_test.go new file mode 100644 index 0000000..5fe7ba4 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_create_test.go @@ -0,0 +1,280 @@ +// 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 serverless + +import ( + "bytes" + "context" + "errors" + "strconv" + "strings" + "testing" + "time" + + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// validOpts returns defaults that satisfy validate(); tests tweak one field each. +func validOpts() *containerCreateOptions { + return &containerCreateOptions{ + Name: "my-endpoint", + Image: "ghcr.io/org/app:v1.2", + Compute: "RTX4500Ada", + ComputeSize: 1, + Port: 80, + HealthcheckPath: defaultHealthcheckPath, + MinReplicas: 0, + MaxReplicas: 3, + Concurrency: 1, + QueuePreset: presetBalanced, + ScaleDownDelay: 300 * time.Second, + RequestTTL: 300 * time.Second, + } +} + +func TestContainerRequest_HappyPath(t *testing.T) { + opts := validOpts() + req, err := opts.request() + if err != nil { + t.Fatalf("request: %v", err) + } + if req.Name != "my-endpoint" { + t.Errorf("name: got %q", req.Name) + } + if req.Compute.Name != "RTX4500Ada" || req.Compute.Size != 1 { + t.Errorf("compute: got %+v", req.Compute) + } + if req.IsSpot { + t.Errorf("spot should be false by default") + } + if len(req.Containers) != 1 { + t.Fatalf("containers count: got %d", len(req.Containers)) + } + c := req.Containers[0] + if c.Image != "ghcr.io/org/app:v1.2" { + t.Errorf("image: got %q", c.Image) + } + if c.ExposedPort != 80 { + t.Errorf("port: got %d", c.ExposedPort) + } + if c.Healthcheck == nil || !c.Healthcheck.Enabled { + t.Errorf("healthcheck: expected enabled, got %+v", c.Healthcheck) + } + if c.Healthcheck.Port != 80 { + t.Errorf("healthcheck port: got %d, want 80 (defaults to exposed)", c.Healthcheck.Port) + } + if c.Healthcheck.Path != "/health" { + t.Errorf("healthcheck path: got %q", c.Healthcheck.Path) + } + if req.Scaling.ScalingTriggers == nil || req.Scaling.ScalingTriggers.QueueLoad == nil { + t.Fatalf("scaling triggers missing") + } + if req.Scaling.ScalingTriggers.QueueLoad.Threshold != queueLoadBalanced { + t.Errorf("balanced preset should map to %d, got %v", queueLoadBalanced, req.Scaling.ScalingTriggers.QueueLoad.Threshold) + } + // Scratch /data mount is always sent; server sizes it. No explicit /dev/shm mount. + if len(c.VolumeMounts) != 1 { + t.Fatalf("expected 1 default mount (scratch /data), got %d: %+v", len(c.VolumeMounts), c.VolumeMounts) + } + if c.VolumeMounts[0].Type != mountTypeScratch || c.VolumeMounts[0].MountPath != defaultGeneralStoragePath { + t.Errorf("default mount: got %+v, want scratch at /data", c.VolumeMounts[0]) + } + if c.VolumeMounts[0].SizeInMB != 0 { + t.Errorf("scratch mount must not send size_in_mb: got %d", c.VolumeMounts[0].SizeInMB) + } +} + +func TestContainerRequest_RejectsLatest(t *testing.T) { + opts := validOpts() + opts.Image = "ghcr.io/org/app:latest" + _, err := opts.request() + if err == nil || !strings.Contains(err.Error(), "latest") { + t.Fatalf("expected :latest rejection, got %v", err) + } +} + +func TestContainerRequest_PresetMapping(t *testing.T) { + cases := []struct { + preset string + custom int + wantLoad float64 + expectErr bool + }{ + {presetInstant, 0, queueLoadInstant, false}, + {presetBalanced, 0, queueLoadBalanced, false}, + {presetCostSaver, 0, queueLoadCostSaver, false}, + {"", 0, queueLoadBalanced, false}, // empty defaults to balanced + {presetCustom, 42, 42, false}, + {"", 17, 17, false}, // --queue-load alone implies custom + {presetCustom, 0, 0, true}, + {"unknown", 0, 0, true}, + {presetCustom, 1001, 0, true}, + } + for _, tc := range cases { + t.Run(tc.preset+":"+strconv.Itoa(tc.custom), func(t *testing.T) { + opts := validOpts() + opts.QueuePreset = tc.preset + opts.QueueLoad = tc.custom + req, err := opts.request() + if tc.expectErr { + if err == nil { + t.Fatalf("expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + got := req.Scaling.ScalingTriggers.QueueLoad.Threshold + if got != tc.wantLoad { + t.Errorf("preset %q custom %d → got threshold %v, want %v", tc.preset, tc.custom, got, tc.wantLoad) + } + }) + } +} + +func TestContainerRequest_HealthcheckOff(t *testing.T) { + opts := validOpts() + opts.HealthcheckOff = true + req, err := opts.request() + if err != nil { + t.Fatalf("request: %v", err) + } + if req.Containers[0].Healthcheck != nil { + t.Errorf("expected nil healthcheck, got %+v", req.Containers[0].Healthcheck) + } +} + +func TestContainerRequest_Spot(t *testing.T) { + opts := validOpts() + opts.Spot = true + req, err := opts.request() + if err != nil { + t.Fatalf("request: %v", err) + } + if !req.IsSpot { + t.Errorf("expected IsSpot=true") + } +} + +func TestContainerRequest_CPUGPUUtilTriggers(t *testing.T) { + opts := validOpts() + opts.CPUUtil = 70 + opts.GPUUtil = 80 + req, err := opts.request() + if err != nil { + t.Fatalf("request: %v", err) + } + tr := req.Scaling.ScalingTriggers + if tr.CPUUtilization == nil || !tr.CPUUtilization.Enabled || tr.CPUUtilization.Threshold != 70 { + t.Errorf("cpu trigger: got %+v", tr.CPUUtilization) + } + if tr.GPUUtilization == nil || !tr.GPUUtilization.Enabled || tr.GPUUtilization.Threshold != 80 { + t.Errorf("gpu trigger: got %+v", tr.GPUUtilization) + } +} + +func TestContainerRequest_EnvMix(t *testing.T) { + opts := validOpts() + opts.Env = []string{"HF_HOME=/data/.hf", "DEBUG=1"} + opts.EnvSecret = []string{"TOKEN=my-secret"} + req, err := opts.request() + if err != nil { + t.Fatalf("request: %v", err) + } + env := req.Containers[0].Env + if len(env) != 3 { + t.Fatalf("env count: got %d, want 3 — %+v", len(env), env) + } + wantTypes := []string{envTypePlain, envTypePlain, envTypeSecret} + for i, want := range wantTypes { + if env[i].Type != want { + t.Errorf("env[%d] type: got %q, want %q", i, env[i].Type, want) + } + } +} + +func TestContainerRequest_RegistryCreds(t *testing.T) { + opts := validOpts() + opts.RegistryCreds = "my-ghcr" + req, err := opts.request() + if err != nil { + t.Fatalf("request: %v", err) + } + rs := req.ContainerRegistrySettings + if !rs.IsPrivate { + t.Errorf("expected IsPrivate=true") + } + if rs.Credentials == nil || rs.Credentials.Name != "my-ghcr" { + t.Errorf("creds: got %+v", rs.Credentials) + } +} + +func TestContainerRequest_ValidationErrors(t *testing.T) { + cases := []struct { + name string + mutate func(*containerCreateOptions) + wantErr string + }{ + {"empty name", func(o *containerCreateOptions) { o.Name = "" }, "required"}, + {"invalid name", func(o *containerCreateOptions) { o.Name = "My_Endpoint" }, "lowercase"}, + {"latest tag", func(o *containerCreateOptions) { o.Image = "nginx:latest" }, "latest"}, + {"zero compute size", func(o *containerCreateOptions) { o.ComputeSize = 0 }, "compute-size"}, + {"negative min", func(o *containerCreateOptions) { o.MinReplicas = -1 }, "min-replicas"}, + {"max < min", func(o *containerCreateOptions) { o.MinReplicas = 5; o.MaxReplicas = 3 }, "max-replicas"}, + {"zero concurrency", func(o *containerCreateOptions) { o.Concurrency = 0 }, "concurrency"}, + {"bad port", func(o *containerCreateOptions) { o.Port = 99999 }, "port"}, + {"bad cpu-util", func(o *containerCreateOptions) { o.CPUUtil = 150 }, "cpu-util"}, + {"bad gpu-util", func(o *containerCreateOptions) { o.GPUUtil = -1 }, "gpu-util"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + opts := validOpts() + tc.mutate(opts) + _, err := opts.request() + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got %v", tc.wantErr, err) + } + }) + } +} + +// TestContainerCreate_AgentMode_NoCredsReturnsMissingFlags ensures agent mode validates flags before auth. +func TestContainerCreate_AgentMode_NoCredsReturnsMissingFlags(t *testing.T) { + f := cmdutil.NewTestFactory(nil) + f.AgentModeOverride = true + // Nil Verda factory: VerdaClient would error if invoked before flag checks. + + ioStreams := cmdutil.IOStreams{In: nil, Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}} + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + + err := runContainerCreate(cmd, f, ioStreams, &containerCreateOptions{}) + if err == nil { + t.Fatal("expected error, got nil") + } + var agentErr *cmdutil.AgentError + if !errors.As(err, &agentErr) { + t.Fatalf("expected *cmdutil.AgentError, got %T: %v", err, err) + } + if agentErr.Code != "MISSING_REQUIRED_FLAGS" { + t.Fatalf("expected MISSING_REQUIRED_FLAGS, got code=%q msg=%q", agentErr.Code, agentErr.Message) + } + if errors.Is(err, cmdutil.ErrNoClient) { + t.Fatalf("auth error leaked through — agent-mode flag check must run before VerdaClient: %v", err) + } +} diff --git a/internal/verda-cli/cmd/serverless/container_delete.go b/internal/verda-cli/cmd/serverless/container_delete.go new file mode 100644 index 0000000..eeea9bd --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_delete.go @@ -0,0 +1,91 @@ +// 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 serverless + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +func newCmdContainerDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + var yes bool + var timeoutMs int + + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm", "del"}, + Short: "Delete a container deployment", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name, err := resolveContainerName(cmd, f, ioStreams, args) + if err != nil || name == "" { + return err + } + return runContainerDelete(cmd, f, ioStreams, name, yes, timeoutMs) + }, + } + + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation (required in agent mode)") + cmd.Flags().IntVar(&timeoutMs, "timeout-ms", -1, "Server-side wait timeout in ms (0 to skip wait; negative uses API default)") + return cmd +} + +func runContainerDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, name string, yes bool, timeoutMs int) error { + client, err := f.VerdaClient() + if err != nil { + return err + } + + if f.AgentMode() && !yes { + return cmdutil.NewConfirmationRequiredError("delete") + } + if !yes { + confirmed, err := confirmDestructive( + cmd.Context(), ioStreams, f.Prompter(), + "Delete container deployment", + fmt.Sprintf("Deployment %q will stop serving requests immediately.", name), + fmt.Sprintf("Delete %s?", name), + ) + if err != nil { + return err + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + } + + ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + + err = cmdutil.RunWithSpinner(ctx, f.Status(), fmt.Sprintf("Deleting %s...", name), func() error { + return client.ContainerDeployments.DeleteDeployment(ctx, name, timeoutMs) + }) + if err != nil { + return err + } + + if f.AgentMode() { + result := map[string]string{"name": name, "action": "delete", "status": "completed"} + _, _ = cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), result) + return nil + } + _, _ = fmt.Fprintf(ioStreams.Out, "Deleted deployment %q\n", name) + return nil +} diff --git a/internal/verda-cli/cmd/serverless/container_describe.go b/internal/verda-cli/cmd/serverless/container_describe.go new file mode 100644 index 0000000..372c29e --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_describe.go @@ -0,0 +1,200 @@ +// 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 serverless + +import ( + "context" + "fmt" + "io" + "strings" + + "charm.land/lipgloss/v2" + "github.com/spf13/cobra" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +func newCmdContainerDescribe(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "describe ", + Aliases: []string{"get", "show"}, + Short: "Show details of a container deployment", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name, err := resolveContainerName(cmd, f, ioStreams, args) + if err != nil || name == "" { + return err + } + return runContainerDescribe(cmd, f, ioStreams, name) + }, + } + return cmd +} + +// resolveContainerName: args[0], else picker; agent requires . +func resolveContainerName(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, args []string) (string, error) { + if len(args) > 0 { + return args[0], nil + } + if f.AgentMode() { + return "", cmdutil.NewMissingFlagsError([]string{""}) + } + return selectContainerDeployment(cmd.Context(), f, ioStreams) +} + +// selectContainerDeployment prompts for a deployment; cancel/empty list → "", nil. +func selectContainerDeployment(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams) (string, error) { + client, err := f.VerdaClient() + if err != nil { + return "", err + } + + listCtx, cancel := context.WithTimeout(ctx, f.Options().Timeout) + defer cancel() + + deployments, err := cmdutil.WithSpinner(listCtx, f.Status(), "Loading container deployments...", func() ([]verda.ContainerDeployment, error) { + return client.ContainerDeployments.GetDeployments(listCtx) + }) + if err != nil { + return "", err + } + if len(deployments) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No container deployments found.") + return "", nil + } + + labels := make([]string, 0, len(deployments)+1) + for i := range deployments { + d := &deployments[i] + compute := "-" + if d.Compute != nil { + compute = fmt.Sprintf("%s x%d", d.Compute.Name, d.Compute.Size) + } + labels = append(labels, fmt.Sprintf("%s (%s)", d.Name, compute)) + } + labels = append(labels, "Cancel") + + idx, err := f.Prompter().Select(ctx, "Select container deployment", labels, tui.WithShowHints(true)) + if err != nil { + if isPromptCancel(err) { + return "", nil + } + return "", err + } + if idx == len(deployments) { + return "", nil + } + return deployments[idx].Name, nil +} + +func runContainerDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, name string) error { + client, err := f.VerdaClient() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + + // Status fetched under the same spinner; best-effort with a short + // 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) { + d, derr := client.ContainerDeployments.GetDeploymentByName(ctx, name) + if derr != nil { + return nil, derr + } + statusCtx, statusCancel := context.WithTimeout(ctx, statusRPCTimeout) + defer statusCancel() + if s, statusErr := client.ContainerDeployments.GetDeploymentStatus(statusCtx, name); statusErr == nil && s != nil { + status = s.Status + } + return d, nil + }) + if err != nil { + return fmt.Errorf("fetching deployment: %w", err) + } + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployment:", deployment) + + // Embed adds Status alongside SDK fields; if SDK gains json:"status", switch to explicit fields. + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), struct { + *verda.ContainerDeployment + Status string `json:"status,omitempty"` + }{deployment, status}); wrote { + return werr + } + + renderContainerDeploymentCard(ioStreams.Out, deployment, status) + return nil +} + +func renderContainerDeploymentCard(w io.Writer, d *verda.ContainerDeployment, status string) { + label := lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + + _, _ = fmt.Fprintf(w, "\n %s\n", label.Render(d.Name)) + if status != "" { + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Status"), statusColor(status).Render(status)) + } + if d.Compute != nil { + _, _ = fmt.Fprintf(w, " %s %s x%d\n", label.Render("Compute"), d.Compute.Name, d.Compute.Size) + } + spotLabel := "on-demand" + if d.IsSpot { + spotLabel = "spot" + } + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Billing"), spotLabel) + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Endpoint"), d.EndpointBaseURL) + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Created"), d.CreatedAt.Format("2006-01-02 15:04")) + + if d.ContainerRegistrySettings != nil { + reg := "public" + if d.ContainerRegistrySettings.IsPrivate && d.ContainerRegistrySettings.Credentials != nil { + reg = d.ContainerRegistrySettings.Credentials.Name + } + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Registry"), reg) + } + + for i := range d.Containers { + c := &d.Containers[i] + _, _ = fmt.Fprintf(w, "\n %s\n", label.Render("Container")) + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Image"), c.Image.Image) + if c.ExposedPort > 0 { + _, _ = fmt.Fprintf(w, " %s %d\n", label.Render("Port"), c.ExposedPort) + } + if c.Healthcheck != nil && c.Healthcheck.Enabled { + _, _ = fmt.Fprintf(w, " %s %s on port %d\n", label.Render("Healthcheck"), c.Healthcheck.Path, c.Healthcheck.Port) + } + if len(c.Env) > 0 { + names := make([]string, 0, len(c.Env)) + for _, e := range c.Env { + names = append(names, e.Name) + } + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Env"), dim.Render(strings.Join(names, ", "))) + } + if len(c.VolumeMounts) > 0 { + mounts := make([]string, 0, len(c.VolumeMounts)) + for _, m := range c.VolumeMounts { + mounts = append(mounts, fmt.Sprintf("%s:%s", m.Type, m.MountPath)) + } + _, _ = fmt.Fprintf(w, " %s %s\n", label.Render("Mounts"), dim.Render(strings.Join(mounts, ", "))) + } + } + _, _ = fmt.Fprintln(w) +} diff --git a/internal/verda-cli/cmd/serverless/container_list.go b/internal/verda-cli/cmd/serverless/container_list.go new file mode 100644 index 0000000..bd595e9 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_list.go @@ -0,0 +1,365 @@ +// 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 serverless + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + "text/tabwriter" + + "github.com/spf13/cobra" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// containerListExitKey is the synthetic LiveRow key for the trailing Exit row (_ cannot appear in deployment names). +const containerListExitKey = "__exit__" + +type containerListOptions struct { + Status string +} + +func newCmdContainerList(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + opts := &containerListOptions{} + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List serverless container deployments", + Long: cmdutil.LongDesc(` + List container deployments. On a terminal, you can type to filter, + select a deployment to view details, and return to the list. + `), + Example: cmdutil.Examples(` + verda container list + verda container ls + verda container list --status healthy + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runContainerList(cmd, f, ioStreams, opts) + }, + } + cmd.Flags().StringVar(&opts.Status, "status", "", "Filter by status substring (e.g., healthy, initializing, error)") + return cmd +} + +func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *containerListOptions) error { + client, err := f.VerdaClient() + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + + statuses := newContainerStatusCache(containerStatusCacheTTL) + deployments, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading container deployments...", func() ([]verda.ContainerDeployment, error) { + return client.ContainerDeployments.GetDeployments(ctx) + }) + if err != nil { + return fmt.Errorf("fetching deployments: %w", err) + } + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployments:", deployments) + + interactive := cmdutil.IsStdoutTerminal() && !f.AgentMode() && f.OutputFormat() == "table" + + // 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 { + statuses.refresh(ctx, client, deployments) + return nil + }) + } + + if opts.Status != "" { + needle := strings.ToLower(opts.Status) + filtered := deployments[:0] + for i := range deployments { + if strings.Contains(strings.ToLower(statuses.get(deployments[i].Name)), needle) { + filtered = append(filtered, deployments[i]) + } + } + deployments = filtered + } + + if f.OutputFormat() != "table" { + type row struct { + *verda.ContainerDeployment + Status string `json:"status,omitempty"` + } + rows := make([]row, len(deployments)) + for i := range deployments { + rows[i] = row{&deployments[i], statuses.get(deployments[i].Name)} + } + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), rows); wrote { + return werr + } + } + + if len(deployments) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No container deployments found.") + return nil + } + + if !interactive { + return printContainerTable(ioStreams.Out, deployments, statuses) + } + + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %d deployment(s) found\n\n", len(deployments)) + return runContainerListInteractive(cmd, f, ioStreams, client, deployments, statuses) +} + +func runContainerListInteractive( + cmd *cobra.Command, + f cmdutil.Factory, + ioStreams cmdutil.IOStreams, + client *verda.Client, + deployments []verda.ContainerDeployment, + statuses *containerStatusCache, +) error { + prompter := f.Prompter() + // LiveLister is optional on Prompter; fall back to eager status fetch + Select. + if liveLister, ok := prompter.(tui.LiveLister); ok { + return runContainerListLive(cmd, f, ioStreams, client, deployments, statuses, prompter, liveLister) + } + return runContainerListEager(cmd, f, ioStreams, client, deployments, statuses, prompter) +} + +// runContainerListLive: LiveList paints rows immediately; status RPCs refine labels asynchronously. +func runContainerListLive( + cmd *cobra.Command, + f cmdutil.Factory, + ioStreams cmdutil.IOStreams, + client *verda.Client, + deployments []verda.ContainerDeployment, + statuses *containerStatusCache, + prompter tui.Prompter, + liveLister tui.LiveLister, +) error { + for { + rows := buildContainerLiveRows(deployments, statuses) + updates := make(chan tui.LiveListUpdate, len(deployments)) + updateCtx, updateCancel := context.WithCancel(cmd.Context()) + go pushContainerStatusUpdates(updateCtx, client, deployments, statuses, updates) + + idx, err := liveLister.LiveList(cmd.Context(), + "Select deployment (type to filter)", + rows, updates, + tui.WithLiveListShowHints(true), + ) + updateCancel() // abort in-flight status fetches once the user picks/exits + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil + } + return err + } + if idx == len(deployments) { + return nil + } + + if derr := runContainerDescribe(cmd, f, ioStreams, deployments[idx].Name); derr != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, "Error: %v\n", derr) + } + + exit, perr := cmdutil.PromptBackOrExit(cmd.Context(), prompter) + if perr != nil { + return perr + } + if exit { + return nil + } + } +} + +// runContainerListEager: no LiveLister—prefetch statuses so Select sees full labels. +func runContainerListEager( + cmd *cobra.Command, + f cmdutil.Factory, + ioStreams cmdutil.IOStreams, + client *verda.Client, + deployments []verda.ContainerDeployment, + statuses *containerStatusCache, + prompter tui.Prompter, +) error { + for { + if statuses.anyStale(deployments) { + _ = cmdutil.RunWithSpinner(cmd.Context(), f.Status(), "Loading statuses...", func() error { + refreshCtx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + statuses.refresh(refreshCtx, client, deployments) + return nil + }) + } + + labels := make([]string, 0, len(deployments)+1) + for i := range deployments { + labels = append(labels, formatContainerRow(&deployments[i], statuses.get(deployments[i].Name))) + } + labels = append(labels, "Exit") + + idx, err := prompter.Select(cmd.Context(), "Select deployment (type to filter)", labels, tui.WithShowHints(true)) + if err != nil { + if cmdutil.IsPromptCancel(err) { + return nil + } + return err + } + if idx == len(deployments) { + return nil + } + + if derr := runContainerDescribe(cmd, f, ioStreams, deployments[idx].Name); derr != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, "Error: %v\n", derr) + } + + exit, perr := cmdutil.PromptBackOrExit(cmd.Context(), prompter) + if perr != nil { + return perr + } + if exit { + return nil + } + } +} + +// buildContainerLiveRows builds deployment rows plus Exit; stale/missing status shows "..." until pushed. +func buildContainerLiveRows(deployments []verda.ContainerDeployment, statuses *containerStatusCache) []tui.LiveRow { + rows := make([]tui.LiveRow, 0, len(deployments)+1) + for i := range deployments { + d := &deployments[i] + label := statuses.get(d.Name) + if label == "" || statuses.stale(d.Name) { + label = containerStatusLoading + } + rows = append(rows, tui.LiveRow{ + Key: d.Name, + Label: formatContainerRow(d, label), + }) + } + rows = append(rows, tui.LiveRow{Key: containerListExitKey, Label: "Exit"}) + return rows +} + +// pushContainerStatusUpdates refreshes stale cache entries with bounded concurrency, +// pushes LiveListUpdate per deployment, closes updates when done. +func pushContainerStatusUpdates( + ctx context.Context, + client *verda.Client, + deployments []verda.ContainerDeployment, + statuses *containerStatusCache, + updates chan<- tui.LiveListUpdate, +) { + defer close(updates) + var wg sync.WaitGroup + sem := make(chan struct{}, containerStatusFetchConcurrency) + for i := range deployments { + d := &deployments[i] + if !statuses.stale(d.Name) { + continue + } + wg.Add(1) + go func(d *verda.ContainerDeployment) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + + s, fetchErr := client.ContainerDeployments.GetDeploymentStatus(ctx, d.Name) + var status string + var liveErr error + switch { + case fetchErr != nil: + status = containerStatusUnknown + liveErr = fetchErr + case s == nil: + status = containerStatusUnknown + default: + status = s.Status + } + statuses.set(d.Name, status) + select { + case updates <- tui.LiveListUpdate{ + Key: d.Name, + Label: formatContainerRow(d, status), + Err: liveErr, + }: + case <-ctx.Done(): + } + }(d) + } + wg.Wait() +} + +func printContainerTable(out io.Writer, deployments []verda.ContainerDeployment, statuses *containerStatusCache) error { + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "NAME\tSTATUS\tCOMPUTE\tBILLING\tENDPOINT\tCREATED") + for i := range deployments { + d := &deployments[i] + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + d.Name, + statusOrDash(statuses.get(d.Name)), + formatContainerCompute(d), + formatContainerBilling(d), + d.EndpointBaseURL, + d.CreatedAt.Format("2006-01-02 15:04"), + ) + } + return w.Flush() +} + +func formatContainerRow(d *verda.ContainerDeployment, status string) string { + return fmt.Sprintf("%-32s ● %-14s %-22s %-10s %s", + truncate(d.Name, 32), + statusOrDash(status), + formatContainerCompute(d), + formatContainerBilling(d), + d.CreatedAt.Format("2006-01-02 15:04"), + ) +} + +func formatContainerCompute(d *verda.ContainerDeployment) string { + if d.Compute == nil { + return "-" + } + return fmt.Sprintf("%dx %s", d.Compute.Size, d.Compute.Name) +} + +func formatContainerBilling(d *verda.ContainerDeployment) string { + if d.IsSpot { + return computeTypeSpot + } + return computeTypeOnDemand +} + +func statusOrDash(s string) string { + if s == "" { + return containerStatusUnknown + } + return s +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-3] + "..." +} diff --git a/internal/verda-cli/cmd/serverless/container_status_cache.go b/internal/verda-cli/cmd/serverless/container_status_cache.go new file mode 100644 index 0000000..44c7abf --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_status_cache.go @@ -0,0 +1,112 @@ +// 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 serverless + +import ( + "context" + "sync" + "time" + + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" +) + +const ( + containerStatusCacheTTL = 30 * time.Second + containerStatusFetchConcurrency = 5 + containerStatusUnknown = "-" + containerStatusLoading = "..." // placeholder until LiveList status RPC completes + + // statusRPCTimeout bounds the best-effort describe status call so a slow + // status endpoint can't consume the parent describe deadline. + statusRPCTimeout = 5 * time.Second +) + +// containerStatusCache: per-name status + TTL because list RPC omits status. +type containerStatusCache struct { + mu sync.Mutex + entries map[string]containerStatusEntry + ttl time.Duration +} + +type containerStatusEntry struct { + status string + fetchedAt time.Time +} + +func newContainerStatusCache(ttl time.Duration) *containerStatusCache { + return &containerStatusCache{ + entries: make(map[string]containerStatusEntry), + ttl: ttl, + } +} + +func (c *containerStatusCache) get(name string) string { + c.mu.Lock() + defer c.mu.Unlock() + if e, ok := c.entries[name]; ok { + return e.status + } + return "" +} + +func (c *containerStatusCache) set(name, status string) { + c.mu.Lock() + defer c.mu.Unlock() + c.entries[name] = containerStatusEntry{status: status, fetchedAt: time.Now()} +} + +func (c *containerStatusCache) stale(name string) bool { + c.mu.Lock() + defer c.mu.Unlock() + e, ok := c.entries[name] + if !ok { + return true + } + return time.Since(e.fetchedAt) > c.ttl +} + +func (c *containerStatusCache) anyStale(deployments []verda.ContainerDeployment) bool { + for i := range deployments { + if c.stale(deployments[i].Name) { + return true + } + } + return false +} + +// refresh fills missing/stale entries concurrently; errors become "-". +func (c *containerStatusCache) refresh(ctx context.Context, client *verda.Client, deployments []verda.ContainerDeployment) { + var wg sync.WaitGroup + sem := make(chan struct{}, containerStatusFetchConcurrency) + for i := range deployments { + name := deployments[i].Name + if !c.stale(name) { + continue + } + wg.Add(1) + go func(name string) { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + s, err := client.ContainerDeployments.GetDeploymentStatus(ctx, name) + if err != nil || s == nil { + c.set(name, containerStatusUnknown) + return + } + c.set(name, s.Status) + }(name) + } + wg.Wait() +} diff --git a/internal/verda-cli/cmd/serverless/gate_test.go b/internal/verda-cli/cmd/serverless/gate_test.go new file mode 100644 index 0000000..0d6776b --- /dev/null +++ b/internal/verda-cli/cmd/serverless/gate_test.go @@ -0,0 +1,37 @@ +// 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 serverless + +import ( + "bytes" + "testing" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// Serverless is pre-GA: both parents stay Hidden so they don't surface in +// `verda --help` even when VERDA_SERVERLESS_ENABLED registers them. Drop these +// assertions (and the Hidden flags) at GA. +func TestServerlessParentsHiddenPreGA(t *testing.T) { + f := cmdutil.NewTestFactory(nil) + streams := cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}} + + if c := NewCmdContainer(f, streams); !c.Hidden { + t.Errorf("container command should be Hidden pre-GA") + } + if c := NewCmdBatchjob(f, streams); !c.Hidden { + t.Errorf("batchjob command should be Hidden pre-GA") + } +} diff --git a/internal/verda-cli/cmd/serverless/shared.go b/internal/verda-cli/cmd/serverless/shared.go new file mode 100644 index 0000000..86b0382 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/shared.go @@ -0,0 +1,155 @@ +// 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 serverless + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + + "charm.land/lipgloss/v2" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// deploymentNameRE: lowercase DNS-label subset (max 63) enforced by the API for URL slugs. +var deploymentNameRE = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`) + +// validateDeploymentName returns an error if the given name is not a valid +// deployment name. Empty is rejected; names > 63 chars are rejected. +func validateDeploymentName(name string) error { + switch { + case name == "": + return errors.New("deployment name is required") + case len(name) > 63: + return fmt.Errorf("deployment name %q is longer than 63 characters", name) + case !deploymentNameRE.MatchString(name): + return fmt.Errorf("deployment name %q must be lowercase alphanumerics and hyphens, starting and ending with an alphanumeric", name) + } + return nil +} + +// rejectLatestTag fails fast on :latest before the API returns a generic 400. +func rejectLatestTag(image string) error { + if verda.IsLatestTag(image) { + return fmt.Errorf("container image %q must use a specific tag, not ':latest'", image) + } + return nil +} + +// envVarNameRE matches a conventional POSIX environment-variable name: +// uppercase letters, digits, and underscores, not leading with a digit. +var envVarNameRE = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`) + +// parseEnvFlag parses a KEY=VALUE env-var flag entry into a ContainerEnvVar. +// envType is "plain" for literal values or "secret" for secret-name references. +func parseEnvFlag(entry, envType string) (verda.ContainerEnvVar, error) { + eq := strings.IndexByte(entry, '=') + if eq < 1 { + return verda.ContainerEnvVar{}, fmt.Errorf("invalid env entry %q: expected KEY=VALUE", entry) + } + name := entry[:eq] + value := entry[eq+1:] + if !envVarNameRE.MatchString(name) { + return verda.ContainerEnvVar{}, fmt.Errorf("invalid env name %q: use uppercase letters, digits, and underscores, not leading with a digit", name) + } + return verda.ContainerEnvVar{ + Type: envType, + Name: name, + ValueOrReferenceToSecret: value, + }, nil +} + +// parseSecretMountFlag parses a SECRET:PATH flag entry into a ContainerVolumeMount. +func parseSecretMountFlag(entry string) (verda.ContainerVolumeMount, error) { + colon := strings.IndexByte(entry, ':') + if colon < 1 || colon == len(entry)-1 { + return verda.ContainerVolumeMount{}, fmt.Errorf("invalid secret mount %q: expected SECRET:MOUNT_PATH", entry) + } + secretName := entry[:colon] + mountPath := entry[colon+1:] + if !strings.HasPrefix(mountPath, "/") { + return verda.ContainerVolumeMount{}, fmt.Errorf("invalid secret mount %q: mount path must be absolute", entry) + } + return verda.ContainerVolumeMount{ + Type: mountTypeSecret, + MountPath: mountPath, + SecretName: secretName, + }, nil +} + +// Mount type constants match the server-side enum. "scratch" is the +// auto-allocated `/data` general-storage volume; the server sizes it. +// `shared` (named volume) is not exposed by the CLI yet — it requires a +// volume_id we have no API to pass. +const ( + mountTypeSecret = "secret" + mountTypeScratch = "scratch" +) + +// Environment-variable type constants. +const ( + envTypePlain = "plain" + envTypeSecret = "secret" +) + +// isPromptCancel is a terse package-local alias for cmdutil.IsPromptCancel, +// kept because the wizard/subflow call sites read better short. The sentinel +// logic lives in cmdutil — do not reimplement it here. +func isPromptCancel(err error) bool { + return cmdutil.IsPromptCancel(err) +} + +// confirmDestructive renders a red-bold warning line and prompts the user to +// confirm. Returns (true, nil) to proceed, (false, nil) on a clean prompter +// cancel (Ctrl+C / Esc), or (false, err) on a real prompter failure that +// callers must surface. In agent mode, callers must bypass this helper and +// enforce --yes themselves. +func confirmDestructive(ctx context.Context, ioStreams cmdutil.IOStreams, prompter tui.Prompter, heading, detail, prompt string) (bool, error) { + warn := lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) + _, _ = fmt.Fprintf(ioStreams.ErrOut, "\n %s %s\n", warn.Render("⚠"), warn.Render(heading)) + if detail != "" { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n", detail) + } + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n\n", warn.Render("This action cannot be undone.")) + confirmed, err := prompter.Confirm(ctx, prompt) + if err != nil { + if isPromptCancel(err) { + return false, nil + } + return false, err + } + return confirmed, nil +} + +// statusColor returns a lipgloss style that highlights a deployment status. +// Green: healthy/running; yellow: transitional; red: errored; dim: stopped. +func statusColor(status string) lipgloss.Style { + s := strings.ToLower(status) + switch { + case strings.Contains(s, "running"), strings.Contains(s, "active"), strings.Contains(s, "healthy"): + return lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Bold(true) // green + case strings.Contains(s, "error"), strings.Contains(s, "failed"): + return lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true) // red + case strings.Contains(s, "paused"), strings.Contains(s, "stopped"), strings.Contains(s, "offline"): + return lipgloss.NewStyle().Foreground(lipgloss.Color("8")) // dim + default: + return lipgloss.NewStyle().Foreground(lipgloss.Color("3")) // yellow for transitional + } +} diff --git a/internal/verda-cli/cmd/serverless/shared_test.go b/internal/verda-cli/cmd/serverless/shared_test.go new file mode 100644 index 0000000..1f4a9b4 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/shared_test.go @@ -0,0 +1,154 @@ +// 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 serverless + +import ( + "strings" + "testing" +) + +func TestValidateDeploymentName(t *testing.T) { + cases := []struct { + name string + input string + wantErr string // substring; "" means expect success + }{ + {"simple", "my-endpoint", ""}, + {"alphanumeric", "api1", ""}, + {"single char", "a", ""}, + {"max length", strings.Repeat("a", 63), ""}, + {"empty", "", "required"}, + {"too long", strings.Repeat("a", 64), "longer than 63"}, + {"uppercase", "My-Endpoint", "lowercase alphanumerics"}, + {"leading hyphen", "-foo", "lowercase alphanumerics"}, + {"trailing hyphen", "foo-", "lowercase alphanumerics"}, + {"underscore", "foo_bar", "lowercase alphanumerics"}, + {"slash", "foo/bar", "lowercase alphanumerics"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateDeploymentName(tc.input) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("expected success, got %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErr) + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got %q", tc.wantErr, err.Error()) + } + }) + } +} + +func TestRejectLatestTag(t *testing.T) { + cases := []struct { + name string + image string + wantErr bool + }{ + {"specific tag", "ghcr.io/org/app:v1.2", false}, + {"with digest", "ghcr.io/org/app@sha256:abc", false}, + {"explicit latest", "ghcr.io/org/app:latest", true}, + {"implicit latest", "ghcr.io/org/app", true}, + {"docker hub latest", "nginx:latest", true}, + {"docker hub implicit", "nginx", true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := rejectLatestTag(tc.image) + if tc.wantErr && err == nil { + t.Fatalf("expected :latest rejection, got nil") + } + if !tc.wantErr && err != nil { + t.Fatalf("expected success, got %v", err) + } + }) + } +} + +func TestParseEnvFlag(t *testing.T) { + cases := []struct { + name string + entry string + envType string + wantName string + wantValue string + wantErr string + }{ + {"plain", "FOO=bar", envTypePlain, "FOO", "bar", ""}, + {"with equals in value", "URL=postgres://u:p@h/db", envTypePlain, "URL", "postgres://u:p@h/db", ""}, + {"secret ref", "TOKEN=my-secret", envTypeSecret, "TOKEN", "my-secret", ""}, + {"empty value OK", "FLAG=", envTypePlain, "FLAG", "", ""}, + {"missing equals", "BAD", envTypePlain, "", "", "expected KEY=VALUE"}, + {"missing name", "=bar", envTypePlain, "", "", "expected KEY=VALUE"}, + {"lowercase name", "foo=bar", envTypePlain, "", "", "invalid env name"}, + {"leading digit", "1FOO=bar", envTypePlain, "", "", "invalid env name"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v, err := parseEnvFlag(tc.entry, tc.envType) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got %v", tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if v.Name != tc.wantName || v.ValueOrReferenceToSecret != tc.wantValue || v.Type != tc.envType { + t.Fatalf("got %+v, want name=%s value=%s type=%s", v, tc.wantName, tc.wantValue, tc.envType) + } + }) + } +} + +func TestParseSecretMountFlag(t *testing.T) { + cases := []struct { + name string + entry string + wantSecret string + wantPath string + wantErr string + }{ + {"valid", "api-key:/etc/secret/api-key", "api-key", "/etc/secret/api-key", ""}, + {"nested path", "conf:/var/lib/app/conf", "conf", "/var/lib/app/conf", ""}, + {"missing colon", "api-key", "", "", "expected SECRET:MOUNT_PATH"}, + {"empty path", "api-key:", "", "", "expected SECRET:MOUNT_PATH"}, + {"empty secret", ":/etc/foo", "", "", "expected SECRET:MOUNT_PATH"}, + {"relative path", "api-key:etc/foo", "", "", "must be absolute"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m, err := parseSecretMountFlag(tc.entry) + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("expected error containing %q, got %v", tc.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if m.SecretName != tc.wantSecret || m.MountPath != tc.wantPath || m.Type != mountTypeSecret { + t.Fatalf("got %+v, want secret=%s path=%s", m, tc.wantSecret, tc.wantPath) + } + }) + } +} diff --git a/internal/verda-cli/cmd/serverless/wire_format_test.go b/internal/verda-cli/cmd/serverless/wire_format_test.go new file mode 100644 index 0000000..76c9e6a --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wire_format_test.go @@ -0,0 +1,316 @@ +// 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. + +// Wire-format regression tests for create payloads; change assertions whenever +// request assembly changes (see cmd/serverless/CLAUDE.md). + +package serverless + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// recordingServer stores POST bodies from create endpoints for JSON assertions. +type recordingServer struct { + mu sync.Mutex + containerOK []byte + jobOK []byte + srv *httptest.Server +} + +func newRecordingServer(t *testing.T) *recordingServer { + t.Helper() + rec := &recordingServer{} + 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": "test-token", + "token_type": "Bearer", + }) + }) + + mux.HandleFunc("POST /container-deployments", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + rec.mu.Lock() + rec.containerOK = body + rec.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(verda.ContainerDeployment{ + Name: "cli-test", + EndpointBaseURL: "https://containers.verda.test/cli-test", + }) + }) + + mux.HandleFunc("POST /job-deployments", func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + rec.mu.Lock() + rec.jobOK = body + rec.mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(verda.JobDeployment{Name: "cli-test-job"}) + }) + + rec.srv = httptest.NewServer(mux) + t.Cleanup(rec.srv.Close) + return rec +} + +func (r *recordingServer) containerBody() []byte { + r.mu.Lock() + defer r.mu.Unlock() + return r.containerOK +} + +func (r *recordingServer) jobBody() []byte { + r.mu.Lock() + defer r.mu.Unlock() + return r.jobOK +} + +func newTestFactory(t *testing.T, baseURL string) *cmdutil.TestFactory { + t.Helper() + client, err := verda.NewClient( + verda.WithBaseURL(baseURL), + verda.WithClientID("test"), + verda.WithClientSecret("test"), + ) + if err != nil { + t.Fatalf("verda.NewClient: %v", err) + } + return &cmdutil.TestFactory{ + ClientOverride: client, + OutputFormatOverride: "json", + AgentModeOverride: true, // skip wizard + confirm prompt + } +} + +// TestContainerCreate_WireFormat runs `verda container create --agent ...` +// against an in-process server and asserts the JSON the CLI actually sends. +// This is the test that would have caught the production bug where the CLI +// sent type:"shared" with size_in_mb and the API rejected it with +// `volume_mounts.0.volume_id should not be null or undefined`. +func TestContainerCreate_WireFormat(t *testing.T) { + t.Parallel() + rec := newRecordingServer(t) + f := newTestFactory(t, rec.srv.URL) + + var stdout, stderr bytes.Buffer + cmd := NewCmdContainer(f, cmdutil.IOStreams{Out: &stdout, ErrOut: &stderr}) + cmd.SetArgs([]string{ + "create", + "--name", "cli-test", + "--image", "ghcr.io/org/app:v1.2", + "--compute", "RTX4500Ada", "--compute-size", "1", + "--yes", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("container create failed: %v\nstderr:\n%s", err, stderr.String()) + } + + body := rec.containerBody() + if body == nil { + t.Fatalf("server did not receive POST /container-deployments\nstderr:\n%s", stderr.String()) + } + + var got struct { + Name string `json:"name"` + IsSpot bool `json:"is_spot"` + Containers []struct { + Image string `json:"image"` + ExposedPort int `json:"exposed_port"` + VolumeMounts []struct { + Type string `json:"type"` + MountPath string `json:"mount_path"` + SizeInMB int `json:"size_in_mb"` + VolumeID string `json:"volume_id"` + } `json:"volume_mounts"` + } `json:"containers"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal request body: %v\nbody: %s", err, body) + } + + if got.Name != "cli-test" { + t.Errorf("name: got %q, want cli-test", got.Name) + } + if len(got.Containers) != 1 { + t.Fatalf("containers: got %d, want 1", len(got.Containers)) + } + c := got.Containers[0] + if c.Image != "ghcr.io/org/app:v1.2" { + t.Errorf("image: got %q", c.Image) + } + if len(c.VolumeMounts) != 1 { + t.Fatalf("volume_mounts: got %d entries, want exactly 1 (scratch /data)\nbody: %s", len(c.VolumeMounts), body) + } + m := c.VolumeMounts[0] + if m.Type != "scratch" { + t.Errorf("volume_mounts[0].type: got %q, want %q — sending %q tells the API this is a named persistent volume and it will reject the request looking for volume_id", m.Type, "scratch", m.Type) + } + if m.MountPath != "/data" { + t.Errorf("volume_mounts[0].mount_path: got %q, want /data", m.MountPath) + } + if m.SizeInMB != 0 { + t.Errorf("volume_mounts[0].size_in_mb: got %d, want 0 — scratch is server-allocated; sending a size makes the API treat the mount as named/shared", m.SizeInMB) + } + if m.VolumeID != "" { + t.Errorf("volume_mounts[0].volume_id: got %q, want empty", m.VolumeID) + } + + // Stdout in agent mode is JSON; verify it parsed the synthetic response. + if !strings.Contains(stdout.String(), "cli-test") { + t.Errorf("stdout should contain deployment name; got:\n%s", stdout.String()) + } +} + +// TestBatchjobCreate_WireFormat is the batchjob counterpart. Same volume_mounts +// contract; additionally asserts deadline_seconds and that IsSpot is NOT sent +// (the API has no IsSpot field on job deployments). +func TestBatchjobCreate_WireFormat(t *testing.T) { + t.Parallel() + rec := newRecordingServer(t) + f := newTestFactory(t, rec.srv.URL) + + var stdout, stderr bytes.Buffer + cmd := NewCmdBatchjob(f, cmdutil.IOStreams{Out: &stdout, ErrOut: &stderr}) + cmd.SetArgs([]string{ + "create", + "--name", "cli-test-job", + "--image", "ghcr.io/org/embedder:v1", + "--compute", "RTX4500Ada", "--compute-size", "1", + "--deadline", "30m", + "--yes", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("batchjob create failed: %v\nstderr:\n%s", err, stderr.String()) + } + + body := rec.jobBody() + if body == nil { + t.Fatalf("server did not receive POST /job-deployments\nstderr:\n%s", stderr.String()) + } + + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatalf("unmarshal request body: %v\nbody: %s", err, body) + } + if _, present := raw["is_spot"]; present { + t.Errorf("job request must not include is_spot (API has no IsSpot field for jobs); body: %s", body) + } + + var got struct { + Name string `json:"name"` + Scaling *struct { + DeadlineSeconds int `json:"deadline_seconds"` + MaxReplicaCount int `json:"max_replica_count"` + } `json:"scaling"` + Containers []struct { + Image string `json:"image"` + VolumeMounts []struct { + Type string `json:"type"` + MountPath string `json:"mount_path"` + SizeInMB int `json:"size_in_mb"` + VolumeID string `json:"volume_id"` + } `json:"volume_mounts"` + } `json:"containers"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal typed: %v\nbody: %s", err, body) + } + + if got.Name != "cli-test-job" { + t.Errorf("name: got %q, want cli-test-job", got.Name) + } + if got.Scaling == nil || got.Scaling.DeadlineSeconds != 30*60 { + t.Errorf("scaling.deadline_seconds: got %+v, want 1800", got.Scaling) + } + if len(got.Containers) != 1 || len(got.Containers[0].VolumeMounts) != 1 { + t.Fatalf("expected 1 container with 1 volume_mount; body: %s", body) + } + m := got.Containers[0].VolumeMounts[0] + if m.Type != "scratch" || m.MountPath != "/data" { + t.Errorf("volume_mounts[0]: got {type:%q path:%q}, want {scratch /data}", m.Type, m.MountPath) + } + if m.SizeInMB != 0 || m.VolumeID != "" { + t.Errorf("scratch mount must not send size_in_mb or volume_id; got size=%d volume_id=%q", m.SizeInMB, m.VolumeID) + } +} + +// TestContainerCreate_SecretMountWireFormat covers the second branch of +// buildVolumeMounts: with one --secret-mount flag, the request should contain +// two mounts — the auto scratch /data and the secret mount. +func TestContainerCreate_SecretMountWireFormat(t *testing.T) { + t.Parallel() + rec := newRecordingServer(t) + f := newTestFactory(t, rec.srv.URL) + + var stdout, stderr bytes.Buffer + cmd := NewCmdContainer(f, cmdutil.IOStreams{Out: &stdout, ErrOut: &stderr}) + cmd.SetArgs([]string{ + "create", + "--name", "cli-test", + "--image", "ghcr.io/org/app:v1.2", + "--compute", "RTX4500Ada", "--compute-size", "1", + "--secret-mount", "my-secret:/etc/creds/token", + "--yes", + }) + if err := cmd.Execute(); err != nil { + t.Fatalf("container create failed: %v\nstderr:\n%s", err, stderr.String()) + } + + body := rec.containerBody() + var got struct { + Containers []struct { + VolumeMounts []struct { + Type string `json:"type"` + MountPath string `json:"mount_path"` + SecretName string `json:"secret_name"` + } `json:"volume_mounts"` + } `json:"containers"` + } + if err := json.Unmarshal(body, &got); err != nil { + t.Fatalf("unmarshal: %v\nbody: %s", err, body) + } + mounts := got.Containers[0].VolumeMounts + if len(mounts) != 2 { + t.Fatalf("expected 2 mounts (scratch + secret), got %d: body=%s", len(mounts), body) + } + if mounts[0].Type != "scratch" || mounts[0].MountPath != "/data" { + t.Errorf("first mount must be scratch /data, got %+v", mounts[0]) + } + if mounts[1].Type != "secret" || mounts[1].SecretName != "my-secret" || mounts[1].MountPath != "/etc/creds/token" { + t.Errorf("second mount must be the secret, got %+v", mounts[1]) + } +} diff --git a/internal/verda-cli/cmd/serverless/wizard.go b/internal/verda-cli/cmd/serverless/wizard.go new file mode 100644 index 0000000..6ec4d83 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard.go @@ -0,0 +1,324 @@ +// 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 serverless + +import ( + "context" + "errors" + "strconv" + "strings" + "time" + + "github.com/verda-cloud/verdagostack/pkg/tui/wizard" +) + +// Container-specific wizard steps. Steps shared with batchjob live in +// wizard_shared.go; the container flow below wires them up together with +// container-only fields (spot, healthcheck, min replicas, concurrency, +// queue-load presets, CPU/GPU util triggers, scale-up/down delays). + +const ( + computeTypeOnDemand = "on-demand" + computeTypeSpot = "spot" + + healthcheckOn = "on" + healthcheckOff = "off" + + utilOff = "off" + + registryPublicValue = "__public__" +) + +// buildContainerCreateFlow returns the full wizard flow for `verda container +// create`. Every step has a matching flag on containerCreateOptions, +// so the same opts struct drives both the wizard and the non-interactive path. +// The final deploy confirmation is NOT a wizard step — the caller prints the +// summary and runs a bare Confirm after the flow returns. +func buildContainerCreateFlow(_ context.Context, getClient clientFunc, opts *containerCreateOptions) *wizard.Flow { + cache := &apiCache{} + return &wizard.Flow{ + Name: "container-create", + Steps: []wizard.Step{ + stepName(&opts.Name), + stepContainerComputeType(&opts.Spot), + stepCompute(getClient, cache, &opts.Compute), + stepComputeSize(&opts.ComputeSize), + stepImage(&opts.Image), + stepRegistryCreds(getClient, cache, &opts.RegistryCreds), + stepPort(&opts.Port), + stepContainerHealthcheck(&opts.HealthcheckOff), + stepContainerHealthcheckPath(&opts.HealthcheckPath), + stepEnvVars(&opts.Env), + stepContainerMinReplicas(&opts.MinReplicas), + stepMaxReplicas(&opts.MaxReplicas), + stepContainerConcurrency(&opts.Concurrency), + stepContainerQueuePreset(&opts.QueuePreset), + stepContainerQueueLoadCustom(&opts.QueueLoad), + stepContainerCPUUtil(&opts.CPUUtil), + stepContainerGPUUtil(&opts.GPUUtil), + stepContainerScaleUpDelay(&opts.ScaleUpDelay), + stepContainerScaleDownDelay(&opts.ScaleDownDelay), + stepRequestTTL(&opts.RequestTTL), + stepSecretMounts(getClient, cache, &opts.SecretMounts), + }, + } +} + +// --- Compute type (on-demand | spot) --- + +func stepContainerComputeType(spot *bool) wizard.Step { + return wizard.Step{ + Name: "compute-type", + Description: "Compute type", + Prompt: wizard.SelectPrompt, + Required: true, + Loader: wizard.StaticChoices( + wizard.Choice{Label: "On-Demand", Value: computeTypeOnDemand, Description: "Dedicated compute; runs until paused or deleted"}, + wizard.Choice{Label: "Spot", Value: computeTypeSpot, Description: "Lower price; may be reclaimed at any time"}, + ), + Default: func(_ map[string]any) any { + if *spot { + return computeTypeSpot + } + return computeTypeOnDemand + }, + Setter: func(v any) { *spot = v.(string) == computeTypeSpot }, + Resetter: func() { *spot = false }, + IsSet: func() bool { return false }, + Value: func() any { + if *spot { + return computeTypeSpot + } + return computeTypeOnDemand + }, + } +} + +// --- Healthcheck (on/off + port + path) --- + +func stepContainerHealthcheck(off *bool) wizard.Step { + return wizard.Step{ + Name: "healthcheck", + Description: "Healthcheck", + Prompt: wizard.SelectPrompt, + Required: true, + Loader: wizard.StaticChoices( + wizard.Choice{Label: "On", Value: healthcheckOn, Description: "Probe the container before routing requests"}, + wizard.Choice{Label: "Off", Value: healthcheckOff, Description: "Route requests immediately"}, + ), + Default: func(_ map[string]any) any { + if *off { + return healthcheckOff + } + return healthcheckOn + }, + Setter: func(v any) { *off = v.(string) == healthcheckOff }, + Resetter: func() { *off = false }, + IsSet: func() bool { return false }, + Value: func() any { + if *off { + return healthcheckOff + } + return healthcheckOn + }, + } +} + +// Note: there is no "healthcheck port" wizard step. The wire defaults the +// healthcheck port to the exposed port (see request() in container_create.go, +// `hcPort = o.Port` when HealthcheckPort == 0). Power users who need a +// different probe port can still pass --healthcheck-port. Removing the step +// from the interactive flow eliminated a confusing prompt where typing the +// path (e.g. "/health") was silently rejected by the int validator and +// re-prompted with no error. + +func stepContainerHealthcheckPath(path *string) wizard.Step { + return wizard.Step{ + Name: "healthcheck-path", + Description: "Healthcheck path", + Prompt: wizard.TextInputPrompt, + Required: false, + DependsOn: []string{"healthcheck"}, + ShouldSkip: func(c map[string]any) bool { + return c["healthcheck"] == healthcheckOff + }, + Default: func(_ map[string]any) any { return *path }, + Setter: func(v any) { *path = strings.TrimSpace(v.(string)) }, + Resetter: func() { *path = defaultHealthcheckPath }, + IsSet: func() bool { return false }, + Value: func() any { return *path }, + } +} + +// --- Min replicas (container-only; batchjob has no min) --- + +func stepContainerMinReplicas(target *int) wizard.Step { + return wizard.Step{ + Name: "min-replicas", + Description: "Min replicas (0 = scale-to-zero)", + Prompt: wizard.TextInputPrompt, + Required: false, + Default: func(_ map[string]any) any { return strconv.Itoa(*target) }, + Validate: parseNonNegativeIntValidator("min replicas"), + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + *target = n + }, + Resetter: func() { *target = 0 }, + IsSet: func() bool { return false }, + Value: func() any { return strconv.Itoa(*target) }, + } +} + +// --- Concurrency --- + +func stepContainerConcurrency(target *int) wizard.Step { + return wizard.Step{ + Name: "concurrency", + Description: "Concurrent requests per replica (1 for image-gen, higher for LLMs)", + Prompt: wizard.TextInputPrompt, + Required: true, + Default: func(_ map[string]any) any { return strconv.Itoa(*target) }, + Validate: parsePositiveIntValidator("concurrency"), + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + *target = n + }, + Resetter: func() { *target = defaultConcurrency }, + IsSet: func() bool { return false }, + Value: func() any { return strconv.Itoa(*target) }, + } +} + +// --- Queue-load preset + custom value --- + +func stepContainerQueuePreset(target *string) wizard.Step { + return wizard.Step{ + Name: "queue-preset", + Description: "Queue-load preset", + Prompt: wizard.SelectPrompt, + Required: true, + Loader: wizard.StaticChoices( + wizard.Choice{Label: "Instant", Value: presetInstant, Description: "Scale up as soon as any request arrives. Minimizes time in queue."}, + wizard.Choice{Label: "Balanced", Value: presetBalanced, Description: "Short queue wait before scaling up. Good for most APIs."}, + wizard.Choice{Label: "Cost saver", Value: presetCostSaver, Description: "Fewer replicas; requests may wait longer in queue."}, + wizard.Choice{Label: "Custom", Value: presetCustom, Description: "Specify a queue-load threshold yourself."}, + ), + Default: func(_ map[string]any) any { return *target }, + Setter: func(v any) { *target = v.(string) }, + Resetter: func() { *target = presetBalanced }, + IsSet: func() bool { return false }, + Value: func() any { return *target }, + } +} + +func stepContainerQueueLoadCustom(target *int) wizard.Step { + return wizard.Step{ + Name: "queue-load-custom", + Description: "Custom queue-load threshold (1..1000)", + Prompt: wizard.TextInputPrompt, + Required: true, + DependsOn: []string{"queue-preset"}, + ShouldSkip: func(c map[string]any) bool { + return c["queue-preset"] != presetCustom + }, + Default: func(_ map[string]any) any { + if *target > 0 { + return strconv.Itoa(*target) + } + return strconv.Itoa(queueLoadBalanced) + }, + Validate: func(v any) error { + n, err := strconv.Atoi(strings.TrimSpace(v.(string))) + if err != nil || n < 1 || n > 1000 { + return errors.New("must be an integer in 1..1000") + } + return nil + }, + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + *target = n + }, + Resetter: func() { *target = 0 }, + IsSet: func() bool { return *target > 0 }, + Value: func() any { return strconv.Itoa(*target) }, + } +} + +// --- Utilization triggers (CPU/GPU) --- + +func stepContainerCPUUtil(target *int) wizard.Step { + return utilThresholdStep("cpu-util", "CPU utilization trigger", target) +} + +func stepContainerGPUUtil(target *int) wizard.Step { + return utilThresholdStep("gpu-util", "GPU utilization trigger", target) +} + +// utilThresholdStep builds a step that asks "off | " as a text +// input. An empty value or "off" maps to 0; any integer in 1..100 enables +// the trigger at that threshold. Shared by CPU and GPU util steps. +func utilThresholdStep(name, desc string, target *int) wizard.Step { + return wizard.Step{ + Name: name, + Description: desc + " (blank = off; else 1..100)", + Prompt: wizard.TextInputPrompt, + Required: false, + Default: func(_ map[string]any) any { + if *target > 0 { + return strconv.Itoa(*target) + } + return "" + }, + Validate: func(v any) error { + s := strings.TrimSpace(v.(string)) + if s == "" || strings.EqualFold(s, utilOff) { + return nil + } + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > 100 { + return errors.New("must be blank, 'off', or an integer in 1..100") + } + return nil + }, + Setter: func(v any) { + s := strings.TrimSpace(v.(string)) + if s == "" || strings.EqualFold(s, utilOff) { + *target = 0 + return + } + n, _ := strconv.Atoi(s) + *target = n + }, + Resetter: func() { *target = 0 }, + IsSet: func() bool { return false }, + Value: func() any { + if *target > 0 { + return strconv.Itoa(*target) + } + return "" + }, + } +} + +// --- Scale-up / scale-down delays --- + +func stepContainerScaleUpDelay(target *time.Duration) wizard.Step { + return durationStep("scale-up-delay", "Scale-up delay", target, 0) +} + +func stepContainerScaleDownDelay(target *time.Duration) wizard.Step { + return durationStep("scale-down-delay", "Scale-down delay", target, defaultScaleDownDelay) +} diff --git a/internal/verda-cli/cmd/serverless/wizard_batchjob.go b/internal/verda-cli/cmd/serverless/wizard_batchjob.go new file mode 100644 index 0000000..90e95bb --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard_batchjob.go @@ -0,0 +1,83 @@ +// 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 serverless + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/verda-cloud/verdagostack/pkg/tui/wizard" +) + +// buildBatchjobCreateFlow returns the wizard flow for `verda batchjob create`. +// It reuses nine of the ten steps from the container +// wizard and adds the single batchjob-specific step (deadline). Jobs never +// use spot and have no min/max-replica range, no scaling triggers, no +// concurrency, no healthcheck — so those container-wizard steps are simply +// not included here. +func buildBatchjobCreateFlow(_ context.Context, getClient clientFunc, opts *batchjobCreateOptions) *wizard.Flow { + cache := &apiCache{} + return &wizard.Flow{ + Name: "batchjob-create", + Steps: []wizard.Step{ + stepName(&opts.Name), + stepCompute(getClient, cache, &opts.Compute), + stepComputeSize(&opts.ComputeSize), + stepImage(&opts.Image), + stepRegistryCreds(getClient, cache, &opts.RegistryCreds), + stepPort(&opts.Port), + stepEnvVars(&opts.Env), + stepMaxReplicas(&opts.MaxReplicas), + stepBatchjobDeadline(&opts.Deadline), + stepRequestTTL(&opts.RequestTTL), + stepSecretMounts(getClient, cache, &opts.SecretMounts), + }, + } +} + +// stepBatchjobDeadline asks for the per-request deadline. Required (> 0); +// `JobScalingOptions.DeadlineSeconds` is rejected server-side when missing, +// so we enforce it client-side for a friendlier error. +func stepBatchjobDeadline(target *time.Duration) wizard.Step { + return wizard.Step{ + Name: "deadline", + Description: "Per-request deadline (e.g. 5m, 30m, 1h) — required", + Prompt: wizard.TextInputPrompt, + Required: true, + Default: func(_ map[string]any) any { + if *target > 0 { + return target.String() + } + return "5m" + }, + Validate: func(v any) error { + s := strings.TrimSpace(v.(string)) + d, err := time.ParseDuration(s) + if err != nil || d <= 0 { + return errors.New("deadline must be a positive duration (e.g. 5m, 30m, 1h)") + } + return nil + }, + Setter: func(v any) { + d, _ := time.ParseDuration(strings.TrimSpace(v.(string))) + *target = d + }, + Resetter: func() { *target = 0 }, + IsSet: func() bool { return *target > 0 }, + Value: func() any { return target.String() }, + } +} diff --git a/internal/verda-cli/cmd/serverless/wizard_cache.go b/internal/verda-cli/cmd/serverless/wizard_cache.go new file mode 100644 index 0000000..006deaf --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard_cache.go @@ -0,0 +1,124 @@ +// 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 serverless + +import ( + "context" + "fmt" + + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui" +) + +// 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. +// 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 +} + +// clientFunc lazily resolves a Verda API client. Early wizard steps (name, +// image, port, replicas) run without credentials; the client is dialed only +// when an API-dependent step fires. +type clientFunc func() (*verda.Client, error) + +// apiCache holds data fetched during a wizard session so back-navigation +// doesn't trigger redundant API calls. All fields are populated lazily. +type apiCache struct { + computeResources []verda.ComputeResource + registryCreds []verda.RegistryCredentials + secrets []verda.Secret + fileSecrets []verda.FileSecret +} + +func (c *apiCache) fetchComputeResources(ctx context.Context, getClient clientFunc) ([]verda.ComputeResource, error) { + if c.computeResources != nil { + return c.computeResources, nil + } + client, err := getClient() + if err != nil { + return nil, err + } + res, err := client.ContainerDeployments.GetServerlessComputeResources(ctx) + if err != nil { + return nil, fmt.Errorf("fetching compute resources: %w", err) + } + c.computeResources = res + return res, nil +} + +func (c *apiCache) fetchRegistryCreds(ctx context.Context, getClient clientFunc) ([]verda.RegistryCredentials, error) { + if c.registryCreds != nil { + return c.registryCreds, nil + } + client, err := getClient() + if err != nil { + return nil, err + } + res, err := client.ContainerDeployments.GetRegistryCredentials(ctx) + if err != nil { + return nil, fmt.Errorf("fetching registry credentials: %w", err) + } + c.registryCreds = res + return res, nil +} + +func (c *apiCache) fetchSecrets(ctx context.Context, getClient clientFunc) ([]verda.Secret, error) { + if c.secrets != nil { + return c.secrets, nil + } + client, err := getClient() + if err != nil { + return nil, err + } + res, err := client.ContainerDeployments.GetSecrets(ctx) + if err != nil { + return nil, fmt.Errorf("fetching secrets: %w", err) + } + c.secrets = res + return res, nil +} + +func (c *apiCache) fetchFileSecrets(ctx context.Context, getClient clientFunc) ([]verda.FileSecret, error) { + if c.fileSecrets != nil { + return c.fileSecrets, nil + } + client, err := getClient() + if err != nil { + return nil, err + } + res, err := client.ContainerDeployments.GetFileSecrets(ctx) + if err != nil { + return nil, fmt.Errorf("fetching file secrets: %w", err) + } + c.fileSecrets = res + return res, nil +} diff --git a/internal/verda-cli/cmd/serverless/wizard_shared.go b/internal/verda-cli/cmd/serverless/wizard_shared.go new file mode 100644 index 0000000..5419b82 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard_shared.go @@ -0,0 +1,405 @@ +// 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 serverless + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "time" + + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui" + "github.com/verda-cloud/verdagostack/pkg/tui/wizard" +) + +// This file holds step builders and helpers shared between the container and +// batchjob create wizards. Each builder takes a pointer to the field it +// mutates so the same step definition drives both `containerCreateOptions` +// and `batchjobCreateOptions` without an interface layer. + +// --- Deployment name --- + +func stepName(target *string) wizard.Step { + return wizard.Step{ + Name: "name", + Description: "Deployment name (URL slug, immutable)", + Prompt: wizard.TextInputPrompt, + Required: true, + Default: func(_ map[string]any) any { return *target }, + Validate: func(v any) error { + return validateDeploymentName(strings.TrimSpace(v.(string))) + }, + Setter: func(v any) { *target = strings.TrimSpace(v.(string)) }, + Resetter: func() { *target = "" }, + IsSet: func() bool { return *target != "" }, + Value: func() any { return *target }, + } +} + +// --- Container image --- + +func stepImage(target *string) wizard.Step { + return wizard.Step{ + Name: "image", + Description: "Container image (e.g. ghcr.io/org/app:v1.2)", + Prompt: wizard.TextInputPrompt, + Required: true, + Default: func(_ map[string]any) any { return *target }, + Validate: func(v any) error { + img := strings.TrimSpace(v.(string)) + if img == "" { + return errors.New("image is required") + } + return rejectLatestTag(img) + }, + Setter: func(v any) { *target = strings.TrimSpace(v.(string)) }, + Resetter: func() { *target = "" }, + IsSet: func() bool { return *target != "" }, + Value: func() any { return *target }, + } +} + +// --- Compute resource (/serverless-compute-resources) --- + +func stepCompute(getClient clientFunc, cache *apiCache, target *string) wizard.Step { + return wizard.Step{ + Name: "compute", + Description: "Compute resource", + Prompt: wizard.SelectPrompt, + Required: true, + Loader: func(ctx context.Context, _ tui.Prompter, status tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + res, err := withFetchSpinner(ctx, status, "Fetching compute resources…", func(ctx context.Context) ([]verda.ComputeResource, error) { + return cache.fetchComputeResources(ctx, getClient) + }) + if err != nil { + return nil, err + } + choices := make([]wizard.Choice, 0, len(res)) + for i := range res { + r := &res[i] + desc := "available" + if !r.IsAvailable { + desc = "unavailable" + } + choices = append(choices, wizard.Choice{ + Label: fmt.Sprintf("%s (size %d)", r.Name, r.Size), + Value: r.Name, + Description: desc, + }) + } + if len(choices) == 0 { + return nil, errors.New("no serverless compute resources available") + } + return choices, nil + }, + Default: func(_ map[string]any) any { return *target }, + Setter: func(v any) { *target = v.(string) }, + Resetter: func() { *target = "" }, + IsSet: func() bool { return *target != "" }, + Value: func() any { return *target }, + } +} + +// --- Compute size (count of GPUs or vCPUs per replica) --- + +func stepComputeSize(target *int) wizard.Step { + return wizard.Step{ + Name: "compute-size", + Description: "Compute size (GPUs or vCPUs per replica)", + Prompt: wizard.TextInputPrompt, + Required: true, + Default: func(_ map[string]any) any { + if *target > 0 { + return strconv.Itoa(*target) + } + return "1" + }, + Validate: parsePositiveIntValidator("compute size"), + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + *target = n + }, + Resetter: func() { *target = 0 }, + IsSet: func() bool { return *target > 0 }, + Value: func() any { return strconv.Itoa(*target) }, + } +} + +// --- Registry credentials --- + +func stepRegistryCreds(getClient clientFunc, cache *apiCache, target *string) wizard.Step { + return wizard.Step{ + Name: "registry-creds", + Description: "Registry credentials (for private images)", + Prompt: wizard.SelectPrompt, + Required: false, + Loader: func(ctx context.Context, _ tui.Prompter, status tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + choices := []wizard.Choice{ + {Label: "Public (no credentials)", Value: registryPublicValue}, + } + creds, err := withFetchSpinner(ctx, status, "Fetching registry credentials…", func(ctx context.Context) ([]verda.RegistryCredentials, error) { + return cache.fetchRegistryCreds(ctx, getClient) + }) + if err != nil { + // Non-fatal: offer public-only and let the user continue. + return choices, nil //nolint:nilerr // degrade gracefully on missing permissions + } + for _, c := range creds { + // Skip any credential whose name collides with the public sentinel + // (registry naming likely forbids underscores, but cheap insurance). + if c.Name == registryPublicValue { + continue + } + choices = append(choices, wizard.Choice{Label: c.Name, Value: c.Name}) + } + return choices, nil + }, + Default: func(_ map[string]any) any { + if *target == "" { + return registryPublicValue + } + return *target + }, + Setter: func(v any) { + s := v.(string) + if s == registryPublicValue { + *target = "" + return + } + *target = s + }, + Resetter: func() { *target = "" }, + IsSet: func() bool { return *target != "" }, + Value: func() any { + if *target == "" { + return registryPublicValue + } + return *target + }, + } +} + +// --- Exposed HTTP port --- + +func stepPort(target *int) wizard.Step { + return wizard.Step{ + Name: "port", + Description: "Exposed HTTP port", + Prompt: wizard.TextInputPrompt, + Required: true, + Default: func(_ map[string]any) any { return strconv.Itoa(*target) }, + Validate: parsePortValidator("port"), + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + *target = n + }, + Resetter: func() { *target = defaultExposedPort }, + IsSet: func() bool { return false }, + Value: func() any { return strconv.Itoa(*target) }, + } +} + +// --- Env vars (loop) --- + +// stepEnvVars and stepSecretMounts use a loop-style step: the Loader runs its +// own inner prompt loop (Confirm + sub-flow) and returns (nil, nil), so the +// engine has no SelectPrompt choices to render. Setter/Value are no-ops and +// IsSet reports whether the loop ran at least once. The Prompt type stays +// SelectPrompt because the wizard engine needs *some* prompt class declared, +// and this one is the cheapest no-op path. +func stepEnvVars(target *[]string) wizard.Step { + return wizard.Step{ + Name: "env-vars", + Description: "Environment variables (optional)", + Prompt: wizard.SelectPrompt, + Required: false, + Loader: func(ctx context.Context, prompter tui.Prompter, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + for { + add, err := prompter.Confirm(ctx, fmt.Sprintf("Add environment variable? (have %d)", len(*target)), tui.WithConfirmDefault(false)) + if err != nil { + if isPromptCancel(err) { + return nil, nil + } + return nil, err + } + if !add { + return nil, nil + } + entry, err := promptEnvVar(ctx, prompter) + if err != nil { + return nil, err + } + if entry == nil { + continue + } + *target = append(*target, entry.Name+"="+entry.ValueOrReferenceToSecret) + } + }, + Setter: func(_ any) {}, + Resetter: func() {}, + IsSet: func() bool { return len(*target) > 0 }, + Value: func() any { return "" }, + } +} + +// --- Max replicas --- + +func stepMaxReplicas(target *int) wizard.Step { + return wizard.Step{ + Name: "max-replicas", + Description: "Max replicas", + Prompt: wizard.TextInputPrompt, + Required: true, + Default: func(_ map[string]any) any { return strconv.Itoa(*target) }, + Validate: parsePositiveIntValidator("max replicas"), + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + *target = n + }, + Resetter: func() { *target = defaultMaxReplicas }, + IsSet: func() bool { return false }, + Value: func() any { return strconv.Itoa(*target) }, + } +} + +// --- Request TTL --- + +func stepRequestTTL(target *time.Duration) wizard.Step { + return durationStep("request-ttl", "Request time-to-live (pending queue)", target, defaultRequestTTL) +} + +// --- Secret mounts (loop) --- + +func stepSecretMounts(getClient clientFunc, cache *apiCache, target *[]string) wizard.Step { + return wizard.Step{ + Name: "secret-mounts", + Description: "Secret mounts (optional)", + Prompt: wizard.SelectPrompt, + Required: false, + Loader: func(ctx context.Context, prompter tui.Prompter, status tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + for { + add, err := prompter.Confirm(ctx, fmt.Sprintf("Add a secret mount? (have %d)", len(*target)), tui.WithConfirmDefault(false)) + if err != nil { + if isPromptCancel(err) { + return nil, nil + } + return nil, err + } + if !add { + return nil, nil + } + secrets, _ := withFetchSpinner(ctx, status, "Fetching secrets…", func(ctx context.Context) ([]verda.Secret, error) { + return cache.fetchSecrets(ctx, getClient) + }) + fileSecrets, _ := withFetchSpinner(ctx, status, "Fetching file secrets…", func(ctx context.Context) ([]verda.FileSecret, error) { + return cache.fetchFileSecrets(ctx, getClient) + }) + if len(secrets)+len(fileSecrets) == 0 { + _, _ = prompter.Confirm(ctx, "No secrets available in this project. Press Enter to continue.", tui.WithConfirmDefault(true)) + return nil, nil + } + mount, err := promptSecretMount(ctx, prompter, secrets, fileSecrets) + if err != nil { + return nil, err + } + if mount == nil { + continue + } + *target = append(*target, mount.SecretName+":"+mount.MountPath) + } + }, + Setter: func(_ any) {}, + Resetter: func() {}, + IsSet: func() bool { return len(*target) > 0 }, + Value: func() any { return "" }, + } +} + +// --- Generic builders shared by both wizards --- + +// durationStep builds a TextInput step that parses a Go duration (0s, 300s, +// 5m, ...). Empty input resets to def. +func durationStep(name, desc string, target *time.Duration, def time.Duration) wizard.Step { + return wizard.Step{ + Name: name, + Description: desc + " (e.g. 0s, 300s, 5m)", + Prompt: wizard.TextInputPrompt, + Required: false, + Default: func(_ map[string]any) any { + if *target > 0 { + return target.String() + } + return def.String() + }, + Validate: func(v any) error { + s := strings.TrimSpace(v.(string)) + if s == "" { + return nil + } + d, err := time.ParseDuration(s) + if err != nil || d < 0 { + return errors.New("must be a non-negative duration (e.g. 0s, 300s, 5m)") + } + return nil + }, + Setter: func(v any) { + s := strings.TrimSpace(v.(string)) + if s == "" { + *target = def + return + } + d, _ := time.ParseDuration(s) + *target = d + }, + Resetter: func() { *target = def }, + IsSet: func() bool { return false }, + Value: func() any { return target.String() }, + } +} + +// --- Validators (shared) --- + +func parsePositiveIntValidator(field string) func(any) error { + return func(v any) error { + n, err := strconv.Atoi(strings.TrimSpace(v.(string))) + if err != nil || n < 1 { + return fmt.Errorf("%s must be a positive integer", field) + } + return nil + } +} + +func parseNonNegativeIntValidator(field string) func(any) error { + return func(v any) error { + n, err := strconv.Atoi(strings.TrimSpace(v.(string))) + if err != nil || n < 0 { + return fmt.Errorf("%s must be an integer >= 0", field) + } + return nil + } +} + +func parsePortValidator(field string) func(any) error { + return func(v any) error { + n, err := strconv.Atoi(strings.TrimSpace(v.(string))) + if err != nil || n < 1 || n > 65535 { + return fmt.Errorf("%s must be an integer in 1..65535", field) + } + return nil + } +} diff --git a/internal/verda-cli/cmd/serverless/wizard_subflows.go b/internal/verda-cli/cmd/serverless/wizard_subflows.go new file mode 100644 index 0000000..2885301 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard_subflows.go @@ -0,0 +1,102 @@ +// 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 serverless + +import ( + "context" + "strings" + + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui" +) + +// promptEnvVar collects one environment-variable entry interactively. Returns +// (nil, nil) on user cancel or empty name so the caller can end the loop; +// real I/O or terminal errors are propagated. +func promptEnvVar(ctx context.Context, prompter tui.Prompter) (*verda.ContainerEnvVar, error) { + name, err := prompter.TextInput(ctx, "Env name (e.g. HF_HOME)") + if err != nil { + if isPromptCancel(err) { + return nil, nil + } + return nil, err + } + name = strings.TrimSpace(name) + if name == "" { + return nil, nil + } + if !envVarNameRE.MatchString(name) { + _, _ = prompter.Confirm(ctx, "Invalid env name — use uppercase, digits, underscore, no leading digit. Press Enter to continue.", tui.WithConfirmDefault(true)) + return nil, nil + } + + value, err := prompter.TextInput(ctx, "Env value") + if err != nil { + if isPromptCancel(err) { + return nil, nil + } + return nil, err + } + return &verda.ContainerEnvVar{ + Type: envTypePlain, + Name: name, + ValueOrReferenceToSecret: value, + }, nil +} + +// promptSecretMount asks the user to pick a secret (or file-secret) and a +// mount path. Returns (nil, nil) on cancel/empty to end the loop. +func promptSecretMount(ctx context.Context, prompter tui.Prompter, secrets []verda.Secret, fileSecrets []verda.FileSecret) (*verda.ContainerVolumeMount, error) { + labels := make([]string, 0, len(secrets)+len(fileSecrets)+1) + values := make([]string, 0, len(secrets)+len(fileSecrets)+1) + for _, s := range secrets { + labels = append(labels, "secret: "+s.Name) + values = append(values, s.Name) + } + for _, s := range fileSecrets { + labels = append(labels, "file-secret: "+s.Name) + values = append(values, s.Name) + } + labels = append(labels, "Cancel") + + idx, err := prompter.Select(ctx, "Select secret to mount", labels) + if err != nil { + if isPromptCancel(err) { + return nil, nil + } + return nil, err + } + if idx == len(labels)-1 { + return nil, nil + } + + mountPath, err := prompter.TextInput(ctx, "Mount path (e.g. /etc/secret/api-key)") + if err != nil { + if isPromptCancel(err) { + return nil, nil + } + return nil, err + } + mountPath = strings.TrimSpace(mountPath) + if !strings.HasPrefix(mountPath, "/") { + _, _ = prompter.Confirm(ctx, "Mount path must be absolute. Press Enter to continue.", tui.WithConfirmDefault(true)) + return nil, nil + } + return &verda.ContainerVolumeMount{ + Type: mountTypeSecret, + MountPath: mountPath, + SecretName: values[idx], + }, nil +} diff --git a/internal/verda-cli/cmd/serverless/wizard_summary.go b/internal/verda-cli/cmd/serverless/wizard_summary.go new file mode 100644 index 0000000..e5e9991 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard_summary.go @@ -0,0 +1,127 @@ +// 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 serverless + +import ( + "fmt" + "io" + "strconv" + "strings" + + "charm.land/lipgloss/v2" +) + +// renderContainerSummary prints a human-readable review card before the final +// deploy confirmation. The cost section shows a scale-to-zero range when min +// replicas is 0 — the lower bound is $0 regardless of compute choice. +func renderContainerSummary(w io.Writer, opts *containerCreateOptions) { + label := lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + header := lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true) + + _, _ = fmt.Fprintf(w, "\n %s\n", header.Render("Deployment summary")) + + kv := func(k, v string) { + _, _ = fmt.Fprintf(w, " %-22s %s\n", label.Render(k), v) + } + + kv("Name", opts.Name) + kv("Image", opts.Image) + billing := "on-demand" + if opts.Spot { + billing = "spot" + } + kv("Billing", billing) + kv("Compute", fmt.Sprintf("%s x%d", opts.Compute, opts.ComputeSize)) + if opts.RegistryCreds != "" { + kv("Registry creds", opts.RegistryCreds) + } else { + kv("Registry creds", dim.Render("public")) + } + kv("Port", strconv.Itoa(opts.Port)) + if opts.HealthcheckOff { + kv("Healthcheck", dim.Render("disabled")) + } else { + port := opts.HealthcheckPort + if port == 0 { + port = opts.Port + } + kv("Healthcheck", fmt.Sprintf("%s on :%d", opts.HealthcheckPath, port)) + } + if n := len(opts.Env) + len(opts.EnvSecret); n > 0 { + kv("Env vars", strconv.Itoa(n)) + } + kv("Replicas", fmt.Sprintf("%d..%d", opts.MinReplicas, opts.MaxReplicas)) + kv("Concurrency", fmt.Sprintf("%d requests/replica", opts.Concurrency)) + + preset := strings.ToLower(opts.QueuePreset) + if opts.QueueLoad > 0 { + preset = fmt.Sprintf("custom (%d)", opts.QueueLoad) + } + kv("Queue-load preset", preset) + + if opts.CPUUtil > 0 { + kv("CPU util trigger", fmt.Sprintf("%d%%", opts.CPUUtil)) + } + if opts.GPUUtil > 0 { + kv("GPU util trigger", fmt.Sprintf("%d%%", opts.GPUUtil)) + } + kv("Scale delays", fmt.Sprintf("up=%s down=%s", opts.ScaleUpDelay, opts.ScaleDownDelay)) + kv("Request TTL", opts.RequestTTL.String()) + + if len(opts.SecretMounts) > 0 { + kv("Secret mounts", strconv.Itoa(len(opts.SecretMounts))) + } + kv("Storage", defaultGeneralStoragePath+" (scratch, server-allocated)") + + _, _ = fmt.Fprintln(w) +} + +// renderBatchjobSummary is the batchjob counterpart to renderContainerSummary. +// Smaller review card — no spot, no scaling triggers, no concurrency, no +// healthcheck — but calls out the deadline prominently since it's the one +// batchjob-only field. +func renderBatchjobSummary(w io.Writer, opts *batchjobCreateOptions) { + label := lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) + dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8")) + header := lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true) + + _, _ = fmt.Fprintf(w, "\n %s\n", header.Render("Batch-job summary")) + + kv := func(k, v string) { + _, _ = fmt.Fprintf(w, " %-22s %s\n", label.Render(k), v) + } + + kv("Name", opts.Name) + kv("Image", opts.Image) + kv("Compute", fmt.Sprintf("%s x%d", opts.Compute, opts.ComputeSize)) + if opts.RegistryCreds != "" { + kv("Registry creds", opts.RegistryCreds) + } else { + kv("Registry creds", dim.Render("public")) + } + kv("Port", strconv.Itoa(opts.Port)) + if n := len(opts.Env) + len(opts.EnvSecret); n > 0 { + kv("Env vars", strconv.Itoa(n)) + } + kv("Max replicas", strconv.Itoa(opts.MaxReplicas)) + kv("Deadline", opts.Deadline.String()) + kv("Request TTL", opts.RequestTTL.String()) + if len(opts.SecretMounts) > 0 { + kv("Secret mounts", strconv.Itoa(len(opts.SecretMounts))) + } + kv("Storage", defaultGeneralStoragePath+" (scratch, server-allocated)") + _, _ = fmt.Fprintln(w) +} diff --git a/internal/verda-cli/cmd/ssh/ssh.go b/internal/verda-cli/cmd/ssh/ssh.go index d29002e..4ded54f 100644 --- a/internal/verda-cli/cmd/ssh/ssh.go +++ b/internal/verda-cli/cmd/ssh/ssh.go @@ -25,6 +25,7 @@ import ( "github.com/spf13/cobra" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) @@ -198,7 +199,7 @@ func pickInstance(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt } labels = append(labels, "Cancel") - idx, err := f.Prompter().Select(ctx, "Select instance to SSH into", labels) + idx, err := f.Prompter().Select(ctx, "Select instance to SSH into", labels, tui.WithShowHints(true)) if err != nil { return "", err } diff --git a/internal/verda-cli/cmd/sshkey/delete.go b/internal/verda-cli/cmd/sshkey/delete.go index 6bfd584..dc05eda 100644 --- a/internal/verda-cli/cmd/sshkey/delete.go +++ b/internal/verda-cli/cmd/sshkey/delete.go @@ -19,6 +19,7 @@ import ( "fmt" "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) @@ -98,7 +99,7 @@ func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream } labels = append(labels, "Cancel") - idx, err := prompter.Select(ctx, "Select SSH key to delete", labels) + idx, err := prompter.Select(ctx, "Select SSH key to delete", labels, tui.WithShowHints(true)) if err != nil { return nil } diff --git a/internal/verda-cli/cmd/startupscript/add.go b/internal/verda-cli/cmd/startupscript/add.go index a3fe1d5..e0f8b86 100644 --- a/internal/verda-cli/cmd/startupscript/add.go +++ b/internal/verda-cli/cmd/startupscript/add.go @@ -105,7 +105,7 @@ func runAdd(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, sourceIdx, err := prompter.Select(ctx, "Script source", []string{ "Load from file", "Paste content", - }) + }, tui.WithShowHints(true)) if err != nil { return nil } diff --git a/internal/verda-cli/cmd/startupscript/delete.go b/internal/verda-cli/cmd/startupscript/delete.go index fbae539..bf5158f 100644 --- a/internal/verda-cli/cmd/startupscript/delete.go +++ b/internal/verda-cli/cmd/startupscript/delete.go @@ -19,6 +19,7 @@ import ( "fmt" "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) @@ -98,7 +99,7 @@ func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream } labels = append(labels, "Cancel") - idx, err := prompter.Select(ctx, "Select startup script to delete", labels) + idx, err := prompter.Select(ctx, "Select startup script to delete", labels, tui.WithShowHints(true)) if err != nil { return nil } diff --git a/internal/verda-cli/cmd/template/create.go b/internal/verda-cli/cmd/template/create.go index 7be05f6..f3aa2aa 100644 --- a/internal/verda-cli/cmd/template/create.go +++ b/internal/verda-cli/cmd/template/create.go @@ -20,6 +20,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/vm" @@ -78,7 +79,7 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream prompter := f.Prompter() // 1. Select resource type. - idx, err := prompter.Select(ctx, "Resource type", resourceTypes) + 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 } diff --git a/internal/verda-cli/cmd/template/edit.go b/internal/verda-cli/cmd/template/edit.go index 66d77e2..7c16c95 100644 --- a/internal/verda-cli/cmd/template/edit.go +++ b/internal/verda-cli/cmd/template/edit.go @@ -100,7 +100,7 @@ func runEdit(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, } labels[len(fields)] = "Save & exit" - idx, selErr := prompter.Select(ctx, "Edit field", labels, tui.WithSelectDefault(len(fields)), tui.WithPageSize(len(labels))) + 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 @@ -137,7 +137,7 @@ func buildFieldMenu(tmpl *Template) []editableField { display: func(t *Template) string { return valueOrDash(t.BillingType) }, edit: func(ctx context.Context, f cmdutil.Factory, t *Template) error { choices := []string{"on-demand", "spot"} - idx, err := f.Prompter().Select(ctx, "Billing type", choices) + idx, err := f.Prompter().Select(ctx, "Billing type", choices, tui.WithShowHints(true)) if err != nil { return nil //nolint:nilerr // user canceled } @@ -153,7 +153,7 @@ func buildFieldMenu(tmpl *Template) []editableField { display: func(t *Template) string { return valueOrDash(t.Kind) }, edit: func(ctx context.Context, f cmdutil.Factory, t *Template) error { choices := []string{"gpu", "cpu"} - idx, err := f.Prompter().Select(ctx, "Kind", choices) + idx, err := f.Prompter().Select(ctx, "Kind", choices, tui.WithShowHints(true)) if err != nil { return nil //nolint:nilerr // user canceled } @@ -303,7 +303,7 @@ func editInstanceType(ctx context.Context, f cmdutil.Factory, t *Template) error } choices = append(choices, "← Back") - idx, selErr := f.Prompter().Select(ctx, "Instance type", choices) + 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 } @@ -328,7 +328,7 @@ func editLocation(ctx context.Context, f cmdutil.Factory, t *Template) error { choices = append(choices, loc.Code) } - idx, selErr := f.Prompter().Select(ctx, "Location", choices) + idx, selErr := f.Prompter().Select(ctx, "Location", choices, tui.WithShowHints(true)) if selErr != nil { return nil //nolint:nilerr // user canceled } @@ -364,7 +364,7 @@ func editImage(ctx context.Context, f cmdutil.Factory, t *Template) error { choices = append(choices, img.Name) } - idx, selErr := f.Prompter().Select(ctx, "Image", choices) + idx, selErr := f.Prompter().Select(ctx, "Image", choices, tui.WithShowHints(true)) if selErr != nil { return nil //nolint:nilerr // user canceled } @@ -430,7 +430,7 @@ func editStartupScript(ctx context.Context, f cmdutil.Factory, t *Template) erro choices = append(choices, s.Name) } - idx, selErr := f.Prompter().Select(ctx, "Startup script", choices) + idx, selErr := f.Prompter().Select(ctx, "Startup script", choices, tui.WithShowHints(true)) if selErr != nil { return nil //nolint:nilerr // user canceled } diff --git a/internal/verda-cli/cmd/template/show.go b/internal/verda-cli/cmd/template/show.go index c1f1c31..f6e5181 100644 --- a/internal/verda-cli/cmd/template/show.go +++ b/internal/verda-cli/cmd/template/show.go @@ -21,6 +21,7 @@ import ( "strings" "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) @@ -173,7 +174,7 @@ func pickTemplateEntry(cmd *cobra.Command, f cmdutil.Factory) (*Entry, error) { labels[i] = fmt.Sprintf("%-25s %s", e.Resource+"/"+e.Name, e.Description) } - idx, err := f.Prompter().Select(cmd.Context(), "Select a template", labels) + idx, err := f.Prompter().Select(cmd.Context(), "Select a template", labels, tui.WithShowHints(true)) if err != nil { return nil, nil //nolint:nilerr // user canceled } diff --git a/internal/verda-cli/cmd/update/update.go b/internal/verda-cli/cmd/update/update.go index 0f64c28..91fbbb0 100644 --- a/internal/verda-cli/cmd/update/update.go +++ b/internal/verda-cli/cmd/update/update.go @@ -79,7 +79,7 @@ func NewCmdUpdate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { if listVersions { - return runList(cmd.Context(), ioStreams) + return runList(cmd.Context(), f, ioStreams) } if verify { info := version.Get() @@ -96,8 +96,10 @@ func NewCmdUpdate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command return cmd } -func runList(ctx context.Context, ioStreams cmdutil.IOStreams) error { - versions, err := fetchVersions(ctx) +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) + }) if err != nil { return err } @@ -126,7 +128,9 @@ func runUpdate(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStrea // Resolve target version. target := targetVersion if target == "" { - latest, err := fetchLatestVersion(ctx) + latest, err := cmdutil.WithSpinner(ctx, f.Status(), "Checking for latest version...", func() (string, error) { + return fetchLatestVersion(ctx) + }) if err != nil { return err } diff --git a/internal/verda-cli/cmd/util/agent_prompter.go b/internal/verda-cli/cmd/util/agent_prompter.go index 29520a3..ced7d3e 100644 --- a/internal/verda-cli/cmd/util/agent_prompter.go +++ b/internal/verda-cli/cmd/util/agent_prompter.go @@ -47,6 +47,20 @@ func (p *agentPrompter) MultiSelect(_ context.Context, prompt string, choices [] return nil, NewPromptBlockedError("multi_select", prompt, choices) } +func (p *agentPrompter) LiveList( + _ context.Context, + prompt string, + rows []tui.LiveRow, + _ <-chan tui.LiveListUpdate, + _ ...tui.LiveListOption, +) (int, error) { + labels := make([]string, len(rows)) + for i, r := range rows { + labels[i] = r.Label + } + return -1, NewPromptBlockedError("live_list", prompt, labels) +} + func (p *agentPrompter) Editor(_ context.Context, prompt string, _ ...tui.EditorOption) (string, error) { return "", NewPromptBlockedError("editor", prompt, nil) } diff --git a/internal/verda-cli/cmd/util/factory.go b/internal/verda-cli/cmd/util/factory.go index 8cff4c5..45083d7 100644 --- a/internal/verda-cli/cmd/util/factory.go +++ b/internal/verda-cli/cmd/util/factory.go @@ -15,9 +15,15 @@ package util import ( + "bytes" "errors" + "fmt" + "io" "net/http" + "regexp" + "sort" "strings" + "time" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" "github.com/verda-cloud/verdagostack/pkg/tui" @@ -27,6 +33,28 @@ import ( clioptions "github.com/verda-cloud/verda-cli/internal/verda-cli/options" ) +const ( + // retryMaxAttempts is the number of retries (additional attempts after the + // initial request) for transient API failures: 429, 408, 5xx. The SDK + // applies exponential backoff with jitter between attempts. + retryMaxAttempts = 3 + // retryInitialDelay is the base delay before the first retry. Doubles + // each attempt, capped at 30s by the SDK middleware. + retryInitialDelay = 200 * time.Millisecond +) + +// 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. +var sensitiveJSONFieldRe = regexp.MustCompile( + `("(?:client_secret|access_token|refresh_token|id_token|password|api_key|bearer|authorization)")(\s*:\s*)"(?:[^"\\]|\\.)*"`) + +func redactSensitiveJSON(s string) string { + return sensitiveJSONFieldRe.ReplaceAllString(s, `$1$2""`) +} + // 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. @@ -81,17 +109,89 @@ func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error return t.base.RoundTrip(req) } -// NewFactory creates a Factory from the given Options. -func NewFactory(opts *clioptions.Options) Factory { - f := &factoryImpl{ - opts: opts, - client: &http.Client{ - Timeout: opts.Timeout, - Transport: &userAgentTransport{base: http.DefaultTransport, userAgent: userAgentString()}, - }, - prompter: tui.Default(), - status: tui.DefaultStatus(), +// debugTransport logs HTTP request and response wire details to out when +// enabled() returns true. The Authorization header value is redacted. +type debugTransport struct { + base http.RoundTripper + out io.Writer + enabled func() bool +} + +func (t *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if t.enabled == nil || !t.enabled() || t.out == nil { + return t.base.RoundTrip(req) + } + + var reqBody []byte + if req.Body != nil { + b, rerr := io.ReadAll(req.Body) + _ = req.Body.Close() + if rerr != nil { + // Body already drained; sending a silently-truncated request would + // be worse than failing. Surface the read error instead. + _, _ = fmt.Fprintf(t.out, "DEBUG: error reading request body: %v\n", rerr) + return nil, rerr + } + reqBody = b + req.Body = io.NopCloser(bytes.NewReader(reqBody)) + } + + _, _ = fmt.Fprintf(t.out, "DEBUG: HTTP %s %s\n", req.Method, req.URL) + keys := make([]string, 0, len(req.Header)) + for k := range req.Header { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if strings.EqualFold(k, "Authorization") { + _, _ = fmt.Fprintf(t.out, "DEBUG: %s: \n", k) + continue + } + _, _ = 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))) + } + + resp, err := t.base.RoundTrip(req) + if err != nil { + _, _ = fmt.Fprintf(t.out, "DEBUG: HTTP error: %v\n", err) + return resp, err + } + + var respBody []byte + if resp.Body != nil { + b, rerr := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if rerr != nil { + // Body already drained; handing the SDK an empty body would decode + // to a misleading "unexpected end of JSON". Surface the real error. + _, _ = fmt.Fprintf(t.out, "DEBUG: error reading response body: %v\n", rerr) + resp.Body = http.NoBody + return resp, rerr + } + respBody = b + resp.Body = io.NopCloser(bytes.NewReader(respBody)) + } + _, _ = 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))) + } + 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 { + 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 @@ -143,6 +243,20 @@ func (f *factoryImpl) VerdaClient() (*verda.Client, error) { return nil, err } + // SDK doesn't enable retry by default. Add the exponential-backoff + // middleware so transient failures (429 rate limit, 5xx, 408, 504) + // retry transparently across all CLI commands. Auth/client errors + // (4xx except 408/429) never retry — see shouldRetry in the SDK. + // + // shouldRetry is method-agnostic, so POST creates also retry on 5xx. That's + // safe here: deployment names are unique slugs, so a retry after a partial + // commit gets 409 (4xx, non-retryable) rather than creating a duplicate. If + // a non-idempotent endpoint without a natural unique key is ever added, make + // shouldRetry method-aware in the SDK before relying on this. + client.AddRequestMiddleware(verda.ExponentialBackoffRetryMiddleware( + retryMaxAttempts, retryInitialDelay, client.Logger, + )) + f.verda = client return client, nil } diff --git a/internal/verda-cli/cmd/util/helpers.go b/internal/verda-cli/cmd/util/helpers.go index c0ab022..b7240f8 100644 --- a/internal/verda-cli/cmd/util/helpers.go +++ b/internal/verda-cli/cmd/util/helpers.go @@ -15,15 +15,64 @@ package util import ( + "context" "encoding/json" + "errors" "fmt" "io" "os" "strings" "github.com/spf13/cobra" + "github.com/verda-cloud/verdagostack/pkg/tui" ) +// IsPromptCancel reports whether err represents a clean prompter exit +// (Ctrl+C surfaces as tui.ErrInterrupted, Esc as context.Canceled) rather +// than a real failure. Real I/O errors and context deadlines should propagate. +// +// Most call sites should prefer IsPromptInterrupt / IsPromptBack so the +// two cancel keys can be handled differently — Ctrl+C is a deliberate +// "I'm done with everything", Esc is a lightweight "back / cancel this +// scope". Conflating them produces UX where the "esc back" hint surfaces +// an unexpected confirmation dialog. +func IsPromptCancel(err error) bool { + return IsPromptInterrupt(err) || IsPromptBack(err) +} + +// IsPromptInterrupt reports whether err is specifically a Ctrl+C interrupt +// from the prompter (tui.ErrInterrupted). Use this to gate exit-confirmation +// prompts — Ctrl+C is a deliberate "I want out" signal. +func IsPromptInterrupt(err error) bool { + return errors.Is(err, tui.ErrInterrupted) +} + +// IsPromptBack reports whether err is specifically an Esc / soft-cancel +// from the prompter (context.Canceled). Use this to drive "back" or +// "return to previous scope" behavior — Esc should not surface an exit +// confirmation since the hint bar already advertises "esc back". +func IsPromptBack(err error) bool { + return errors.Is(err, context.Canceled) +} + +// PromptBackOrExit renders the two-choice "Back to list / Exit" gate shown +// after a detail view in interactive list loops. Esc returns to the list +// (exit=false), Ctrl+C exits the loop (exit=true, no confirmation), and an +// explicit "Exit" selection exits. Real prompter failures propagate. +func PromptBackOrExit(ctx context.Context, prompter tui.Prompter) (exit bool, err error) { + nextIdx, nerr := prompter.Select(ctx, "", []string{"Back to list", "Exit"}, tui.WithShowHints(true)) + if nerr != nil { + if IsPromptInterrupt(nerr) { + return true, nil // Ctrl+C = exit + } + if IsPromptBack(nerr) { + return false, nil // Esc = back to list + } + return false, nerr + } + return nextIdx == 1, nil +} + // CheckErr prints a user-friendly error to stderr and exits with code 1. func CheckErr(err error) { if err == nil { diff --git a/internal/verda-cli/cmd/util/iostreams.go b/internal/verda-cli/cmd/util/iostreams.go index 0f75d0a..cd6c1f6 100644 --- a/internal/verda-cli/cmd/util/iostreams.go +++ b/internal/verda-cli/cmd/util/iostreams.go @@ -42,3 +42,9 @@ func NewStdIOStreams() IOStreams { func IsStdoutTerminal() bool { return term.IsTerminal(os.Stdout.Fd()) } + +// IsStderrTerminal returns true if stderr is a terminal. Progress/status output +// goes to stderr, so in-place (\r) rendering should be gated on this. +func IsStderrTerminal() bool { + return term.IsTerminal(os.Stderr.Fd()) +} diff --git a/internal/verda-cli/cmd/util/versionhint.go b/internal/verda-cli/cmd/util/versionhint.go index 4f5f55a..6a4638c 100644 --- a/internal/verda-cli/cmd/util/versionhint.go +++ b/internal/verda-cli/cmd/util/versionhint.go @@ -85,11 +85,8 @@ func SaveVersionCache(path string, c *VersionCache) error { return os.WriteFile(path, data, 0o644) //nolint:gosec // version cache is not sensitive } -// FetchLatestVersion queries the GitHub releases API for the latest release -// tag of verda-cli. The per-request timeout is intentionally tight (2s): the -// only callers are `doctor`, `update`, and help/root — if GitHub is slow or -// unreachable we'd rather skip the hint than make the user wait, and a live -// CLI on a reachable network comfortably returns in well under 2s. +// FetchLatestVersion returns the latest verda-cli release tag from GitHub. +// Uses a 2s timeout so slow/unreachable GitHub fails fast instead of stalling UX. func FetchLatestVersion(ctx context.Context) (string, error) { const url = "https://api.github.com/repos/verda-cloud/verda-cli/releases/latest" @@ -125,6 +122,21 @@ func FetchLatestVersion(ctx context.Context) (string, error) { return release.TagName, nil } +// CheckVersionFromCache reads ~/.verda/version-check.json only—no network. +// Empty cache yields latest "" and callers skip the hint. +func CheckVersionFromCache() (latest, current string, err error) { + cachePath, err := VersionCachePath() + if err != nil { + return "", "", err + } + cache, err := LoadVersionCache(cachePath) + if err != nil { + return "", "", err + } + current = currentVersion() + return cache.LatestVersion, current, nil +} + // CheckVersion loads the version cache, fetches if stale (24h TTL), saves the // cache, and returns the latest and current versions. On fetch error it falls // back to the cached value. @@ -154,17 +166,24 @@ func CheckVersion(ctx context.Context) (latest, current string, err error) { cache.CheckedAt = time.Now() _ = SaveVersionCache(cachePath, cache) // best-effort } - // fetchErr != nil && cache.LatestVersion != "": fall back to cached value + // Stale fetch with retained cache: keep serving cached latest. } - current = version.Get().GitVersion - if !strings.HasPrefix(current, "v") { - current = "v" + current - } + current = currentVersion() return latest, current, nil } +// currentVersion returns the running CLI version, ensuring a "v" prefix so it +// compares cleanly against tag names from the GitHub releases API. +func currentVersion() string { + v := version.Get().GitVersion + if !strings.HasPrefix(v, "v") { + v = "v" + v + } + return v +} + // PrintVersionHint prints an update hint to w if latest > current. func PrintVersionHint(w io.Writer, latest, current string) { if CompareVersions(latest, current) > 0 { @@ -178,7 +197,7 @@ func CompareVersions(a, b string) int { aParts := parseSemver(a) bParts := parseSemver(b) - for i := 0; i < 3; i++ { + for i := range 3 { if aParts[i] < bParts[i] { return -1 } diff --git a/internal/verda-cli/cmd/verda-logo.png b/internal/verda-cli/cmd/verda-logo.png new file mode 100644 index 0000000..12a4aba Binary files /dev/null and b/internal/verda-cli/cmd/verda-logo.png differ diff --git a/internal/verda-cli/cmd/vm/action.go b/internal/verda-cli/cmd/vm/action.go index d27cc42..b592679 100644 --- a/internal/verda-cli/cmd/vm/action.go +++ b/internal/verda-cli/cmd/vm/action.go @@ -253,7 +253,7 @@ func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream } actionLabels = append(actionLabels, "Cancel") - actionIdx, err := prompter.Select(ctx, "Select action", actionLabels) + actionIdx, err := prompter.Select(ctx, "Select action", actionLabels, tui.WithShowHints(true)) if err != nil { return nil } @@ -412,7 +412,7 @@ func selectInstance(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IO } labels = append(labels, "Cancel") - idx, err := f.Prompter().Select(ctx, "Select instance (type to filter)", labels) + 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. } diff --git a/internal/verda-cli/cmd/vm/list.go b/internal/verda-cli/cmd/vm/list.go index 9a32773..d855187 100644 --- a/internal/verda-cli/cmd/vm/list.go +++ b/internal/verda-cli/cmd/vm/list.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/cobra" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) @@ -105,7 +106,16 @@ func runList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, } _, _ = fmt.Fprintf(ioStreams.ErrOut, " %d instance(s) found\n\n", len(instances)) + return runListInteractive(cmd, f, ioStreams, client, instances) +} +func runListInteractive( + cmd *cobra.Command, + f cmdutil.Factory, + ioStreams cmdutil.IOStreams, + client *verda.Client, + instances []verda.Instance, +) error { prompter := f.Prompter() labels := make([]string, 0, len(instances)+1) for i := range instances { @@ -114,15 +124,17 @@ func runList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, labels = append(labels, "Exit") for { - idx, err := prompter.Select(cmd.Context(), "Select instance (type to filter)", labels) + idx, err := prompter.Select(cmd.Context(), "Select instance (type to filter)", labels, tui.WithShowHints(true)) if err != nil { - return nil + if cmdutil.IsPromptCancel(err) { + return nil // Esc / Ctrl+C at top level = clean exit + } + return err } if idx == len(instances) { // "Exit" return nil } - // Fetch fresh details and volumes. inst, err := client.Instances.GetByID(cmd.Context(), instances[idx].ID) if err != nil { _, _ = fmt.Fprintf(ioStreams.ErrOut, "Error: %v\n", err) @@ -131,7 +143,13 @@ func runList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, volumes := fetchInstanceVolumes(cmd.Context(), client, inst) _, _ = fmt.Fprint(ioStreams.Out, renderInstanceCard(inst, volumes...)) - // After showing details, loop back to the list. + exit, perr := cmdutil.PromptBackOrExit(cmd.Context(), prompter) + if perr != nil { + return perr + } + if exit { + return nil + } } } diff --git a/internal/verda-cli/cmd/vm/template_apply.go b/internal/verda-cli/cmd/vm/template_apply.go index ddc3723..1684eab 100644 --- a/internal/verda-cli/cmd/vm/template_apply.go +++ b/internal/verda-cli/cmd/vm/template_apply.go @@ -22,6 +22,7 @@ import ( "github.com/spf13/cobra" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + "github.com/verda-cloud/verdagostack/pkg/tui" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github.com/verda-cloud/verda-cli/internal/verda-cli/template" @@ -121,7 +122,7 @@ func pickTemplate(ctx context.Context, f cmdutil.Factory, baseDir string) (*temp labels[i] = fmt.Sprintf("%-20s %s", e.Name, e.Description) } - idx, err := f.Prompter().Select(ctx, "Select a template", labels) + idx, err := f.Prompter().Select(ctx, "Select a template", labels, tui.WithShowHints(true)) if err != nil { return nil, nil //nolint:nilerr // user canceled } diff --git a/internal/verda-cli/cmd/vm/wizard.go b/internal/verda-cli/cmd/vm/wizard.go index b12211a..364938b 100644 --- a/internal/verda-cli/cmd/vm/wizard.go +++ b/internal/verda-cli/cmd/vm/wizard.go @@ -537,7 +537,7 @@ func stepStorage(getClient clientFunc, cache *apiCache, opts *createOptions) wiz return nil, nil case addNewVolumeValue: - vol, err := promptAddVolume(ctx, prompter, store, cache) + vol, err := promptAddVolume(ctx, prompter, store) 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 93384fd..e197c02 100644 --- a/internal/verda-cli/cmd/vm/wizard_subflows.go +++ b/internal/verda-cli/cmd/vm/wizard_subflows.go @@ -281,33 +281,10 @@ func buildStorageChoices(volumes []verda.VolumeCreateRequest, existingIDs []stri return choices } -func promptAddVolume(ctx context.Context, prompter tui.Prompter, store *wizard.Store, cache *apiCache) (*verda.VolumeCreateRequest, error) { - // Volume type with prices. - nvmeLabel := "NVMe (fast SSD)" - hddLabel := "HDD (large capacity)" - if cache != nil && cache.volumeTypes != nil { - if vt, ok := cache.volumeTypes[verda.VolumeTypeNVMe]; ok && vt.Price.PricePerMonthPerGB > 0 { - nvmeLabel = fmt.Sprintf("NVMe (fast SSD) $%.2f/GB/mo", vt.Price.PricePerMonthPerGB) - } - if vt, ok := cache.volumeTypes[verda.VolumeTypeHDD]; ok && vt.Price.PricePerMonthPerGB > 0 { - hddLabel = fmt.Sprintf("HDD (large capacity) $%.2f/GB/mo", vt.Price.PricePerMonthPerGB) - } - } - typeIdx, err := prompter.Select(ctx, "Volume type", []string{ - nvmeLabel, - hddLabel, - "← Back", - }) - if err != nil { - return nil, nil //nolint:nilerr // User pressed Esc/Ctrl+C during prompt. - } - if typeIdx == 2 { // "← Back" - return nil, nil - } +func promptAddVolume(ctx context.Context, prompter tui.Prompter, store *wizard.Store) (*verda.VolumeCreateRequest, error) { + // NVMe is the only provisionable volume type — HDD is deprecated. Existing + // HDD volumes still display in list/describe; we just no longer offer it. volType := verda.VolumeTypeNVMe - if typeIdx == 1 { - volType = verda.VolumeTypeHDD - } // Name c := store.Collected() diff --git a/internal/verda-cli/cmd/volume/README.md b/internal/verda-cli/cmd/volume/README.md index 854fc68..eaaf443 100644 --- a/internal/verda-cli/cmd/volume/README.md +++ b/internal/verda-cli/cmd/volume/README.md @@ -48,7 +48,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 is prompted as a selection (NVMe / HDD with pricing), 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. ### 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 339859a..67145af 100644 --- a/internal/verda-cli/cmd/volume/action.go +++ b/internal/verda-cli/cmd/volume/action.go @@ -106,7 +106,7 @@ func runVolumeAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO } labels = append(labels, "Cancel") - idx, err := prompter.Select(ctx, "Select action", labels) + idx, err := prompter.Select(ctx, "Select action", labels, tui.WithShowHints(true)) if err != nil { return nil } @@ -277,7 +277,7 @@ func selectVolume(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt } labels = append(labels, "Cancel") - idx, err := f.Prompter().Select(ctx, "Select volume (type to filter)", labels) + 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. } diff --git a/internal/verda-cli/cmd/volume/create.go b/internal/verda-cli/cmd/volume/create.go index 4fbbb58..4177908 100644 --- a/internal/verda-cli/cmd/volume/create.go +++ b/internal/verda-cli/cmd/volume/create.go @@ -65,7 +65,7 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command flags := cmd.Flags() flags.StringVar(&opts.Name, "name", "", "Volume name") flags.IntVar(&opts.Size, "size", 0, "Volume size in GiB") - flags.StringVar(&opts.Type, "type", "", "Volume type: NVMe or HDD") + flags.StringVar(&opts.Type, "type", "", "Volume type (default: NVMe)") flags.StringVar(&opts.Location, "location", "", "Location code, e.g. FIN-01") opts.Wait.AddFlags(flags, false) // --wait defaults to false for volume create @@ -100,25 +100,10 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream vtMap[vt.Type] = vt } - // Volume type. - if opts.Type == "" { //nolint:nestif // Interactive prompt flow requires nested conditionals. - nvmeLabel := "NVMe (fast SSD)" - hddLabel := "HDD (large capacity)" - if vt, ok := vtMap[verda.VolumeTypeNVMe]; ok && vt.Price.PricePerMonthPerGB > 0 { - nvmeLabel = fmt.Sprintf("NVMe (fast SSD) $%.2f/GB/mo", vt.Price.PricePerMonthPerGB) - } - if vt, ok := vtMap[verda.VolumeTypeHDD]; ok && vt.Price.PricePerMonthPerGB > 0 { - hddLabel = fmt.Sprintf("HDD (large capacity) $%.2f/GB/mo", vt.Price.PricePerMonthPerGB) - } - idx, err := prompter.Select(ctx, "Volume type", []string{nvmeLabel, hddLabel}) - if err != nil { - return nil - } - if idx == 0 { - opts.Type = verda.VolumeTypeNVMe - } else { - opts.Type = verda.VolumeTypeHDD - } + // 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. + if opts.Type == "" { + opts.Type = verda.VolumeTypeNVMe } // Name. @@ -161,7 +146,7 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream for i, loc := range locations { labels[i] = fmt.Sprintf("%s (%s)", loc.Code, loc.Name) } - idx, err := prompter.Select(ctx, "Location", labels) + idx, err := prompter.Select(ctx, "Location", labels, tui.WithShowHints(true)) if err != nil { return nil } diff --git a/internal/verda-cli/options/options.go b/internal/verda-cli/options/options.go index ab0979d..0825094 100644 --- a/internal/verda-cli/options/options.go +++ b/internal/verda-cli/options/options.go @@ -238,6 +238,22 @@ func (o *Options) Validate() error { return nil } +// ActiveProfile resolves the auth profile name for commands that skip +// Options.Complete() (s3, registry — see skipCredentialResolution in cmd.go), +// honoring the same precedence as full resolution: explicit flag > VERDA_PROFILE +// env > config file (auth.profile, set by `verda auth use`). Returns "" when +// none is set so the caller can apply its own default. Without this, those +// commands ignore `verda auth use` and always read the "default" profile. +func ActiveProfile(flagProfile string) string { + if flagProfile != "" { + return flagProfile + } + if p := os.Getenv("VERDA_PROFILE"); p != "" { + return p + } + return viper.GetString("auth.profile") +} + // resolveDefaultProfile picks the best profile when none is explicitly set. // It prefers "default" if it exists, otherwise uses the sole profile if there // is exactly one, and falls back to "default" as a last resort. diff --git a/internal/verda-cli/options/options_test.go b/internal/verda-cli/options/options_test.go index b2239d8..f147909 100644 --- a/internal/verda-cli/options/options_test.go +++ b/internal/verda-cli/options/options_test.go @@ -409,3 +409,42 @@ func TestCredentialPriority(t *testing.T) { }) } } + +func TestActiveProfile(t *testing.T) { + // no t.Parallel — mutates global viper + env + + t.Run("flag wins", func(t *testing.T) { + t.Setenv("VERDA_PROFILE", "envprof") + viper.Set("auth.profile", "cfgprof") + defer viper.Set("auth.profile", "") + if got := ActiveProfile("flagprof"); got != "flagprof" { + t.Errorf("ActiveProfile = %q, want flagprof (explicit flag wins)", got) + } + }) + + t.Run("env over config", func(t *testing.T) { + t.Setenv("VERDA_PROFILE", "envprof") + viper.Set("auth.profile", "cfgprof") + defer viper.Set("auth.profile", "") + if got := ActiveProfile(""); got != "envprof" { + t.Errorf("ActiveProfile = %q, want envprof (env over config)", got) + } + }) + + t.Run("config when no flag/env (the auth-use path)", func(t *testing.T) { + t.Setenv("VERDA_PROFILE", "") + viper.Set("auth.profile", "production") + defer viper.Set("auth.profile", "") + if got := ActiveProfile(""); got != "production" { + t.Errorf("ActiveProfile = %q, want production (config-file active profile)", got) + } + }) + + t.Run("empty when nothing set", func(t *testing.T) { + t.Setenv("VERDA_PROFILE", "") + viper.Set("auth.profile", "") + if got := ActiveProfile(""); got != "" { + t.Errorf("ActiveProfile = %q, want empty (caller applies default)", got) + } + }) +}