From 098ca3231d47cbdf3301de36dd1369679d0b278f Mon Sep 17 00:00:00 2001 From: lei Date: Fri, 24 Apr 2026 21:34:51 +0300 Subject: [PATCH 01/26] feat(serverless): add container + batchjob commands `verda serverless container` and `verda serverless batchjob` manage the two serverless deployment shapes on Verda Cloud: always-on HTTPS endpoints and one-shot batch jobs. The web UI's "Deployment type" radio maps to the subcommand choice; each subcommand calls its own SDK service (/container-deployments vs /job-deployments). - container: create (22-step wizard + flags), list/describe/delete, pause/resume/restart/purge-queue - batchjob: create (flags only; wizard is a follow-up), list/describe/delete, pause/resume/purge-queue. Cannot use spot; deadline is required. - Validation client-side: reject :latest tags, RFC-1123 deployment names, env-var name pattern, absolute mount paths - Scaling preset maps Instant=1 / Balanced=3 / Cost saver=6 / Custom into ScalingTriggers.QueueLoad.Threshold - Tests cover name validation, :latest rejection, env/mount parsing, full preset mapping, validation errors, request-payload shape for both subcommands - CLAUDE.md and README.md per the per-command docs convention - Design: docs/plans/2026-04-24-serverless-container-design.md Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/verda-cli/cmd/cmd.go | 7 + internal/verda-cli/cmd/serverless/CLAUDE.md | 124 ++++ internal/verda-cli/cmd/serverless/README.md | 158 ++++ internal/verda-cli/cmd/serverless/batchjob.go | 47 ++ .../cmd/serverless/batchjob_actions.go | 117 +++ .../cmd/serverless/batchjob_create.go | 241 ++++++ .../cmd/serverless/batchjob_create_test.go | 88 +++ .../cmd/serverless/batchjob_delete.go | 87 +++ .../cmd/serverless/batchjob_describe.go | 166 +++++ .../verda-cli/cmd/serverless/batchjob_list.go | 87 +++ .../verda-cli/cmd/serverless/container.go | 47 ++ .../cmd/serverless/container_actions.go | 130 ++++ .../cmd/serverless/container_create.go | 461 ++++++++++++ .../cmd/serverless/container_create_test.go | 264 +++++++ .../cmd/serverless/container_delete.go | 88 +++ .../cmd/serverless/container_describe.go | 191 +++++ .../cmd/serverless/container_list.go | 93 +++ .../verda-cli/cmd/serverless/serverless.go | 41 + internal/verda-cli/cmd/serverless/shared.go | 142 ++++ .../verda-cli/cmd/serverless/shared_test.go | 154 ++++ internal/verda-cli/cmd/serverless/wizard.go | 702 ++++++++++++++++++ .../verda-cli/cmd/serverless/wizard_cache.go | 100 +++ .../cmd/serverless/wizard_subflows.go | 86 +++ .../cmd/serverless/wizard_summary.go | 91 +++ 24 files changed, 3712 insertions(+) create mode 100644 internal/verda-cli/cmd/serverless/CLAUDE.md create mode 100644 internal/verda-cli/cmd/serverless/README.md create mode 100644 internal/verda-cli/cmd/serverless/batchjob.go create mode 100644 internal/verda-cli/cmd/serverless/batchjob_actions.go create mode 100644 internal/verda-cli/cmd/serverless/batchjob_create.go create mode 100644 internal/verda-cli/cmd/serverless/batchjob_create_test.go create mode 100644 internal/verda-cli/cmd/serverless/batchjob_delete.go create mode 100644 internal/verda-cli/cmd/serverless/batchjob_describe.go create mode 100644 internal/verda-cli/cmd/serverless/batchjob_list.go create mode 100644 internal/verda-cli/cmd/serverless/container.go create mode 100644 internal/verda-cli/cmd/serverless/container_actions.go create mode 100644 internal/verda-cli/cmd/serverless/container_create.go create mode 100644 internal/verda-cli/cmd/serverless/container_create_test.go create mode 100644 internal/verda-cli/cmd/serverless/container_delete.go create mode 100644 internal/verda-cli/cmd/serverless/container_describe.go create mode 100644 internal/verda-cli/cmd/serverless/container_list.go create mode 100644 internal/verda-cli/cmd/serverless/serverless.go create mode 100644 internal/verda-cli/cmd/serverless/shared.go create mode 100644 internal/verda-cli/cmd/serverless/shared_test.go create mode 100644 internal/verda-cli/cmd/serverless/wizard.go create mode 100644 internal/verda-cli/cmd/serverless/wizard_cache.go create mode 100644 internal/verda-cli/cmd/serverless/wizard_subflows.go create mode 100644 internal/verda-cli/cmd/serverless/wizard_summary.go diff --git a/internal/verda-cli/cmd/cmd.go b/internal/verda-cli/cmd/cmd.go index 30e069f..d97af6b 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" @@ -168,6 +169,12 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op ssh.NewCmdSSH(f, ioStreams), }, }, + { + Message: "Serverless Commands:", + Commands: []*cobra.Command{ + serverless.NewCmdServerless(f, ioStreams), + }, + }, { Message: "Resource Commands:", Commands: resourceCmds, diff --git a/internal/verda-cli/cmd/serverless/CLAUDE.md b/internal/verda-cli/cmd/serverless/CLAUDE.md new file mode 100644 index 0000000..d4f4dd9 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/CLAUDE.md @@ -0,0 +1,124 @@ +# 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. + +## Quick Reference + +- Parent: `verda serverless` (no aliases) +- Subcommands: + - `verda serverless container` → `/container-deployments` (continuous endpoints, supports spot) + - `verda serverless batchjob` → `/job-deployments` (one-shot jobs, deadline-based, **no spot**) +- Verbs (both trees): `create`, `list` (alias `ls`), `describe` (aliases `get`, `show`), `delete` (aliases `rm`, `del`), `pause`, `resume`, `purge-queue`. Container also has `restart`. +- Files: + - `serverless.go` — Parent command. Registered in `cmd/cmd.go` under the "Serverless Commands" group. **No feature gate, no `Hidden: true`** — this is a GA feature, unlike s3/registry. + - `container.go`, `batchjob.go` — Subcommand parents. + - `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.go` — 22 step definitions + `buildContainerCreateFlow`. Step-per-field with defaults, so `make test` passes without a mock TUI because every wizard step is just a closure. + - `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. + - `wizard_subflows.go` — `promptEnvVar`, `promptSecretMount` for the two loop-add steps. + - `wizard_summary.go` — `renderContainerSummary` prints the review card before the final confirm. **Not** a wizard step — rendered from `runContainerCreate` after the flow returns. + - `*_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 defaults are fixed today + +General storage (`/data`, 500 GiB) and SHM (`/dev/shm`, 64 MiB) are labeled "fixed for now" in the web UI. The wizard does NOT prompt for them — `renderContainerSummary` shows them as "(fixed)" in the review card, and the create request always includes both mounts with the default sizes. Flags `--general-storage-size` and `--shm-size` exist for the future when the API unlocks them; today they default to the fixed values. + +Mount types in `ContainerVolumeMount.Type`: + +- `"secret"` — from `--secret-mount NAME:PATH`; `SecretName` set +- `"shared"` — general `/data` storage; `SizeInMB` set +- `"shm"` — `/dev/shm`; `SizeInMB` set + +See `buildVolumeMounts` in `container_create.go`. + +### 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 healthcheck sub-prompts when Off.** Steps `healthcheck-port` and `healthcheck-path` have `ShouldSkip: c["healthcheck"] == "off"`. Don't call them unconditionally; the engine wires the skip gate via `DependsOn`. +- **`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 serverless 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. +- **Batch-job wizard is NOT implemented yet.** `batchjob create` with missing flags errors out with "interactive wizard is coming" pointing at the design doc. The flag-driven path fully works. Follow-up: factor the shared wizard steps out of `wizard.go` and build a 13-step job flow. +- **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..0d62b9c --- /dev/null +++ b/internal/verda-cli/cmd/serverless/README.md @@ -0,0 +1,158 @@ +# `verda serverless` + +Manage serverless container deployments (always-on endpoints) and batch-job deployments (one-shot runs) on Verda Cloud. + +``` +verda serverless container # → /container-deployments (continuous; supports spot) +verda serverless batchjob # → /job-deployments (one-shot; deadline-based; no spot) +``` + +## Container deployments + +### Create + +Interactive wizard (launches when any of `--name`/`--image`/`--compute` is missing): + +```bash +verda serverless container create +``` + +Non-interactive: + +```bash +verda serverless 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 serverless 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 +- General storage at `/data` (500 GiB) and SHM at `/dev/shm` (64 MiB) are included automatically and cannot be edited today. Flags exist (`--general-storage-size`, `--shm-size`) for forward-compatibility when the API exposes them. + +### Lifecycle + +```bash +verda serverless container list +verda serverless container describe my-endpoint +verda serverless container pause my-endpoint # stop serving requests +verda serverless container resume my-endpoint +verda serverless container restart my-endpoint # destructive; requires --yes in agent mode +verda serverless container purge-queue my-endpoint # destructive; requires --yes in agent mode +verda serverless 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 + +```bash +verda serverless batchjob create \ + --name nightly-embed \ + --image ghcr.io/me/embedder:v1 \ + --compute RTX4500Ada --compute-size 1 \ + --deadline 30m +``` + +**Required flags** (both interactive and agent modes, until the wizard lands): `--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 serverless batchjob list +verda serverless batchjob describe nightly-embed +verda serverless batchjob pause nightly-embed +verda serverless batchjob resume nightly-embed +verda serverless batchjob purge-queue nightly-embed # destructive +verda serverless 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 serverless container create \ + --name api --image ghcr.io/org/app:v1 \ + --compute RTX4500Ada --compute-size 1 -o json + +verda --agent serverless 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..aca29f5 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob.go @@ -0,0 +1,47 @@ +// 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 `verda serverless batchjob` subcommand 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)", + 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..51eed48 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_actions.go @@ -0,0 +1,117 @@ +// 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 + +func newBatchjobActionCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, verb, short, spinner, successMsg 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, 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 string, destructive, yes bool, fn batchjobActionFn) error { + client, err := f.VerdaClient() + if err != nil { + return err + } + + if destructive { + if f.AgentMode() && !yes { + return cmdutil.NewConfirmationRequiredError(verb) + } + if !yes { + confirmed, err := confirmDestructive(cmd.Context(), ioStreams, f.Prompter(), + verb+" batch-job deployment", + fmt.Sprintf("Deployment %q will be %sd.", name, verb), + fmt.Sprintf("%s %s?", verb, name), + ) + if err != nil || !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", 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..b5eb8f9 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_create.go @@ -0,0 +1,241 @@ +// 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" + + cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// batchjobCreateOptions is the simpler sibling of containerCreateOptions: no +// spot flag (jobs never run on spot), no continuous-scaling parameters, and a +// required deadline. Otherwise mirrors the container shape. +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 + GeneralStorageSize int + SHMSize int + + Yes bool +} + +func newCmdBatchjobCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + opts := &batchjobCreateOptions{ + Port: defaultExposedPort, + MaxReplicas: defaultMaxReplicas, + RequestTTL: defaultRequestTTL, + GeneralStorageSize: defaultGeneralStorageGiB, + SHMSize: defaultSHMMiB, + } + + 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 serverless 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; repeat for multiple") + flags.StringArrayVar(&opts.EnvSecret, "env-secret", nil, "Secret-backed env KEY=SECRET_NAME; 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.IntVar(&opts.GeneralStorageSize, "general-storage-size", opts.GeneralStorageSize, "Size of the fixed /data mount in GiB") + flags.IntVar(&opts.SHMSize, "shm-size", opts.SHMSize, "Size of the /dev/shm mount in MiB") + + 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 { + client, err := f.VerdaClient() + if err != nil { + return err + } + + 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 { + // TODO(wizard): launch the batchjob wizard here. + return errors.New("missing required flags: --name, --image, --compute, --deadline are required " + + "(interactive wizard is coming; track progress in docs/plans/2026-04-24-serverless-container-design.md)") + } + + req, err := opts.request() + if err != nil { + return cmdutil.UsageErrorf(cmd, "%v", err) + } + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Request payload:", req) + + 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 +} + +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, o.GeneralStorageSize, o.SHMSize) + 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...), + } + } + + 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..ab7d828 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_create_test.go @@ -0,0 +1,88 @@ +// 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" + "time" +) + +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, + GeneralStorageSize: defaultGeneralStorageGiB, + SHMSize: defaultSHMMiB, + } +} + +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) + } + } +} 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..acccee7 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_delete.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" + + "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 || !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..6803fc1 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/batchjob_describe.go @@ -0,0 +1,166 @@ +// 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" + "strings" + + "charm.land/lipgloss/v2" + "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 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) + if err != nil { + return "", nil // prompter cancel is a clean exit + } + 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() + + job, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading deployment...", func() (*verda.JobDeployment, error) { + return client.ServerlessJobs.GetJobDeploymentByName(ctx, name) + }) + if err != nil { + return fmt.Errorf("fetching deployment: %w", err) + } + + var status string + if s, statusErr := client.ServerlessJobs.GetJobDeploymentStatus(ctx, name); statusErr == nil && s != nil { + status = s.Status + } + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployment:", job) + + 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 interface{ Write(p []byte) (int, error) }, 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..a15e412 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container.go @@ -0,0 +1,47 @@ +// 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 `verda serverless container` subcommand tree. +func newCmdContainer(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "container", + Short: "Manage serverless container deployments (always-on endpoints)", + 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..daa808d --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_actions.go @@ -0,0 +1,130 @@ +// 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 a `verda serverless container ` command +// whose only behavior is to call the given SDK method with the resolved name. +// All four lifecycle commands (pause/resume/restart/purge-queue) share this shape. +func newContainerActionCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, verb, short, spinner, successMsg 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, 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 string, destructive, yes bool, fn containerActionFn) error { + client, err := f.VerdaClient() + if err != nil { + return err + } + + if destructive { + if f.AgentMode() && !yes { + return cmdutil.NewConfirmationRequiredError(verb) + } + if !yes { + confirmed, err := confirmDestructive(cmd.Context(), ioStreams, f.Prompter(), + verb+" container deployment", + fmt.Sprintf("Deployment %q will be %sd.", name, verb), + fmt.Sprintf("%s %s?", verb, name), + ) + if err != nil || !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", 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", 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..a91968f --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_create.go @@ -0,0 +1,461 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package 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 preset constants. The CLI exposes four named profiles (Instant, +// Balanced, Cost saver, Custom); all four map to an integer threshold written +// into ScalingTriggers.QueueLoad. +const ( + presetInstant = "instant" + presetBalanced = "balanced" + presetCostSaver = "cost-saver" + presetCustom = "custom" + + queueLoadInstant = 1 + queueLoadBalanced = 3 + queueLoadCostSaver = 6 + + // Fixed storage values — web UI labels these "fixed for now" and does + // not expose editors. Flags default to these and may be overridden + // when the API unlocks them. + defaultGeneralStoragePath = "/data" + defaultGeneralStorageGiB = 500 + defaultSHMPath = "/dev/shm" + defaultSHMMiB = 64 + + defaultExposedPort = 80 + defaultHealthcheckPath = "/health" + defaultMaxReplicas = 3 + defaultConcurrency = 1 + defaultScaleDownDelay = 300 * time.Second + defaultRequestTTL = 300 * time.Second +) + +// containerCreateOptions collects every field the CreateDeploymentRequest needs. +// Flag parsing populates these; later (follow-up task) the wizard will fill +// remaining gaps. request() turns these into 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 + GeneralStorageSize int // GiB; 0 = omit the mount + SHMSize int // MiB; 0 = omit the mount + + 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, + GeneralStorageSize: defaultGeneralStorageGiB, + SHMSize: defaultSHMMiB, + } + + 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 serverless 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 serverless 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; repeat for multiple") + flags.StringArrayVar(&opts.EnvSecret, "env-secret", nil, "Secret-backed env KEY=SECRET_NAME; 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.IntVar(&opts.GeneralStorageSize, "general-storage-size", opts.GeneralStorageSize, "Size of the fixed /data mount in GiB") + flags.IntVar(&opts.SHMSize, "shm-size", opts.SHMSize, "Size of the /dev/shm mount in MiB") + + 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 { + client, err := f.VerdaClient() + if err != nil { + return err + } + + 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 + } + } + + req, err := opts.request() + if err != nil { + return cmdutil.UsageErrorf(cmd, "%v", err) + } + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Request payload:", req) + + // Interactive confirmation with summary. Agent mode relies on --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 || !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 drives the interactive 22-step create flow. Writes into +// opts in place; the caller then turns opts into a request via opts.request(). +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 every option for well-formed values before the request is +// assembled. Split out from request() to keep cyclomatic complexity low — the +// cluster of range checks lives here, the assembly lives there. +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 assembles a CreateDeploymentRequest from the options. Validation +// happens in validate(); assembly + the SDK's server-side-parity check live here. +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, o.GeneralStorageSize, o.SHMSize) + 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 +} + +func buildVolumeMounts(secretMounts []string, generalStorageGiB, shmMiB int) ([]verda.ContainerVolumeMount, error) { + var mounts []verda.ContainerVolumeMount + for _, entry := range secretMounts { + m, err := parseSecretMountFlag(entry) + if err != nil { + return nil, err + } + mounts = append(mounts, m) + } + if generalStorageGiB > 0 { + mounts = append(mounts, verda.ContainerVolumeMount{ + Type: mountTypeShared, + MountPath: defaultGeneralStoragePath, + SizeInMB: generalStorageGiB * 1024, + }) + } + if shmMiB > 0 { + mounts = append(mounts, verda.ContainerVolumeMount{ + Type: mountTypeSHM, + MountPath: defaultSHMPath, + SizeInMB: shmMiB, + }) + } + 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..9872b7e --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_create_test.go @@ -0,0 +1,264 @@ +// 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" + "time" +) + +// validOpts returns a containerCreateOptions that passes validate() — used as +// a baseline each test tweaks a single field on. +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, + GeneralStorageSize: defaultGeneralStorageGiB, + SHMSize: defaultSHMMiB, + } +} + +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) + } + // General storage + SHM mounts should be present by default. + if len(c.VolumeMounts) != 2 { + t.Errorf("expected 2 default mounts (general + shm), got %d: %+v", len(c.VolumeMounts), c.VolumeMounts) + } +} + +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+":"+strconvItoa(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) + } + }) + } +} + +// strconvItoa is a tiny helper so the test table above can embed ints in +// subtest names without an import dance. +func strconvItoa(n int) string { + if n == 0 { + return "0" + } + s := "" + neg := n < 0 + if neg { + n = -n + } + for n > 0 { + s = string(rune('0'+n%10)) + s + n /= 10 + } + if neg { + s = "-" + s + } + return s +} 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..a3d6978 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_delete.go @@ -0,0 +1,88 @@ +// 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 || !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..b60966b --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_describe.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 serverless + +import ( + "context" + "fmt" + "strings" + + "charm.land/lipgloss/v2" + "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 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 returns the first positional arg when present; otherwise +// prompts interactively. Agent mode returns a MISSING_REQUIRED_FLAGS error when +// no name is supplied because prompts are blocked. +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 loads all container deployments and runs a single-select +// picker. Returns "" (no error) when the user cancels or there are no deployments. +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) + if err != nil { + return "", nil // prompter cancel is a clean exit + } + 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() + + deployment, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading deployment...", func() (*verda.ContainerDeployment, error) { + return client.ContainerDeployments.GetDeploymentByName(ctx, name) + }) + if err != nil { + return fmt.Errorf("fetching deployment: %w", err) + } + + // Best-effort status fetch — don't fail the describe if status errors. + var status string + s, statusErr := client.ContainerDeployments.GetDeploymentStatus(ctx, name) + if statusErr == nil && s != nil { + status = s.Status + } + + cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployment:", deployment) + + 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 interface{ Write(p []byte) (int, error) }, 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..b6425dd --- /dev/null +++ b/internal/verda-cli/cmd/serverless/container_list.go @@ -0,0 +1,93 @@ +// 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 newCmdContainerList(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List serverless container deployments", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + return runContainerList(cmd, f, ioStreams) + }, + } + return cmd +} + +func runContainerList(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() + + 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) + + if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), deployments); wrote { + return werr + } + + if len(deployments) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No container deployments found.") + return nil + } + + w := tabwriter.NewWriter(ioStreams.Out, 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(w, "NAME\tCOMPUTE\tSIZE\tSPOT\tENDPOINT\tCREATED") + for i := range deployments { + d := &deployments[i] + compute := "-" + size := "-" + if d.Compute != nil { + compute = d.Compute.Name + size = strconv.Itoa(d.Compute.Size) + } + spot := "no" + if d.IsSpot { + spot = "yes" + } + _, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\n", + d.Name, + compute, + size, + spot, + d.EndpointBaseURL, + d.CreatedAt.Format("2006-01-02 15:04"), + ) + } + return w.Flush() +} diff --git a/internal/verda-cli/cmd/serverless/serverless.go b/internal/verda-cli/cmd/serverless/serverless.go new file mode 100644 index 0000000..3a99917 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/serverless.go @@ -0,0 +1,41 @@ +// 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" +) + +// NewCmdServerless creates the parent `verda serverless` command. +func NewCmdServerless(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "serverless", + Short: "Manage serverless container and batch-job deployments", + Long: cmdutil.LongDesc(` + Deploy and manage serverless container endpoints and one-shot batch + jobs on Verda Cloud. Container deployments run continuously and scale + with demand; batch jobs run to completion on a deadline. + `), + Run: cmdutil.DefaultSubCommandRun(ioStreams.Out), + } + + cmd.AddCommand( + newCmdContainer(f, ioStreams), + newCmdBatchjob(f, ioStreams), + ) + return cmd +} diff --git a/internal/verda-cli/cmd/serverless/shared.go b/internal/verda-cli/cmd/serverless/shared.go new file mode 100644 index 0000000..1a3cecc --- /dev/null +++ b/internal/verda-cli/cmd/serverless/shared.go @@ -0,0 +1,142 @@ +// 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 matches an RFC-1123 subset suitable for a public URL slug: +// lowercase alphanumerics and hyphens, must start and end with alphanumeric, +// max 63 characters. This is the contract the Verda backend enforces for +// deployment names (they become part of https://containers.datacrunch.io/). +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 returns an error if the image reference uses the ':latest' +// tag (explicit or implicit). The API rejects latest-tagged deployments; we +// fail fast so users see a friendly error instead of a validation 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. +const ( + mountTypeSecret = "secret" + mountTypeShared = "shared" + mountTypeSHM = "shm" +) + +// Environment-variable type constants. +const ( + envTypePlain = "plain" + envTypeSecret = "secret" +) + +// confirmDestructive renders a red-bold warning line and prompts the user to +// confirm. Returns (true, nil) to proceed, (false, nil) on cancellation. +// 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.")) + return prompter.Confirm(ctx, prompt) +} + +// 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/wizard.go b/internal/verda-cli/cmd/serverless/wizard.go new file mode 100644 index 0000000..19f4b28 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard.go @@ -0,0 +1,702 @@ +// 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/verdagostack/pkg/tui" + "github.com/verda-cloud/verdagostack/pkg/tui/wizard" +) + +const ( + computeTypeOnDemand = "on-demand" + computeTypeSpot = "spot" + + healthcheckOn = "on" + healthcheckOff = "off" + + utilOff = "off" +) + +// buildContainerCreateFlow returns the full wizard flow for `verda serverless +// container create`. Each 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, so the review card +// has full-width layout control. +func buildContainerCreateFlow(_ context.Context, getClient clientFunc, opts *containerCreateOptions) *wizard.Flow { + cache := &apiCache{} + return &wizard.Flow{ + Name: "container-create", + Steps: []wizard.Step{ + stepContainerName(opts), + stepContainerComputeType(opts), + stepContainerCompute(getClient, cache, opts), + stepContainerComputeSize(opts), + stepContainerImage(opts), + stepContainerRegistryCreds(getClient, cache, opts), + stepContainerPort(opts), + stepContainerHealthcheck(opts), + stepContainerHealthcheckPort(opts), + stepContainerHealthcheckPath(opts), + stepContainerEnvVars(opts), + stepContainerMinReplicas(opts), + stepContainerMaxReplicas(opts), + stepContainerConcurrency(opts), + stepContainerQueuePreset(opts), + stepContainerQueueLoadCustom(opts), + stepContainerCPUUtil(opts), + stepContainerGPUUtil(opts), + stepContainerScaleUpDelay(opts), + stepContainerScaleDownDelay(opts), + stepContainerRequestTTL(opts), + stepContainerSecretMounts(getClient, cache, opts), + }, + } +} + +// --- 1. Deployment name --- + +func stepContainerName(opts *containerCreateOptions) 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 opts.Name }, + Validate: func(v any) error { + return validateDeploymentName(strings.TrimSpace(v.(string))) + }, + Setter: func(v any) { opts.Name = strings.TrimSpace(v.(string)) }, + Resetter: func() { opts.Name = "" }, + IsSet: func() bool { return opts.Name != "" }, + Value: func() any { return opts.Name }, + } +} + +// --- 2. Compute type (on-demand | spot) --- + +func stepContainerComputeType(opts *containerCreateOptions) 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 opts.Spot { + return computeTypeSpot + } + return computeTypeOnDemand + }, + Setter: func(v any) { opts.Spot = v.(string) == computeTypeSpot }, + Resetter: func() { opts.Spot = false }, + IsSet: func() bool { return false }, // always prompt + Value: func() any { + if opts.Spot { + return computeTypeSpot + } + return computeTypeOnDemand + }, + } +} + +// --- 3. Compute resource (GPU/CPU pick from /serverless-compute-resources) --- + +func stepContainerCompute(getClient clientFunc, cache *apiCache, opts *containerCreateOptions) wizard.Step { + return wizard.Step{ + Name: "compute", + Description: "Compute resource", + Prompt: wizard.SelectPrompt, + Required: true, + Loader: func(ctx context.Context, _ tui.Prompter, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + res, err := 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 opts.Compute }, + Setter: func(v any) { opts.Compute = v.(string) }, + Resetter: func() { opts.Compute = "" }, + IsSet: func() bool { return opts.Compute != "" }, + Value: func() any { return opts.Compute }, + } +} + +// --- 4. Compute size (count of GPUs or vCPUs) --- + +func stepContainerComputeSize(opts *containerCreateOptions) wizard.Step { + return wizard.Step{ + Name: "compute-size", + Description: "Compute size (number of GPUs/vCPUs per replica)", + Prompt: wizard.TextInputPrompt, + Required: true, + Default: func(_ map[string]any) any { + if opts.ComputeSize > 0 { + return strconv.Itoa(opts.ComputeSize) + } + return "1" + }, + Validate: parsePositiveIntValidator("compute size"), + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + opts.ComputeSize = n + }, + Resetter: func() { opts.ComputeSize = 0 }, + IsSet: func() bool { return opts.ComputeSize > 0 }, + Value: func() any { return strconv.Itoa(opts.ComputeSize) }, + } +} + +// --- 5. Container image --- + +func stepContainerImage(opts *containerCreateOptions) 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 opts.Image }, + 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) { opts.Image = strings.TrimSpace(v.(string)) }, + Resetter: func() { opts.Image = "" }, + IsSet: func() bool { return opts.Image != "" }, + Value: func() any { return opts.Image }, + } +} + +// --- 6. Registry credentials --- + +const registryPublicValue = "__public__" + +func stepContainerRegistryCreds(getClient clientFunc, cache *apiCache, opts *containerCreateOptions) 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, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + choices := []wizard.Choice{ + {Label: "Public (no credentials)", Value: registryPublicValue}, + } + creds, err := 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 { + choices = append(choices, wizard.Choice{ + Label: c.Name, + Value: c.Name, + }) + } + return choices, nil + }, + Default: func(_ map[string]any) any { + if opts.RegistryCreds == "" { + return registryPublicValue + } + return opts.RegistryCreds + }, + Setter: func(v any) { + s := v.(string) + if s == registryPublicValue { + opts.RegistryCreds = "" + return + } + opts.RegistryCreds = s + }, + Resetter: func() { opts.RegistryCreds = "" }, + IsSet: func() bool { return opts.RegistryCreds != "" }, + Value: func() any { + if opts.RegistryCreds == "" { + return registryPublicValue + } + return opts.RegistryCreds + }, + } +} + +// --- 7. Exposed HTTP port --- + +func stepContainerPort(opts *containerCreateOptions) 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(opts.Port) }, + Validate: parsePortValidator("port"), + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + opts.Port = n + }, + Resetter: func() { opts.Port = defaultExposedPort }, + IsSet: func() bool { return false }, // always show the step; default carries the pre-set value + Value: func() any { return strconv.Itoa(opts.Port) }, + } +} + +// --- 8-10. Healthcheck (on/off + port + path) --- + +func stepContainerHealthcheck(opts *containerCreateOptions) 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 opts.HealthcheckOff { + return healthcheckOff + } + return healthcheckOn + }, + Setter: func(v any) { opts.HealthcheckOff = v.(string) == healthcheckOff }, + Resetter: func() { opts.HealthcheckOff = false }, + IsSet: func() bool { return false }, + Value: func() any { + if opts.HealthcheckOff { + return healthcheckOff + } + return healthcheckOn + }, + } +} + +func stepContainerHealthcheckPort(opts *containerCreateOptions) wizard.Step { + return wizard.Step{ + Name: "healthcheck-port", + Description: "Healthcheck port (blank = same as exposed)", + 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 { + if opts.HealthcheckPort > 0 { + return strconv.Itoa(opts.HealthcheckPort) + } + return "" + }, + Validate: func(v any) error { + s := strings.TrimSpace(v.(string)) + if s == "" { + return nil + } + return parsePortValidator("healthcheck port")(v) + }, + Setter: func(v any) { + s := strings.TrimSpace(v.(string)) + if s == "" { + opts.HealthcheckPort = 0 + return + } + n, _ := strconv.Atoi(s) + opts.HealthcheckPort = n + }, + Resetter: func() { opts.HealthcheckPort = 0 }, + IsSet: func() bool { return opts.HealthcheckPort > 0 }, + Value: func() any { return strconv.Itoa(opts.HealthcheckPort) }, + } +} + +func stepContainerHealthcheckPath(opts *containerCreateOptions) 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 opts.HealthcheckPath }, + Setter: func(v any) { opts.HealthcheckPath = strings.TrimSpace(v.(string)) }, + Resetter: func() { opts.HealthcheckPath = defaultHealthcheckPath }, + IsSet: func() bool { return false }, + Value: func() any { return opts.HealthcheckPath }, + } +} + +// --- 11. Env vars (loop) --- + +func stepContainerEnvVars(opts *containerCreateOptions) 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) { + // Loop: add env vars until the user says "done". + for { + add, err := prompter.Confirm(ctx, fmt.Sprintf("Add environment variable? (have %d)", len(opts.Env)), tui.WithConfirmDefault(false)) + if err != nil || !add { + return nil, nil //nolint:nilerr // prompter cancel is a clean exit + } + entry, err := promptEnvVar(ctx, prompter) + if err != nil { + return nil, err + } + if entry == nil { + continue + } + opts.Env = append(opts.Env, entry.Name+"="+entry.ValueOrReferenceToSecret) + } + }, + Setter: func(_ any) {}, + Resetter: func() {}, + IsSet: func() bool { return len(opts.Env) > 0 }, + Value: func() any { return "" }, + } +} + +// --- 12. Min replicas --- + +func stepContainerMinReplicas(opts *containerCreateOptions) 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(opts.MinReplicas) }, + Validate: parseNonNegativeIntValidator("min replicas"), + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + opts.MinReplicas = n + }, + Resetter: func() { opts.MinReplicas = 0 }, + IsSet: func() bool { return false }, + Value: func() any { return strconv.Itoa(opts.MinReplicas) }, + } +} + +// --- 13. Max replicas --- + +func stepContainerMaxReplicas(opts *containerCreateOptions) 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(opts.MaxReplicas) }, + Validate: parsePositiveIntValidator("max replicas"), + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + opts.MaxReplicas = n + }, + Resetter: func() { opts.MaxReplicas = defaultMaxReplicas }, + IsSet: func() bool { return false }, + Value: func() any { return strconv.Itoa(opts.MaxReplicas) }, + } +} + +// --- 14. Concurrent requests per replica --- + +func stepContainerConcurrency(opts *containerCreateOptions) 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(opts.Concurrency) }, + Validate: parsePositiveIntValidator("concurrency"), + Setter: func(v any) { + n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) + opts.Concurrency = n + }, + Resetter: func() { opts.Concurrency = defaultConcurrency }, + IsSet: func() bool { return false }, + Value: func() any { return strconv.Itoa(opts.Concurrency) }, + } +} + +// --- 15. Queue-load preset --- + +func stepContainerQueuePreset(opts *containerCreateOptions) 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 opts.QueuePreset }, + Setter: func(v any) { opts.QueuePreset = v.(string) }, + Resetter: func() { opts.QueuePreset = presetBalanced }, + IsSet: func() bool { return false }, + Value: func() any { return opts.QueuePreset }, + } +} + +// --- 16. Custom queue-load (only when preset == custom) --- + +func stepContainerQueueLoadCustom(opts *containerCreateOptions) 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 opts.QueueLoad > 0 { + return strconv.Itoa(opts.QueueLoad) + } + 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))) + opts.QueueLoad = n + }, + Resetter: func() { opts.QueueLoad = 0 }, + IsSet: func() bool { return opts.QueueLoad > 0 }, + Value: func() any { return strconv.Itoa(opts.QueueLoad) }, + } +} + +// --- 17. CPU utilization trigger --- + +func stepContainerCPUUtil(opts *containerCreateOptions) wizard.Step { + return utilThresholdStep("cpu-util", "CPU utilization trigger", + &opts.CPUUtil) +} + +// --- 18. GPU utilization trigger --- + +func stepContainerGPUUtil(opts *containerCreateOptions) wizard.Step { + return utilThresholdStep("gpu-util", "GPU utilization trigger", + &opts.GPUUtil) +} + +// 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 "" + }, + } +} + +// --- 19. Scale-up delay --- + +func stepContainerScaleUpDelay(opts *containerCreateOptions) wizard.Step { + return durationStep("scale-up-delay", "Scale-up delay", &opts.ScaleUpDelay, 0) +} + +// --- 20. Scale-down delay --- + +func stepContainerScaleDownDelay(opts *containerCreateOptions) wizard.Step { + return durationStep("scale-down-delay", "Scale-down delay", &opts.ScaleDownDelay, defaultScaleDownDelay) +} + +// --- 21. Request TTL --- + +func stepContainerRequestTTL(opts *containerCreateOptions) wizard.Step { + return durationStep("request-ttl", "Request time-to-live (pending queue)", &opts.RequestTTL, defaultRequestTTL) +} + +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() }, + } +} + +// --- 22. Secret mounts (loop) --- + +func stepContainerSecretMounts(getClient clientFunc, cache *apiCache, opts *containerCreateOptions) 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, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + for { + add, err := prompter.Confirm(ctx, fmt.Sprintf("Add a secret mount? (have %d)", len(opts.SecretMounts)), tui.WithConfirmDefault(false)) + if err != nil || !add { + return nil, nil //nolint:nilerr // prompter cancel is a clean exit + } + secrets, _ := cache.fetchSecrets(ctx, getClient) + fileSecrets, _ := 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 + } + opts.SecretMounts = append(opts.SecretMounts, mount.SecretName+":"+mount.MountPath) + } + }, + Setter: func(_ any) {}, + Resetter: func() {}, + IsSet: func() bool { return len(opts.SecretMounts) > 0 }, + Value: func() any { return "" }, + } +} + +// --- Shared validators --- + +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_cache.go b/internal/verda-cli/cmd/serverless/wizard_cache.go new file mode 100644 index 0000000..fd04bd5 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard_cache.go @@ -0,0 +1,100 @@ +// 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" +) + +// 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_subflows.go b/internal/verda-cli/cmd/serverless/wizard_subflows.go new file mode 100644 index 0000000..edb59b9 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard_subflows.go @@ -0,0 +1,86 @@ +// 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. +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 { + return nil, nil //nolint:nilerr // prompter cancel is a clean exit + } + 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 { + return nil, nil //nolint:nilerr // prompter cancel is a clean exit + } + 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 || idx == len(labels)-1 { + return nil, nil //nolint:nilerr // prompter cancel is a clean exit + } + + mountPath, err := prompter.TextInput(ctx, "Mount path (e.g. /etc/secret/api-key)") + if err != nil { + return nil, nil //nolint:nilerr // prompter cancel is a clean exit + } + 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..06ff9f1 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard_summary.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 ( + "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("General storage", fmt.Sprintf("%s %d GiB (fixed)", defaultGeneralStoragePath, opts.GeneralStorageSize)) + kv("Shared memory", fmt.Sprintf("%s %d MiB", defaultSHMPath, opts.SHMSize)) + + _, _ = fmt.Fprintln(w) +} From 1c6d39a8e7c6548bc5fa97c1e1d7015476be9263 Mon Sep 17 00:00:00 2001 From: lei Date: Fri, 24 Apr 2026 21:40:59 +0300 Subject: [PATCH 02/26] feat(serverless): extract shared wizard steps, add batchjob wizard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployment type (Continuous vs Job) is a subcommand-level decision in the CLI, so the same step factories drive both create wizards: - wizard_shared.go — 10 shared step builders taking `*T` pointers: stepName, stepImage, stepCompute, stepComputeSize, stepRegistryCreds, stepPort, stepEnvVars, stepMaxReplicas, stepRequestTTL, stepSecretMounts. Plus durationStep + int validators. - wizard.go — container-only steps: compute-type (spot), healthcheck on/off/port/path, min-replicas, concurrency, queue-preset + queue-load custom, CPU/GPU util triggers, scale-up/down delays. Drops ~350 lines vs the old file since nothing is duplicated. - wizard_batchjob.go — batchjob flow (11 steps: 10 shared + deadline). Reuses the full shared factory surface; the only batchjob-specific step is stepBatchjobDeadline (required, > 0). - batchjob_create.go — launches runBatchjobWizard when any of --name/--image/--compute/--deadline is missing interactively. Prints renderBatchjobSummary + Confirm before the API call, mirroring the container flow. - CLAUDE.md + README.md updated to reflect the split. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/verda-cli/cmd/serverless/CLAUDE.md | 10 +- internal/verda-cli/cmd/serverless/README.md | 10 +- .../cmd/serverless/batchjob_create.go | 27 +- internal/verda-cli/cmd/serverless/wizard.go | 531 +++--------------- .../cmd/serverless/wizard_batchjob.go | 83 +++ .../verda-cli/cmd/serverless/wizard_shared.go | 373 ++++++++++++ .../cmd/serverless/wizard_summary.go | 38 ++ 7 files changed, 625 insertions(+), 447 deletions(-) create mode 100644 internal/verda-cli/cmd/serverless/wizard_batchjob.go create mode 100644 internal/verda-cli/cmd/serverless/wizard_shared.go diff --git a/internal/verda-cli/cmd/serverless/CLAUDE.md b/internal/verda-cli/cmd/serverless/CLAUDE.md index d4f4dd9..069c6db 100644 --- a/internal/verda-cli/cmd/serverless/CLAUDE.md +++ b/internal/verda-cli/cmd/serverless/CLAUDE.md @@ -20,10 +20,12 @@ - `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.go` — 22 step definitions + `buildContainerCreateFlow`. Step-per-field with defaults, so `make test` passes without a mock TUI because every wizard step is just a closure. - - `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. + - `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/port/path (3 sub-steps with `ShouldSkip` on the parent), min-replicas, concurrency, queue-load preset + custom override, CPU/GPU util triggers, scale-up/down delays. 22 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` prints the review card before the final confirm. **Not** a wizard step — rendered from `runContainerCreate` after the flow returns. + - `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 @@ -104,7 +106,7 @@ Describe cards (`renderContainerDeploymentCard`, `renderJobDeploymentCard`) prin - **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 serverless 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. -- **Batch-job wizard is NOT implemented yet.** `batchjob create` with missing flags errors out with "interactive wizard is coming" pointing at the design doc. The flag-driven path fully works. Follow-up: factor the shared wizard steps out of `wizard.go` and build a 13-step job flow. +- **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. diff --git a/internal/verda-cli/cmd/serverless/README.md b/internal/verda-cli/cmd/serverless/README.md index 0d62b9c..a4d3eaa 100644 --- a/internal/verda-cli/cmd/serverless/README.md +++ b/internal/verda-cli/cmd/serverless/README.md @@ -100,6 +100,14 @@ verda serverless container delete my-endpoint # destructive; requires -- ### Create +Interactive wizard (launches when any of `--name`/`--image`/`--compute`/`--deadline` is missing): + +```bash +verda serverless batchjob create +``` + +Non-interactive: + ```bash verda serverless batchjob create \ --name nightly-embed \ @@ -108,7 +116,7 @@ verda serverless batchjob create \ --deadline 30m ``` -**Required flags** (both interactive and agent modes, until the wizard lands): `--name`, `--image`, `--compute`, `--deadline`. +**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. diff --git a/internal/verda-cli/cmd/serverless/batchjob_create.go b/internal/verda-cli/cmd/serverless/batchjob_create.go index b5eb8f9..9b244de 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_create.go +++ b/internal/verda-cli/cmd/serverless/batchjob_create.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/wizard" cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) @@ -122,9 +123,9 @@ func runBatchjobCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. return cmdutil.NewMissingFlagsError(missing) } } else if opts.Name == "" || opts.Image == "" || opts.Compute == "" || opts.Deadline <= 0 { - // TODO(wizard): launch the batchjob wizard here. - return errors.New("missing required flags: --name, --image, --compute, --deadline are required " + - "(interactive wizard is coming; track progress in docs/plans/2026-04-24-serverless-container-design.md)") + if err := runBatchjobWizard(cmd.Context(), f, ioStreams, opts); err != nil { + return err + } } req, err := opts.request() @@ -134,6 +135,15 @@ func runBatchjobCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. 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 || !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + } + ctx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) defer cancel() @@ -153,6 +163,17 @@ func runBatchjobCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. return nil } +// runBatchjobWizard drives the batchjob create flow and fills any fields the +// user hasn't pre-set via flags. Shares nine of its ten steps with the +// container wizard; the only batchjob-specific step is the 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 == "" { diff --git a/internal/verda-cli/cmd/serverless/wizard.go b/internal/verda-cli/cmd/serverless/wizard.go index 19f4b28..48c4bb3 100644 --- a/internal/verda-cli/cmd/serverless/wizard.go +++ b/internal/verda-cli/cmd/serverless/wizard.go @@ -17,15 +17,18 @@ package serverless import ( "context" "errors" - "fmt" "strconv" "strings" "time" - "github.com/verda-cloud/verdagostack/pkg/tui" "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" @@ -34,67 +37,49 @@ const ( healthcheckOff = "off" utilOff = "off" + + registryPublicValue = "__public__" ) // buildContainerCreateFlow returns the full wizard flow for `verda serverless -// container create`. Each step has a matching flag on containerCreateOptions, +// 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, so the review card -// has full-width layout control. +// 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{ - stepContainerName(opts), - stepContainerComputeType(opts), - stepContainerCompute(getClient, cache, opts), - stepContainerComputeSize(opts), - stepContainerImage(opts), - stepContainerRegistryCreds(getClient, cache, opts), - stepContainerPort(opts), - stepContainerHealthcheck(opts), - stepContainerHealthcheckPort(opts), - stepContainerHealthcheckPath(opts), - stepContainerEnvVars(opts), - stepContainerMinReplicas(opts), - stepContainerMaxReplicas(opts), - stepContainerConcurrency(opts), - stepContainerQueuePreset(opts), - stepContainerQueueLoadCustom(opts), - stepContainerCPUUtil(opts), - stepContainerGPUUtil(opts), - stepContainerScaleUpDelay(opts), - stepContainerScaleDownDelay(opts), - stepContainerRequestTTL(opts), - stepContainerSecretMounts(getClient, cache, opts), - }, - } -} - -// --- 1. Deployment name --- - -func stepContainerName(opts *containerCreateOptions) 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 opts.Name }, - Validate: func(v any) error { - return validateDeploymentName(strings.TrimSpace(v.(string))) + 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), + stepContainerHealthcheckPort(&opts.HealthcheckPort), + 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), }, - Setter: func(v any) { opts.Name = strings.TrimSpace(v.(string)) }, - Resetter: func() { opts.Name = "" }, - IsSet: func() bool { return opts.Name != "" }, - Value: func() any { return opts.Name }, } } -// --- 2. Compute type (on-demand | spot) --- +// --- Compute type (on-demand | spot) --- -func stepContainerComputeType(opts *containerCreateOptions) wizard.Step { +func stepContainerComputeType(spot *bool) wizard.Step { return wizard.Step{ Name: "compute-type", Description: "Compute type", @@ -105,16 +90,16 @@ func stepContainerComputeType(opts *containerCreateOptions) wizard.Step { wizard.Choice{Label: "Spot", Value: computeTypeSpot, Description: "Lower price; may be reclaimed at any time"}, ), Default: func(_ map[string]any) any { - if opts.Spot { + if *spot { return computeTypeSpot } return computeTypeOnDemand }, - Setter: func(v any) { opts.Spot = v.(string) == computeTypeSpot }, - Resetter: func() { opts.Spot = false }, - IsSet: func() bool { return false }, // always prompt + Setter: func(v any) { *spot = v.(string) == computeTypeSpot }, + Resetter: func() { *spot = false }, + IsSet: func() bool { return false }, Value: func() any { - if opts.Spot { + if *spot { return computeTypeSpot } return computeTypeOnDemand @@ -122,168 +107,9 @@ func stepContainerComputeType(opts *containerCreateOptions) wizard.Step { } } -// --- 3. Compute resource (GPU/CPU pick from /serverless-compute-resources) --- - -func stepContainerCompute(getClient clientFunc, cache *apiCache, opts *containerCreateOptions) wizard.Step { - return wizard.Step{ - Name: "compute", - Description: "Compute resource", - Prompt: wizard.SelectPrompt, - Required: true, - Loader: func(ctx context.Context, _ tui.Prompter, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { - res, err := 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 opts.Compute }, - Setter: func(v any) { opts.Compute = v.(string) }, - Resetter: func() { opts.Compute = "" }, - IsSet: func() bool { return opts.Compute != "" }, - Value: func() any { return opts.Compute }, - } -} - -// --- 4. Compute size (count of GPUs or vCPUs) --- - -func stepContainerComputeSize(opts *containerCreateOptions) wizard.Step { - return wizard.Step{ - Name: "compute-size", - Description: "Compute size (number of GPUs/vCPUs per replica)", - Prompt: wizard.TextInputPrompt, - Required: true, - Default: func(_ map[string]any) any { - if opts.ComputeSize > 0 { - return strconv.Itoa(opts.ComputeSize) - } - return "1" - }, - Validate: parsePositiveIntValidator("compute size"), - Setter: func(v any) { - n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) - opts.ComputeSize = n - }, - Resetter: func() { opts.ComputeSize = 0 }, - IsSet: func() bool { return opts.ComputeSize > 0 }, - Value: func() any { return strconv.Itoa(opts.ComputeSize) }, - } -} - -// --- 5. Container image --- - -func stepContainerImage(opts *containerCreateOptions) 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 opts.Image }, - 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) { opts.Image = strings.TrimSpace(v.(string)) }, - Resetter: func() { opts.Image = "" }, - IsSet: func() bool { return opts.Image != "" }, - Value: func() any { return opts.Image }, - } -} - -// --- 6. Registry credentials --- - -const registryPublicValue = "__public__" - -func stepContainerRegistryCreds(getClient clientFunc, cache *apiCache, opts *containerCreateOptions) 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, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { - choices := []wizard.Choice{ - {Label: "Public (no credentials)", Value: registryPublicValue}, - } - creds, err := 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 { - choices = append(choices, wizard.Choice{ - Label: c.Name, - Value: c.Name, - }) - } - return choices, nil - }, - Default: func(_ map[string]any) any { - if opts.RegistryCreds == "" { - return registryPublicValue - } - return opts.RegistryCreds - }, - Setter: func(v any) { - s := v.(string) - if s == registryPublicValue { - opts.RegistryCreds = "" - return - } - opts.RegistryCreds = s - }, - Resetter: func() { opts.RegistryCreds = "" }, - IsSet: func() bool { return opts.RegistryCreds != "" }, - Value: func() any { - if opts.RegistryCreds == "" { - return registryPublicValue - } - return opts.RegistryCreds - }, - } -} - -// --- 7. Exposed HTTP port --- - -func stepContainerPort(opts *containerCreateOptions) 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(opts.Port) }, - Validate: parsePortValidator("port"), - Setter: func(v any) { - n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) - opts.Port = n - }, - Resetter: func() { opts.Port = defaultExposedPort }, - IsSet: func() bool { return false }, // always show the step; default carries the pre-set value - Value: func() any { return strconv.Itoa(opts.Port) }, - } -} - -// --- 8-10. Healthcheck (on/off + port + path) --- +// --- Healthcheck (on/off + port + path) --- -func stepContainerHealthcheck(opts *containerCreateOptions) wizard.Step { +func stepContainerHealthcheck(off *bool) wizard.Step { return wizard.Step{ Name: "healthcheck", Description: "Healthcheck", @@ -294,16 +120,16 @@ func stepContainerHealthcheck(opts *containerCreateOptions) wizard.Step { wizard.Choice{Label: "Off", Value: healthcheckOff, Description: "Route requests immediately"}, ), Default: func(_ map[string]any) any { - if opts.HealthcheckOff { + if *off { return healthcheckOff } return healthcheckOn }, - Setter: func(v any) { opts.HealthcheckOff = v.(string) == healthcheckOff }, - Resetter: func() { opts.HealthcheckOff = false }, + Setter: func(v any) { *off = v.(string) == healthcheckOff }, + Resetter: func() { *off = false }, IsSet: func() bool { return false }, Value: func() any { - if opts.HealthcheckOff { + if *off { return healthcheckOff } return healthcheckOn @@ -311,7 +137,7 @@ func stepContainerHealthcheck(opts *containerCreateOptions) wizard.Step { } } -func stepContainerHealthcheckPort(opts *containerCreateOptions) wizard.Step { +func stepContainerHealthcheckPort(port *int) wizard.Step { return wizard.Step{ Name: "healthcheck-port", Description: "Healthcheck port (blank = same as exposed)", @@ -322,8 +148,8 @@ func stepContainerHealthcheckPort(opts *containerCreateOptions) wizard.Step { return c["healthcheck"] == healthcheckOff }, Default: func(_ map[string]any) any { - if opts.HealthcheckPort > 0 { - return strconv.Itoa(opts.HealthcheckPort) + if *port > 0 { + return strconv.Itoa(*port) } return "" }, @@ -337,19 +163,19 @@ func stepContainerHealthcheckPort(opts *containerCreateOptions) wizard.Step { Setter: func(v any) { s := strings.TrimSpace(v.(string)) if s == "" { - opts.HealthcheckPort = 0 + *port = 0 return } n, _ := strconv.Atoi(s) - opts.HealthcheckPort = n + *port = n }, - Resetter: func() { opts.HealthcheckPort = 0 }, - IsSet: func() bool { return opts.HealthcheckPort > 0 }, - Value: func() any { return strconv.Itoa(opts.HealthcheckPort) }, + Resetter: func() { *port = 0 }, + IsSet: func() bool { return *port > 0 }, + Value: func() any { return strconv.Itoa(*port) }, } } -func stepContainerHealthcheckPath(opts *containerCreateOptions) wizard.Step { +func stepContainerHealthcheckPath(path *string) wizard.Step { return wizard.Step{ Name: "healthcheck-path", Description: "Healthcheck path", @@ -359,109 +185,57 @@ func stepContainerHealthcheckPath(opts *containerCreateOptions) wizard.Step { ShouldSkip: func(c map[string]any) bool { return c["healthcheck"] == healthcheckOff }, - Default: func(_ map[string]any) any { return opts.HealthcheckPath }, - Setter: func(v any) { opts.HealthcheckPath = strings.TrimSpace(v.(string)) }, - Resetter: func() { opts.HealthcheckPath = defaultHealthcheckPath }, + 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 opts.HealthcheckPath }, + Value: func() any { return *path }, } } -// --- 11. Env vars (loop) --- +// --- Min replicas (container-only; batchjob has no min) --- -func stepContainerEnvVars(opts *containerCreateOptions) 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) { - // Loop: add env vars until the user says "done". - for { - add, err := prompter.Confirm(ctx, fmt.Sprintf("Add environment variable? (have %d)", len(opts.Env)), tui.WithConfirmDefault(false)) - if err != nil || !add { - return nil, nil //nolint:nilerr // prompter cancel is a clean exit - } - entry, err := promptEnvVar(ctx, prompter) - if err != nil { - return nil, err - } - if entry == nil { - continue - } - opts.Env = append(opts.Env, entry.Name+"="+entry.ValueOrReferenceToSecret) - } - }, - Setter: func(_ any) {}, - Resetter: func() {}, - IsSet: func() bool { return len(opts.Env) > 0 }, - Value: func() any { return "" }, - } -} - -// --- 12. Min replicas --- - -func stepContainerMinReplicas(opts *containerCreateOptions) wizard.Step { +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(opts.MinReplicas) }, + 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))) - opts.MinReplicas = n - }, - Resetter: func() { opts.MinReplicas = 0 }, - IsSet: func() bool { return false }, - Value: func() any { return strconv.Itoa(opts.MinReplicas) }, - } -} - -// --- 13. Max replicas --- - -func stepContainerMaxReplicas(opts *containerCreateOptions) 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(opts.MaxReplicas) }, - Validate: parsePositiveIntValidator("max replicas"), - Setter: func(v any) { - n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) - opts.MaxReplicas = n + *target = n }, - Resetter: func() { opts.MaxReplicas = defaultMaxReplicas }, + Resetter: func() { *target = 0 }, IsSet: func() bool { return false }, - Value: func() any { return strconv.Itoa(opts.MaxReplicas) }, + Value: func() any { return strconv.Itoa(*target) }, } } -// --- 14. Concurrent requests per replica --- +// --- Concurrency --- -func stepContainerConcurrency(opts *containerCreateOptions) wizard.Step { +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(opts.Concurrency) }, + Default: func(_ map[string]any) any { return strconv.Itoa(*target) }, Validate: parsePositiveIntValidator("concurrency"), Setter: func(v any) { n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) - opts.Concurrency = n + *target = n }, - Resetter: func() { opts.Concurrency = defaultConcurrency }, + Resetter: func() { *target = defaultConcurrency }, IsSet: func() bool { return false }, - Value: func() any { return strconv.Itoa(opts.Concurrency) }, + Value: func() any { return strconv.Itoa(*target) }, } } -// --- 15. Queue-load preset --- +// --- Queue-load preset + custom value --- -func stepContainerQueuePreset(opts *containerCreateOptions) wizard.Step { +func stepContainerQueuePreset(target *string) wizard.Step { return wizard.Step{ Name: "queue-preset", Description: "Queue-load preset", @@ -473,17 +247,15 @@ func stepContainerQueuePreset(opts *containerCreateOptions) wizard.Step { 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 opts.QueuePreset }, - Setter: func(v any) { opts.QueuePreset = v.(string) }, - Resetter: func() { opts.QueuePreset = presetBalanced }, + 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 opts.QueuePreset }, + Value: func() any { return *target }, } } -// --- 16. Custom queue-load (only when preset == custom) --- - -func stepContainerQueueLoadCustom(opts *containerCreateOptions) wizard.Step { +func stepContainerQueueLoadCustom(target *int) wizard.Step { return wizard.Step{ Name: "queue-load-custom", Description: "Custom queue-load threshold (1..1000)", @@ -494,8 +266,8 @@ func stepContainerQueueLoadCustom(opts *containerCreateOptions) wizard.Step { return c["queue-preset"] != presetCustom }, Default: func(_ map[string]any) any { - if opts.QueueLoad > 0 { - return strconv.Itoa(opts.QueueLoad) + if *target > 0 { + return strconv.Itoa(*target) } return strconv.Itoa(queueLoadBalanced) }, @@ -508,26 +280,22 @@ func stepContainerQueueLoadCustom(opts *containerCreateOptions) wizard.Step { }, Setter: func(v any) { n, _ := strconv.Atoi(strings.TrimSpace(v.(string))) - opts.QueueLoad = n + *target = n }, - Resetter: func() { opts.QueueLoad = 0 }, - IsSet: func() bool { return opts.QueueLoad > 0 }, - Value: func() any { return strconv.Itoa(opts.QueueLoad) }, + Resetter: func() { *target = 0 }, + IsSet: func() bool { return *target > 0 }, + Value: func() any { return strconv.Itoa(*target) }, } } -// --- 17. CPU utilization trigger --- +// --- Utilization triggers (CPU/GPU) --- -func stepContainerCPUUtil(opts *containerCreateOptions) wizard.Step { - return utilThresholdStep("cpu-util", "CPU utilization trigger", - &opts.CPUUtil) +func stepContainerCPUUtil(target *int) wizard.Step { + return utilThresholdStep("cpu-util", "CPU utilization trigger", target) } -// --- 18. GPU utilization trigger --- - -func stepContainerGPUUtil(opts *containerCreateOptions) wizard.Step { - return utilThresholdStep("gpu-util", "GPU utilization trigger", - &opts.GPUUtil) +func stepContainerGPUUtil(target *int) wizard.Step { + return utilThresholdStep("gpu-util", "GPU utilization trigger", target) } // utilThresholdStep builds a step that asks "off | " as a text @@ -576,127 +344,12 @@ func utilThresholdStep(name, desc string, target *int) wizard.Step { } } -// --- 19. Scale-up delay --- - -func stepContainerScaleUpDelay(opts *containerCreateOptions) wizard.Step { - return durationStep("scale-up-delay", "Scale-up delay", &opts.ScaleUpDelay, 0) -} - -// --- 20. Scale-down delay --- - -func stepContainerScaleDownDelay(opts *containerCreateOptions) wizard.Step { - return durationStep("scale-down-delay", "Scale-down delay", &opts.ScaleDownDelay, defaultScaleDownDelay) -} - -// --- 21. Request TTL --- - -func stepContainerRequestTTL(opts *containerCreateOptions) wizard.Step { - return durationStep("request-ttl", "Request time-to-live (pending queue)", &opts.RequestTTL, defaultRequestTTL) -} - -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() }, - } -} - -// --- 22. Secret mounts (loop) --- +// --- Scale-up / scale-down delays --- -func stepContainerSecretMounts(getClient clientFunc, cache *apiCache, opts *containerCreateOptions) 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, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { - for { - add, err := prompter.Confirm(ctx, fmt.Sprintf("Add a secret mount? (have %d)", len(opts.SecretMounts)), tui.WithConfirmDefault(false)) - if err != nil || !add { - return nil, nil //nolint:nilerr // prompter cancel is a clean exit - } - secrets, _ := cache.fetchSecrets(ctx, getClient) - fileSecrets, _ := 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 - } - opts.SecretMounts = append(opts.SecretMounts, mount.SecretName+":"+mount.MountPath) - } - }, - Setter: func(_ any) {}, - Resetter: func() {}, - IsSet: func() bool { return len(opts.SecretMounts) > 0 }, - Value: func() any { return "" }, - } -} - -// --- Shared validators --- - -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 stepContainerScaleUpDelay(target *time.Duration) wizard.Step { + return durationStep("scale-up-delay", "Scale-up delay", target, 0) } -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 - } +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..e71d202 --- /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 serverless +// 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_shared.go b/internal/verda-cli/cmd/serverless/wizard_shared.go new file mode 100644 index 0000000..dfe2513 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wizard_shared.go @@ -0,0 +1,373 @@ +// 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/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, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + res, err := 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, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + choices := []wizard.Choice{ + {Label: "Public (no credentials)", Value: registryPublicValue}, + } + creds, err := 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 { + 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) --- + +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 || !add { + return nil, nil //nolint:nilerr // prompter cancel is a clean exit + } + 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, _ 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 || !add { + return nil, nil //nolint:nilerr // prompter cancel is a clean exit + } + secrets, _ := cache.fetchSecrets(ctx, getClient) + fileSecrets, _ := 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_summary.go b/internal/verda-cli/cmd/serverless/wizard_summary.go index 06ff9f1..ec5199b 100644 --- a/internal/verda-cli/cmd/serverless/wizard_summary.go +++ b/internal/verda-cli/cmd/serverless/wizard_summary.go @@ -89,3 +89,41 @@ func renderContainerSummary(w io.Writer, opts *containerCreateOptions) { _, _ = 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("General storage", fmt.Sprintf("%s %d GiB (fixed)", defaultGeneralStoragePath, opts.GeneralStorageSize)) + kv("Shared memory", fmt.Sprintf("%s %d MiB", defaultSHMPath, opts.SHMSize)) + _, _ = fmt.Fprintln(w) +} From 419825bca1aefa99fb2be8745545d6db6c54e7ef Mon Sep 17 00:00:00 2001 From: lei Date: Fri, 15 May 2026 11:49:50 +0300 Subject: [PATCH 03/26] feat: serverless polish and CLI UX improvements - Promote container and batchjob to top-level commands (drop `verda serverless` parent) - Add `verda` banner + logo on no-args / help path - Read version-update hint from on-disk cache instead of fetching GitHub at hot-path - HTTP debug transport: `--debug` now logs request/response (with auth/JSON-secret redaction) for all commands, including failed calls - `container list` and `vm list`: keep details visible after selection with explicit "Back to list / Exit" gate - `container list`: parallel per-deployment status fetch (5 concurrent) cached for 30s, `--status` substring filter, interactive selector with type-to-filter and describe drill-down loop - Serverless wizard refactor: shared step factories used by both container and batchjob create flows; new wire-format tests guard the create-request JSON shape - pre-commit: replace dnephin go-unit-tests (hardcoded -timeout=30s) with local hook running `make test` Co-Authored-By: Claude Opus 4.7 (1M context) --- .pre-commit-config.yaml | 11 +- internal/verda-cli/cmd/banner.go | 60 ++++ internal/verda-cli/cmd/banner_test.go | 28 ++ internal/verda-cli/cmd/cmd.go | 48 +-- internal/verda-cli/cmd/cmd_test.go | 9 +- internal/verda-cli/cmd/doctor/doctor.go | 9 +- internal/verda-cli/cmd/serverless/README.md | 50 +-- internal/verda-cli/cmd/serverless/batchjob.go | 4 +- .../cmd/serverless/batchjob_actions.go | 42 ++- .../cmd/serverless/batchjob_create.go | 24 +- .../cmd/serverless/batchjob_create_test.go | 18 +- .../cmd/serverless/batchjob_delete.go | 5 +- .../cmd/serverless/batchjob_describe.go | 10 +- .../verda-cli/cmd/serverless/container.go | 4 +- .../cmd/serverless/container_actions.go | 47 +-- .../cmd/serverless/container_create.go | 68 ++-- .../cmd/serverless/container_create_test.go | 63 ++-- .../cmd/serverless/container_delete.go | 5 +- .../cmd/serverless/container_describe.go | 13 +- .../cmd/serverless/container_list.go | 183 ++++++++-- .../cmd/serverless/container_status_cache.go | 112 ++++++ .../verda-cli/cmd/serverless/serverless.go | 41 --- internal/verda-cli/cmd/serverless/shared.go | 33 +- .../cmd/serverless/wire_format_test.go | 332 ++++++++++++++++++ internal/verda-cli/cmd/serverless/wizard.go | 49 +-- .../cmd/serverless/wizard_batchjob.go | 4 +- .../verda-cli/cmd/serverless/wizard_cache.go | 24 ++ .../verda-cli/cmd/serverless/wizard_shared.go | 54 ++- .../cmd/serverless/wizard_subflows.go | 28 +- .../cmd/serverless/wizard_summary.go | 6 +- internal/verda-cli/cmd/update/update.go | 12 +- internal/verda-cli/cmd/util/factory.go | 97 ++++- internal/verda-cli/cmd/util/versionhint.go | 35 +- internal/verda-cli/cmd/verda-logo.png | Bin 0 -> 29199 bytes internal/verda-cli/cmd/vm/list.go | 11 +- 35 files changed, 1180 insertions(+), 359 deletions(-) create mode 100644 internal/verda-cli/cmd/banner.go create mode 100644 internal/verda-cli/cmd/banner_test.go create mode 100644 internal/verda-cli/cmd/serverless/container_status_cache.go delete mode 100644 internal/verda-cli/cmd/serverless/serverless.go create mode 100644 internal/verda-cli/cmd/serverless/wire_format_test.go create mode 100644 internal/verda-cli/cmd/verda-logo.png 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/internal/verda-cli/cmd/banner.go b/internal/verda-cli/cmd/banner.go new file mode 100644 index 0000000..2990525 --- /dev/null +++ b/internal/verda-cli/cmd/banner.go @@ -0,0 +1,60 @@ +// 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 renders the embedded Verda logo via the iTerm2 inline image +// escape sequence when out is a TTY on a known-supporting terminal. Skipped +// silently in every other case — pipes, narrow terminals, non-supporting +// terminals like VS Code or stock Terminal.app — because terminals that do +// not understand OSC 1337 would print the sequence as 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 cells; width auto via preserveAspectRatio. + _, _ = fmt.Fprintf(f, + "\x1b]1337;File=inline=1;height=6;preserveAspectRatio=1:%s\x07\n\n", + enc) +} + +// supportsITermImageProtocol reports whether the current terminal accepts +// iTerm2's inline image escape. Detection is by-name only: spoofable, but +// the failure mode (escape printed verbatim) is purely visual, and an +// unrecognized terminal silently shows no banner. +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 d97af6b..cb6d07d 100644 --- a/internal/verda-cli/cmd/cmd.go +++ b/internal/verda-cli/cmd/cmd.go @@ -71,6 +71,7 @@ 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 { @@ -102,19 +103,22 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op 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. + // Version-update hint is best-effort and reads ONLY from the + // on-disk cache — never the network. `verda` and `verda --help` + // must feel instant, so we don't pay 1–5s for a GitHub fetch on + // the help path. The cache is refreshed by `verda doctor`, which + // runs its own check (with a spinner) and does block on the + // network because the user invoked it explicitly. `verda update` + // fetches the latest tag directly to drive its own flow but does + // not write the version cache. 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) @@ -136,7 +140,7 @@ 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), @@ -172,7 +176,8 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op { Message: "Serverless Commands:", Commands: []*cobra.Command{ - serverless.NewCmdServerless(f, ioStreams), + serverless.NewCmdContainer(f, ioStreams), + serverless.NewCmdBatchjob(f, ioStreams), }, }, { @@ -256,25 +261,22 @@ 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. +// shouldCheckVersion returns true for commands where it makes sense to print a +// cached "Update available" hint. The PostRun hook never touches the network — +// it only reads the on-disk cache populated by `verda doctor`. // // 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) +// - `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. +// Excluded: +// - `doctor` / `update` — they print version info themselves (with spinners) +// and a trailing duplicate hint just adds noise. +// - every business command (`vm list`, `volume rm`, etc.) — they must feel +// instant and never pay any cost, even cache I/O, for a cosmetic hint. 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 diff --git a/internal/verda-cli/cmd/cmd_test.go b/internal/verda-cli/cmd/cmd_test.go index 17f21ea..7d34069 100644 --- a/internal/verda-cli/cmd/cmd_test.go +++ b/internal/verda-cli/cmd/cmd_test.go @@ -55,12 +55,15 @@ 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}, + // ---- Yes: help paths read from the cache to print a hint. ---- {"help", newCmd("help"), true}, {"verda root (bare)", newCmd("verda"), true}, + // ---- No: doctor / update print their own version info (with + // spinners) — a trailing duplicate hint would just be noise. + {"doctor", newCmd("doctor"), false}, + {"update", newCmd("update"), false}, + // ---- No: resource / business commands. They must NEVER do a // network fetch or even read the cache to print a cosmetic hint. {"vm", newCmd("vm"), false}, diff --git a/internal/verda-cli/cmd/doctor/doctor.go b/internal/verda-cli/cmd/doctor/doctor.go index 31dfaae..c657a2b 100644 --- a/internal/verda-cli/cmd/doctor/doctor.go +++ b/internal/verda-cli/cmd/doctor/doctor.go @@ -82,11 +82,18 @@ 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) + // The CLI-version check is the slowest step (GitHub API round-trip). + // Show a spinner so users know the command is working — without it, + // doctor sits silent for up to ~2s. + 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 + versionResult, // 4. CLI up to date checkBinaryInstalled(), // 5. Binary installed checkTemplatesDir(), // 6. Templates directory checkConfigDir(), // 7. Config directory diff --git a/internal/verda-cli/cmd/serverless/README.md b/internal/verda-cli/cmd/serverless/README.md index a4d3eaa..3a61764 100644 --- a/internal/verda-cli/cmd/serverless/README.md +++ b/internal/verda-cli/cmd/serverless/README.md @@ -1,12 +1,14 @@ -# `verda serverless` +# `verda container` / `verda batchjob` Manage serverless container deployments (always-on endpoints) and batch-job deployments (one-shot runs) on Verda Cloud. ``` -verda serverless container # → /container-deployments (continuous; supports spot) -verda serverless batchjob # → /job-deployments (one-shot; deadline-based; no spot) +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 @@ -14,13 +16,13 @@ verda serverless batchjob # → /job-deployments (one-shot; deadline-based; n Interactive wizard (launches when any of `--name`/`--image`/`--compute` is missing): ```bash -verda serverless container create +verda container create ``` Non-interactive: ```bash -verda serverless container create \ +verda container create \ --name my-endpoint \ --image ghcr.io/ai-dock/comfyui:cpu-22.04 \ --compute RTX4500Ada --compute-size 1 @@ -29,7 +31,7 @@ verda serverless container create \ With private registry + env + custom scaling: ```bash -verda serverless container create \ +verda container create \ --name my-api --image ghcr.io/me/llm:v1.2 \ --compute RTX4500Ada --compute-size 1 \ --registry-creds my-ghcr \ @@ -76,18 +78,18 @@ verda serverless container create \ ### Storage - `--secret-mount SECRET:/path` (repeatable) — mount a project secret as a file -- General storage at `/data` (500 GiB) and SHM at `/dev/shm` (64 MiB) are included automatically and cannot be edited today. Flags exist (`--general-storage-size`, `--shm-size`) for forward-compatibility when the API exposes them. +- 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 serverless container list -verda serverless container describe my-endpoint -verda serverless container pause my-endpoint # stop serving requests -verda serverless container resume my-endpoint -verda serverless container restart my-endpoint # destructive; requires --yes in agent mode -verda serverless container purge-queue my-endpoint # destructive; requires --yes in agent mode -verda serverless container delete my-endpoint # destructive; requires --yes in agent mode +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: @@ -103,13 +105,13 @@ verda serverless container delete my-endpoint # destructive; requires -- Interactive wizard (launches when any of `--name`/`--image`/`--compute`/`--deadline` is missing): ```bash -verda serverless batchjob create +verda batchjob create ``` Non-interactive: ```bash -verda serverless batchjob create \ +verda batchjob create \ --name nightly-embed \ --image ghcr.io/me/embedder:v1 \ --compute RTX4500Ada --compute-size 1 \ @@ -132,12 +134,12 @@ verda serverless batchjob create \ Identical shape to container, minus `restart` (not supported by the job-deployment API): ```bash -verda serverless batchjob list -verda serverless batchjob describe nightly-embed -verda serverless batchjob pause nightly-embed -verda serverless batchjob resume nightly-embed -verda serverless batchjob purge-queue nightly-embed # destructive -verda serverless batchjob delete nightly-embed # destructive +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 @@ -145,11 +147,11 @@ verda serverless batchjob delete nightly-embed # destructive 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 serverless container create \ +verda --agent container create \ --name api --image ghcr.io/org/app:v1 \ --compute RTX4500Ada --compute-size 1 -o json -verda --agent serverless container delete api --yes -o json +verda --agent container delete api --yes -o json ``` ## Environment variables diff --git a/internal/verda-cli/cmd/serverless/batchjob.go b/internal/verda-cli/cmd/serverless/batchjob.go index aca29f5..c97feb1 100644 --- a/internal/verda-cli/cmd/serverless/batchjob.go +++ b/internal/verda-cli/cmd/serverless/batchjob.go @@ -20,8 +20,8 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) -// newCmdBatchjob creates the `verda serverless batchjob` subcommand tree. -func newCmdBatchjob(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { +// 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)", diff --git a/internal/verda-cli/cmd/serverless/batchjob_actions.go b/internal/verda-cli/cmd/serverless/batchjob_actions.go index 51eed48..8a8976f 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_actions.go +++ b/internal/verda-cli/cmd/serverless/batchjob_actions.go @@ -26,7 +26,9 @@ import ( type batchjobActionFn func(ctx context.Context, client *verda.Client, name string) error -func newBatchjobActionCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, verb, short, spinner, successMsg string, destructive bool, fn batchjobActionFn) *cobra.Command { +// detailMsg is a Sprintf template with one %q for the deployment name; it is +// only consulted for destructive verbs (used in the confirm prompt detail). +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 + " ", @@ -37,7 +39,7 @@ func newBatchjobActionCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, verb, if err != nil || name == "" { return err } - return runBatchjobAction(cmd, f, ioStreams, name, verb, spinner, successMsg, destructive, yes, fn) + return runBatchjobAction(cmd, f, ioStreams, name, verb, spinner, successMsg, detailMsg, destructive, yes, fn) }, } if destructive { @@ -46,26 +48,27 @@ func newBatchjobActionCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, verb, return cmd } -func runBatchjobAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, name, verb, spinner, successMsg string, destructive, yes bool, fn batchjobActionFn) error { +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 { - if f.AgentMode() && !yes { - return cmdutil.NewConfirmationRequiredError(verb) + 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 !yes { - confirmed, err := confirmDestructive(cmd.Context(), ioStreams, f.Prompter(), - verb+" batch-job deployment", - fmt.Sprintf("Deployment %q will be %sd.", name, verb), - fmt.Sprintf("%s %s?", verb, name), - ) - if err != nil || !confirmed { - _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") - return nil - } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil } } @@ -91,7 +94,7 @@ func runBatchjobAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. 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, + "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) }, @@ -100,7 +103,7 @@ func newCmdBatchjobPause(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra. 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, + "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) }, @@ -109,7 +112,8 @@ func newCmdBatchjobResume(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra 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", true, + "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 index 9b244de..d54c786 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_create.go +++ b/internal/verda-cli/cmd/serverless/batchjob_create.go @@ -49,20 +49,16 @@ type batchjobCreateOptions struct { Deadline time.Duration RequestTTL time.Duration - SecretMounts []string - GeneralStorageSize int - SHMSize int + SecretMounts []string Yes bool } func newCmdBatchjobCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { opts := &batchjobCreateOptions{ - Port: defaultExposedPort, - MaxReplicas: defaultMaxReplicas, - RequestTTL: defaultRequestTTL, - GeneralStorageSize: defaultGeneralStorageGiB, - SHMSize: defaultSHMMiB, + Port: defaultExposedPort, + MaxReplicas: defaultMaxReplicas, + RequestTTL: defaultRequestTTL, } cmd := &cobra.Command{ @@ -74,7 +70,7 @@ func newCmdBatchjobCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra cannot use spot compute; --deadline is required. `), Example: cmdutil.Examples(` - verda serverless batchjob create \ + verda batchjob create \ --name nightly-embed \ --image ghcr.io/me/embedder:v1 \ --compute RTX4500Ada --compute-size 1 \ @@ -104,8 +100,6 @@ func newCmdBatchjobCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra 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.IntVar(&opts.GeneralStorageSize, "general-storage-size", opts.GeneralStorageSize, "Size of the fixed /data mount in GiB") - flags.IntVar(&opts.SHMSize, "shm-size", opts.SHMSize, "Size of the /dev/shm mount in MiB") flags.BoolVarP(&opts.Yes, "yes", "y", false, "Skip confirmation (required in agent mode)") @@ -138,6 +132,9 @@ func runBatchjobCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. 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 @@ -215,7 +212,7 @@ func (o *batchjobCreateOptions) request() (*verda.CreateJobDeploymentRequest, er if err != nil { return nil, err } - mounts, err := buildVolumeMounts(o.SecretMounts, o.GeneralStorageSize, o.SHMSize) + mounts, err := buildVolumeMounts(o.SecretMounts) if err != nil { return nil, err } @@ -229,6 +226,9 @@ func (o *batchjobCreateOptions) request() (*verda.CreateJobDeploymentRequest, er } } + // CreateJobDeploymentRequest.ContainerRegistrySettings is a pointer (unlike + // the container variant, which is a value with IsPrivate:false for public). + // Pass nil for public images so the field is omitted from the request body. registry := (*verda.ContainerRegistrySettings)(nil) if o.RegistryCreds != "" { registry = &verda.ContainerRegistrySettings{ diff --git a/internal/verda-cli/cmd/serverless/batchjob_create_test.go b/internal/verda-cli/cmd/serverless/batchjob_create_test.go index ab7d828..d70c368 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_create_test.go +++ b/internal/verda-cli/cmd/serverless/batchjob_create_test.go @@ -22,16 +22,14 @@ import ( 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, - GeneralStorageSize: defaultGeneralStorageGiB, - SHMSize: defaultSHMMiB, + 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, } } diff --git a/internal/verda-cli/cmd/serverless/batchjob_delete.go b/internal/verda-cli/cmd/serverless/batchjob_delete.go index acccee7..ecc9b27 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_delete.go +++ b/internal/verda-cli/cmd/serverless/batchjob_delete.go @@ -61,7 +61,10 @@ func runBatchjobDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. fmt.Sprintf("Deployment %q will stop accepting jobs immediately.", name), fmt.Sprintf("Delete %s?", name), ) - if err != nil || !confirmed { + if err != nil { + return err + } + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } diff --git a/internal/verda-cli/cmd/serverless/batchjob_describe.go b/internal/verda-cli/cmd/serverless/batchjob_describe.go index 6803fc1..6aad52e 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_describe.go +++ b/internal/verda-cli/cmd/serverless/batchjob_describe.go @@ -17,6 +17,7 @@ package serverless import ( "context" "fmt" + "io" "strings" "charm.land/lipgloss/v2" @@ -86,7 +87,10 @@ func selectBatchjobDeployment(ctx context.Context, f cmdutil.Factory, ioStreams idx, err := f.Prompter().Select(ctx, "Select batch-job deployment", labels) if err != nil { - return "", nil // prompter cancel is a clean exit + if isPromptCancel(err) { + return "", nil + } + return "", err } if idx == len(jobs) { return "", nil @@ -117,6 +121,8 @@ func runBatchjobDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmduti cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployment:", job) + // See container_describe.go for the embed-vs-explicit-fields tradeoff — + // same caveat applies if verda.JobDeployment ever grows a Status field. if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), struct { *verda.JobDeployment Status string `json:"status,omitempty"` @@ -128,7 +134,7 @@ func runBatchjobDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmduti return nil } -func renderJobDeploymentCard(w interface{ Write(p []byte) (int, error) }, j *verda.JobDeployment, status string) { +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")) diff --git a/internal/verda-cli/cmd/serverless/container.go b/internal/verda-cli/cmd/serverless/container.go index a15e412..a0e2634 100644 --- a/internal/verda-cli/cmd/serverless/container.go +++ b/internal/verda-cli/cmd/serverless/container.go @@ -20,8 +20,8 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) -// newCmdContainer creates the `verda serverless container` subcommand tree. -func newCmdContainer(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { +// 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)", diff --git a/internal/verda-cli/cmd/serverless/container_actions.go b/internal/verda-cli/cmd/serverless/container_actions.go index daa808d..2641240 100644 --- a/internal/verda-cli/cmd/serverless/container_actions.go +++ b/internal/verda-cli/cmd/serverless/container_actions.go @@ -27,10 +27,12 @@ import ( // containerActionFn executes a lifecycle action on a named container deployment. type containerActionFn func(ctx context.Context, client *verda.Client, name string) error -// newContainerActionCmd builds a `verda serverless container ` command +// newContainerActionCmd builds a `verda container ` command // whose only behavior is to call the given SDK method with the resolved name. // All four lifecycle commands (pause/resume/restart/purge-queue) share this shape. -func newContainerActionCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, verb, short, spinner, successMsg string, destructive bool, fn containerActionFn) *cobra.Command { +// detailMsg is a Sprintf template with one %q for the deployment name; it is +// only consulted for destructive verbs (used in the confirm prompt detail). +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 + " ", @@ -41,7 +43,7 @@ func newContainerActionCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, verb, if err != nil || name == "" { return err } - return runContainerAction(cmd, f, ioStreams, name, verb, spinner, successMsg, destructive, yes, fn) + return runContainerAction(cmd, f, ioStreams, name, verb, spinner, successMsg, detailMsg, destructive, yes, fn) }, } if destructive { @@ -50,26 +52,27 @@ func newContainerActionCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, verb, return cmd } -func runContainerAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, name, verb, spinner, successMsg string, destructive, yes bool, fn containerActionFn) error { +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 { - if f.AgentMode() && !yes { - return cmdutil.NewConfirmationRequiredError(verb) + 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 !yes { - confirmed, err := confirmDestructive(cmd.Context(), ioStreams, f.Prompter(), - verb+" container deployment", - fmt.Sprintf("Deployment %q will be %sd.", name, verb), - fmt.Sprintf("%s %s?", verb, name), - ) - if err != nil || !confirmed { - _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") - return nil - } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil } } @@ -95,7 +98,7 @@ func runContainerAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil func newCmdContainerPause(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { return newContainerActionCmd(f, ioStreams, - "pause", "Pause a container deployment", "Pausing", "Paused deployment %q", false, + "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) }, @@ -104,7 +107,7 @@ func newCmdContainerPause(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra 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, + "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) }, @@ -113,7 +116,8 @@ func newCmdContainerResume(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobr func newCmdContainerRestart(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { return newContainerActionCmd(f, ioStreams, - "restart", "Restart a container deployment", "Restarting", "Restarted deployment %q", true, + "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) }, @@ -122,7 +126,8 @@ func newCmdContainerRestart(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cob 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", true, + "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 index a91968f..ab70589 100644 --- a/internal/verda-cli/cmd/serverless/container_create.go +++ b/internal/verda-cli/cmd/serverless/container_create.go @@ -41,13 +41,10 @@ const ( queueLoadBalanced = 3 queueLoadCostSaver = 6 - // Fixed storage values — web UI labels these "fixed for now" and does - // not expose editors. Flags default to these and may be overridden - // when the API unlocks them. + // Auto-allocated general-storage mount. The server sizes it; the CLI + // only sends the mount path and "scratch" type. /dev/shm is provided + // by the runtime and the CLI does not send a mount for it. defaultGeneralStoragePath = "/data" - defaultGeneralStorageGiB = 500 - defaultSHMPath = "/dev/shm" - defaultSHMMiB = 64 defaultExposedPort = 80 defaultHealthcheckPath = "/health" @@ -92,26 +89,22 @@ type containerCreateOptions struct { ScaleDownDelay time.Duration RequestTTL time.Duration - SecretMounts []string // SECRET:PATH - GeneralStorageSize int // GiB; 0 = omit the mount - SHMSize int // MiB; 0 = omit the mount + 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, - GeneralStorageSize: defaultGeneralStorageGiB, - SHMSize: defaultSHMMiB, + Port: defaultExposedPort, + HealthcheckPath: defaultHealthcheckPath, + MinReplicas: 0, + MaxReplicas: defaultMaxReplicas, + Concurrency: defaultConcurrency, + QueuePreset: presetBalanced, + ScaleUpDelay: 0, + ScaleDownDelay: defaultScaleDownDelay, + RequestTTL: defaultRequestTTL, } cmd := &cobra.Command{ @@ -126,13 +119,13 @@ func newCmdContainerCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobr `), Example: cmdutil.Examples(` # Minimal flag-driven - verda serverless container create \ + 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 serverless container create \ + verda container create \ --name my-api --image ghcr.io/me/llm:v1.2 \ --compute RTX4500Ada --compute-size 1 \ --env HF_HOME=/data/.huggingface \ @@ -177,8 +170,6 @@ func newCmdContainerCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobr 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.IntVar(&opts.GeneralStorageSize, "general-storage-size", opts.GeneralStorageSize, "Size of the fixed /data mount in GiB") - flags.IntVar(&opts.SHMSize, "shm-size", opts.SHMSize, "Size of the /dev/shm mount in MiB") flags.BoolVarP(&opts.Yes, "yes", "y", false, "Skip confirmation (required in agent mode)") @@ -212,6 +203,9 @@ func runContainerCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil 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 @@ -312,7 +306,7 @@ func (o *containerCreateOptions) request() (*verda.CreateDeploymentRequest, erro return nil, err } - mounts, err := buildVolumeMounts(o.SecretMounts, o.GeneralStorageSize, o.SHMSize) + mounts, err := buildVolumeMounts(o.SecretMounts) if err != nil { return nil, err } @@ -413,8 +407,14 @@ func buildEnvVars(plain, secret []string) ([]verda.ContainerEnvVar, error) { return out, nil } -func buildVolumeMounts(secretMounts []string, generalStorageGiB, shmMiB int) ([]verda.ContainerVolumeMount, error) { - var mounts []verda.ContainerVolumeMount +// buildVolumeMounts assembles the volume_mounts array sent to the API. Every +// deployment gets an auto-allocated scratch mount at /data — the server sizes +// it, the CLI just declares the mount. /dev/shm is provided by the runtime +// and is not sent as an explicit mount. Secret mounts come from --secret-mount. +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 { @@ -422,20 +422,6 @@ func buildVolumeMounts(secretMounts []string, generalStorageGiB, shmMiB int) ([] } mounts = append(mounts, m) } - if generalStorageGiB > 0 { - mounts = append(mounts, verda.ContainerVolumeMount{ - Type: mountTypeShared, - MountPath: defaultGeneralStoragePath, - SizeInMB: generalStorageGiB * 1024, - }) - } - if shmMiB > 0 { - mounts = append(mounts, verda.ContainerVolumeMount{ - Type: mountTypeSHM, - MountPath: defaultSHMPath, - SizeInMB: shmMiB, - }) - } return mounts, nil } diff --git a/internal/verda-cli/cmd/serverless/container_create_test.go b/internal/verda-cli/cmd/serverless/container_create_test.go index 9872b7e..aa1b1d1 100644 --- a/internal/verda-cli/cmd/serverless/container_create_test.go +++ b/internal/verda-cli/cmd/serverless/container_create_test.go @@ -15,6 +15,7 @@ package serverless import ( + "strconv" "strings" "testing" "time" @@ -24,20 +25,18 @@ import ( // a baseline each test tweaks a single field on. 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, - GeneralStorageSize: defaultGeneralStorageGiB, - SHMSize: defaultSHMMiB, + 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, } } @@ -81,9 +80,16 @@ func TestContainerRequest_HappyPath(t *testing.T) { if req.Scaling.ScalingTriggers.QueueLoad.Threshold != queueLoadBalanced { t.Errorf("balanced preset should map to %d, got %v", queueLoadBalanced, req.Scaling.ScalingTriggers.QueueLoad.Threshold) } - // General storage + SHM mounts should be present by default. - if len(c.VolumeMounts) != 2 { - t.Errorf("expected 2 default mounts (general + shm), got %d: %+v", len(c.VolumeMounts), c.VolumeMounts) + // One scratch mount at /data is always sent — the API allocates and + // sizes it server-side. /dev/shm is provided by the runtime. + 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) } } @@ -114,7 +120,7 @@ func TestContainerRequest_PresetMapping(t *testing.T) { {presetCustom, 1001, 0, true}, } for _, tc := range cases { - t.Run(tc.preset+":"+strconvItoa(tc.custom), func(t *testing.T) { + t.Run(tc.preset+":"+strconv.Itoa(tc.custom), func(t *testing.T) { opts := validOpts() opts.QueuePreset = tc.preset opts.QueueLoad = tc.custom @@ -241,24 +247,3 @@ func TestContainerRequest_ValidationErrors(t *testing.T) { }) } } - -// strconvItoa is a tiny helper so the test table above can embed ints in -// subtest names without an import dance. -func strconvItoa(n int) string { - if n == 0 { - return "0" - } - s := "" - neg := n < 0 - if neg { - n = -n - } - for n > 0 { - s = string(rune('0'+n%10)) + s - n /= 10 - } - if neg { - s = "-" + s - } - return s -} diff --git a/internal/verda-cli/cmd/serverless/container_delete.go b/internal/verda-cli/cmd/serverless/container_delete.go index a3d6978..eeea9bd 100644 --- a/internal/verda-cli/cmd/serverless/container_delete.go +++ b/internal/verda-cli/cmd/serverless/container_delete.go @@ -62,7 +62,10 @@ func runContainerDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil fmt.Sprintf("Deployment %q will stop serving requests immediately.", name), fmt.Sprintf("Delete %s?", name), ) - if err != nil || !confirmed { + if err != nil { + return err + } + if !confirmed { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") return nil } diff --git a/internal/verda-cli/cmd/serverless/container_describe.go b/internal/verda-cli/cmd/serverless/container_describe.go index b60966b..7d60450 100644 --- a/internal/verda-cli/cmd/serverless/container_describe.go +++ b/internal/verda-cli/cmd/serverless/container_describe.go @@ -17,6 +17,7 @@ package serverless import ( "context" "fmt" + "io" "strings" "charm.land/lipgloss/v2" @@ -91,7 +92,10 @@ func selectContainerDeployment(ctx context.Context, f cmdutil.Factory, ioStreams idx, err := f.Prompter().Select(ctx, "Select container deployment", labels) if err != nil { - return "", nil // prompter cancel is a clean exit + if isPromptCancel(err) { + return "", nil + } + return "", err } if idx == len(deployments) { return "", nil @@ -124,6 +128,11 @@ func runContainerDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdut cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployment:", deployment) + // Embed promotes the SDK's fields plus our outer Status. encoding/json gives + // the shallowest field precedence, so if the SDK grows its own json:"status" + // the outer Status here still wins — but the embedded value would silently + // disappear from output. If the SDK ever exposes a Status on the deployment + // itself, drop the embed and enumerate fields explicitly here. if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), struct { *verda.ContainerDeployment Status string `json:"status,omitempty"` @@ -135,7 +144,7 @@ func runContainerDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdut return nil } -func renderContainerDeploymentCard(w interface{ Write(p []byte) (int, error) }, d *verda.ContainerDeployment, status string) { +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")) diff --git a/internal/verda-cli/cmd/serverless/container_list.go b/internal/verda-cli/cmd/serverless/container_list.go index b6425dd..b388cef 100644 --- a/internal/verda-cli/cmd/serverless/container_list.go +++ b/internal/verda-cli/cmd/serverless/container_list.go @@ -17,7 +17,8 @@ package serverless import ( "context" "fmt" - "strconv" + "io" + "strings" "text/tabwriter" "github.com/spf13/cobra" @@ -26,20 +27,35 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) +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", - Args: cobra.NoArgs, + 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) + 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) error { +func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *containerListOptions) error { client, err := f.VerdaClient() if err != nil { return err @@ -48,8 +64,14 @@ func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I 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) + deps, derr := client.ContainerDeployments.GetDeployments(ctx) + if derr != nil { + return nil, derr + } + statuses.refresh(ctx, client, deps) + return deps, nil }) if err != nil { return fmt.Errorf("fetching deployments: %w", err) @@ -57,8 +79,31 @@ func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployments:", deployments) - if wrote, werr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), deployments); wrote { - return werr + // Client-side status filter (API does not support it on the list endpoint). + 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 + } + + // Structured output (JSON/YAML): emit and return. + 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 { @@ -66,28 +111,122 @@ func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I return nil } - w := tabwriter.NewWriter(ioStreams.Out, 0, 0, 2, ' ', 0) - _, _ = fmt.Fprintln(w, "NAME\tCOMPUTE\tSIZE\tSPOT\tENDPOINT\tCREATED") - for i := range deployments { - d := &deployments[i] - compute := "-" - size := "-" - if d.Compute != nil { - compute = d.Compute.Name - size = strconv.Itoa(d.Compute.Size) + // Non-interactive table when piped, redirected, or in agent mode. + if !cmdutil.IsStdoutTerminal() || f.AgentMode() { + 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() + for { + if statuses.anyStale(deployments) { + _ = cmdutil.RunWithSpinner(cmd.Context(), f.Status(), "Refreshing statuses...", func() error { + refreshCtx, cancel := context.WithTimeout(cmd.Context(), f.Options().Timeout) + defer cancel() + statuses.refresh(refreshCtx, client, deployments) + return nil + }) } - spot := "no" - if d.IsSpot { - spot = "yes" + + 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) + if err != nil { + if 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) + } + + // Pause on the describe card until the user picks an explicit next step. + // Without this gate the loop re-enters Select immediately and the TUI + // redraw wipes the card. + nextIdx, nerr := prompter.Select(cmd.Context(), "", []string{"Back to list", "Exit"}) + if nerr != nil { + if isPromptCancel(nerr) { + return nil + } + return nerr + } + if nextIdx == 1 { + return nil + } + } +} + +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, - compute, - size, - spot, + 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..63d60bb --- /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 = "-" +) + +// containerStatusCache holds deployment status strings keyed by name with a +// per-entry TTL. The API list endpoint does not include status, so the CLI +// fetches it per-deployment in parallel; the cache lets the interactive loop +// reuse those results across iterations without hammering the API. +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 fetches status for any cache entries that are absent or stale, +// bounded by containerStatusFetchConcurrency. Fetch errors fall back to +// containerStatusUnknown so the row still renders. +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/serverless.go b/internal/verda-cli/cmd/serverless/serverless.go deleted file mode 100644 index 3a99917..0000000 --- a/internal/verda-cli/cmd/serverless/serverless.go +++ /dev/null @@ -1,41 +0,0 @@ -// 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" -) - -// NewCmdServerless creates the parent `verda serverless` command. -func NewCmdServerless(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { - cmd := &cobra.Command{ - Use: "serverless", - Short: "Manage serverless container and batch-job deployments", - Long: cmdutil.LongDesc(` - Deploy and manage serverless container endpoints and one-shot batch - jobs on Verda Cloud. Container deployments run continuously and scale - with demand; batch jobs run to completion on a deadline. - `), - Run: cmdutil.DefaultSubCommandRun(ioStreams.Out), - } - - cmd.AddCommand( - newCmdContainer(f, ioStreams), - newCmdBatchjob(f, ioStreams), - ) - return cmd -} diff --git a/internal/verda-cli/cmd/serverless/shared.go b/internal/verda-cli/cmd/serverless/shared.go index 1a3cecc..cadedb1 100644 --- a/internal/verda-cli/cmd/serverless/shared.go +++ b/internal/verda-cli/cmd/serverless/shared.go @@ -99,11 +99,13 @@ func parseSecretMountFlag(entry string) (verda.ContainerVolumeMount, error) { }, nil } -// Mount type constants match the server-side enum. +// 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" - mountTypeShared = "shared" - mountTypeSHM = "shm" + mountTypeSecret = "secret" + mountTypeScratch = "scratch" ) // Environment-variable type constants. @@ -112,9 +114,19 @@ const ( envTypeSecret = "secret" ) +// isPromptCancel reports whether err represents a clean prompter exit rather +// than a real failure. Ctrl+C surfaces as tui.ErrInterrupted, Esc as +// context.Canceled. Anything else (I/O errors, terminal disconnects, real +// context deadlines) should propagate so the failure isn't invisible. +func isPromptCancel(err error) bool { + return errors.Is(err, tui.ErrInterrupted) || errors.Is(err, context.Canceled) +} + // confirmDestructive renders a red-bold warning line and prompts the user to -// confirm. Returns (true, nil) to proceed, (false, nil) on cancellation. -// In agent mode, callers must bypass this helper and enforce --yes themselves. +// 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)) @@ -122,7 +134,14 @@ func confirmDestructive(ctx context.Context, ioStreams cmdutil.IOStreams, prompt _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n", detail) } _, _ = fmt.Fprintf(ioStreams.ErrOut, " %s\n\n", warn.Render("This action cannot be undone.")) - return prompter.Confirm(ctx, prompt) + 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. 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..76607b5 --- /dev/null +++ b/internal/verda-cli/cmd/serverless/wire_format_test.go @@ -0,0 +1,332 @@ +// 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 tests for `verda container create` and `verda batchjob create`. +// +// HARD RULE: when you change the create flow — options struct, request() +// assembly, buildVolumeMounts/buildEnvVars/buildContainerScaling, mount-type +// constants, new flags that land in the payload — you MUST update the +// assertions in this file. These tests are the only layer that catches +// payload bugs the SDK's client-side validators miss (the production +// `volume_id`/mount-type 400 is the canonical example). +// +// Do NOT relax an assertion to make a failing test pass. If you cannot +// explain why the new payload is correct, the real API will reject it. +// See `CLAUDE.md` in this directory ("Wire-Format Tests Must Stay in Sync") +// for the full rule. + +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 captures the JSON body posted to deployment-create endpoints +// so tests can assert on the exact wire format the CLI sends to the API. This +// is the layer where the volume_id / mount-type bug lived — opts.request() and +// the SDK's client-side validators both accepted the bad payload; only the +// real server rejected it. A wire-format test catches it without spending +// money on a live deployment. +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 index 48c4bb3..6ec4d83 100644 --- a/internal/verda-cli/cmd/serverless/wizard.go +++ b/internal/verda-cli/cmd/serverless/wizard.go @@ -41,8 +41,8 @@ const ( registryPublicValue = "__public__" ) -// buildContainerCreateFlow returns the full wizard flow for `verda serverless -// container create`. Every step has a matching flag on containerCreateOptions, +// 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. @@ -59,7 +59,6 @@ func buildContainerCreateFlow(_ context.Context, getClient clientFunc, opts *con stepRegistryCreds(getClient, cache, &opts.RegistryCreds), stepPort(&opts.Port), stepContainerHealthcheck(&opts.HealthcheckOff), - stepContainerHealthcheckPort(&opts.HealthcheckPort), stepContainerHealthcheckPath(&opts.HealthcheckPath), stepEnvVars(&opts.Env), stepContainerMinReplicas(&opts.MinReplicas), @@ -137,43 +136,13 @@ func stepContainerHealthcheck(off *bool) wizard.Step { } } -func stepContainerHealthcheckPort(port *int) wizard.Step { - return wizard.Step{ - Name: "healthcheck-port", - Description: "Healthcheck port (blank = same as exposed)", - 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 { - if *port > 0 { - return strconv.Itoa(*port) - } - return "" - }, - Validate: func(v any) error { - s := strings.TrimSpace(v.(string)) - if s == "" { - return nil - } - return parsePortValidator("healthcheck port")(v) - }, - Setter: func(v any) { - s := strings.TrimSpace(v.(string)) - if s == "" { - *port = 0 - return - } - n, _ := strconv.Atoi(s) - *port = n - }, - Resetter: func() { *port = 0 }, - IsSet: func() bool { return *port > 0 }, - Value: func() any { return strconv.Itoa(*port) }, - } -} +// 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{ diff --git a/internal/verda-cli/cmd/serverless/wizard_batchjob.go b/internal/verda-cli/cmd/serverless/wizard_batchjob.go index e71d202..90e95bb 100644 --- a/internal/verda-cli/cmd/serverless/wizard_batchjob.go +++ b/internal/verda-cli/cmd/serverless/wizard_batchjob.go @@ -23,8 +23,8 @@ import ( "github.com/verda-cloud/verdagostack/pkg/tui/wizard" ) -// buildBatchjobCreateFlow returns the wizard flow for `verda serverless -// batchjob create`. It reuses nine of the ten steps from the container +// 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 diff --git a/internal/verda-cli/cmd/serverless/wizard_cache.go b/internal/verda-cli/cmd/serverless/wizard_cache.go index fd04bd5..006deaf 100644 --- a/internal/verda-cli/cmd/serverless/wizard_cache.go +++ b/internal/verda-cli/cmd/serverless/wizard_cache.go @@ -19,8 +19,32 @@ import ( "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. diff --git a/internal/verda-cli/cmd/serverless/wizard_shared.go b/internal/verda-cli/cmd/serverless/wizard_shared.go index dfe2513..5419b82 100644 --- a/internal/verda-cli/cmd/serverless/wizard_shared.go +++ b/internal/verda-cli/cmd/serverless/wizard_shared.go @@ -22,6 +22,7 @@ import ( "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" ) @@ -81,8 +82,10 @@ func stepCompute(getClient clientFunc, cache *apiCache, target *string) wizard.S Description: "Compute resource", Prompt: wizard.SelectPrompt, Required: true, - Loader: func(ctx context.Context, _ tui.Prompter, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { - res, err := cache.fetchComputeResources(ctx, getClient) + 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 } @@ -145,16 +148,23 @@ func stepRegistryCreds(getClient clientFunc, cache *apiCache, target *string) wi Description: "Registry credentials (for private images)", Prompt: wizard.SelectPrompt, Required: false, - Loader: func(ctx context.Context, _ tui.Prompter, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + 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 := cache.fetchRegistryCreds(ctx, getClient) + 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 @@ -206,6 +216,12 @@ func stepPort(target *int) wizard.Step { // --- 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", @@ -215,8 +231,14 @@ func stepEnvVars(target *[]string) wizard.Step { 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 || !add { - return nil, nil //nolint:nilerr // prompter cancel is a clean exit + 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 { @@ -269,14 +291,24 @@ func stepSecretMounts(getClient clientFunc, cache *apiCache, target *[]string) w Description: "Secret mounts (optional)", Prompt: wizard.SelectPrompt, Required: false, - Loader: func(ctx context.Context, prompter tui.Prompter, _ tui.Status, _ *wizard.Store) ([]wizard.Choice, error) { + 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 || !add { - return nil, nil //nolint:nilerr // prompter cancel is a clean exit + if err != nil { + if isPromptCancel(err) { + return nil, nil + } + return nil, err + } + if !add { + return nil, nil } - secrets, _ := cache.fetchSecrets(ctx, getClient) - fileSecrets, _ := cache.fetchFileSecrets(ctx, getClient) + 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 diff --git a/internal/verda-cli/cmd/serverless/wizard_subflows.go b/internal/verda-cli/cmd/serverless/wizard_subflows.go index edb59b9..2885301 100644 --- a/internal/verda-cli/cmd/serverless/wizard_subflows.go +++ b/internal/verda-cli/cmd/serverless/wizard_subflows.go @@ -23,11 +23,15 @@ import ( ) // promptEnvVar collects one environment-variable entry interactively. Returns -// (nil, nil) on user cancel or empty name so the caller can end the loop. +// (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 { - return nil, nil //nolint:nilerr // prompter cancel is a clean exit + if isPromptCancel(err) { + return nil, nil + } + return nil, err } name = strings.TrimSpace(name) if name == "" { @@ -40,7 +44,10 @@ func promptEnvVar(ctx context.Context, prompter tui.Prompter) (*verda.ContainerE value, err := prompter.TextInput(ctx, "Env value") if err != nil { - return nil, nil //nolint:nilerr // prompter cancel is a clean exit + if isPromptCancel(err) { + return nil, nil + } + return nil, err } return &verda.ContainerEnvVar{ Type: envTypePlain, @@ -65,13 +72,22 @@ func promptSecretMount(ctx context.Context, prompter tui.Prompter, secrets []ver labels = append(labels, "Cancel") idx, err := prompter.Select(ctx, "Select secret to mount", labels) - if err != nil || idx == len(labels)-1 { - return nil, nil //nolint:nilerr // prompter cancel is a clean exit + 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 { - return nil, nil //nolint:nilerr // prompter cancel is a clean exit + if isPromptCancel(err) { + return nil, nil + } + return nil, err } mountPath = strings.TrimSpace(mountPath) if !strings.HasPrefix(mountPath, "/") { diff --git a/internal/verda-cli/cmd/serverless/wizard_summary.go b/internal/verda-cli/cmd/serverless/wizard_summary.go index ec5199b..e5e9991 100644 --- a/internal/verda-cli/cmd/serverless/wizard_summary.go +++ b/internal/verda-cli/cmd/serverless/wizard_summary.go @@ -84,8 +84,7 @@ func renderContainerSummary(w io.Writer, opts *containerCreateOptions) { if len(opts.SecretMounts) > 0 { kv("Secret mounts", strconv.Itoa(len(opts.SecretMounts))) } - kv("General storage", fmt.Sprintf("%s %d GiB (fixed)", defaultGeneralStoragePath, opts.GeneralStorageSize)) - kv("Shared memory", fmt.Sprintf("%s %d MiB", defaultSHMPath, opts.SHMSize)) + kv("Storage", defaultGeneralStoragePath+" (scratch, server-allocated)") _, _ = fmt.Fprintln(w) } @@ -123,7 +122,6 @@ func renderBatchjobSummary(w io.Writer, opts *batchjobCreateOptions) { if len(opts.SecretMounts) > 0 { kv("Secret mounts", strconv.Itoa(len(opts.SecretMounts))) } - kv("General storage", fmt.Sprintf("%s %d GiB (fixed)", defaultGeneralStoragePath, opts.GeneralStorageSize)) - kv("Shared memory", fmt.Sprintf("%s %d MiB", defaultSHMPath, opts.SHMSize)) + kv("Storage", defaultGeneralStoragePath+" (scratch, server-allocated)") _, _ = fmt.Fprintln(w) } 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/factory.go b/internal/verda-cli/cmd/util/factory.go index 8cff4c5..4f2254b 100644 --- a/internal/verda-cli/cmd/util/factory.go +++ b/internal/verda-cli/cmd/util/factory.go @@ -15,8 +15,13 @@ package util import ( + "bytes" "errors" + "fmt" + "io" "net/http" + "regexp" + "sort" "strings" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" @@ -27,6 +32,15 @@ import ( clioptions "github.com/verda-cloud/verda-cli/internal/verda-cli/options" ) +// sensitiveJSONFieldRe matches "field": "value" JSON entries whose values must +// not appear in debug output (OAuth credentials, bearer tokens, etc.). +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 +95,80 @@ 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, err := io.ReadAll(req.Body) + _ = req.Body.Close() + if err == nil { + 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 { + 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 diff --git a/internal/verda-cli/cmd/util/versionhint.go b/internal/verda-cli/cmd/util/versionhint.go index 4f5f55a..00e3380 100644 --- a/internal/verda-cli/cmd/util/versionhint.go +++ b/internal/verda-cli/cmd/util/versionhint.go @@ -125,6 +125,24 @@ func FetchLatestVersion(ctx context.Context) (string, error) { return release.TagName, nil } +// CheckVersionFromCache returns the cached latest version and the current +// version without ever touching the network. Used by hot paths like `verda` +// and `verda --help` where blocking on a GitHub fetch would visibly slow the +// CLI. If the cache is empty or unreadable, latest is "" and the caller will +// simply print no 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. @@ -157,14 +175,21 @@ func CheckVersion(ctx context.Context) (latest, current string, err error) { // fetchErr != nil && cache.LatestVersion != "": fall back to cached value } - 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 +203,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 0000000000000000000000000000000000000000..12a4aba90c45848521a9ac42479c1ad79572fceb GIT binary patch literal 29199 zcmeFZcRZZyw>M0hBtmuyf*_HQ=w*!FLXY@k!)%yUJ?Uy4L!xHF^F(SA*d^+j$xq z8U~2weFGYrQ&luHv~SLy0p2tZWN!oiob%Q+^P{1mzjFMSmL}u%H5wW^T2~`ee^VW8 zSqCqKu$`lqy_0Yd!W$S(LnE&gN-pSe3Q-ON}i{rlG>ZrhN0@e}L@m6(m zan%g*butXmHF5}XcaU-9R#LnnA0!JbfN=7+yAg!&@br@nQsDk`U0LAy__YZ4jX#I@ zyDMaUMD3)d;0}T}#6`uyBBCG>QAr_j zQCYC4teE7De|>QS8~QrJWex7D|7$zoCk1X7e}8XT5fLO3DU1XOd-*zxh{?#vh=_`d zh>HsWBZT~dJ^k&1ggpIj{k_6{CqD;YS8sn;FV7ptE85w61^6p)1H}Ht1;SfL=bwc= z{r)8@Kx!gEcHSal!lEJw#4&4sj`s66aQY82{%=S783lVgi5NKfc?I}7I03_N{l{dr zKw#&8@$lc6tQP3x>Hp7kK>GjhlmFhw(czzQ-T}TIe~QV`LBz?!2?4C<2k<2J4@2HA zUjANwE?)l+OaJrhKLv5T0U#||JzrPAZtOhndpQIgFA2G?z#S0a>L?4oFD@!ABQ7BW zQc=IJA|@syCUH+1EF*d!BnB3hR+avHfq$O}EGg^=%$1Z-5mUb>0aB4vy)P!FE~+AV zUsY8_OcJmWH5rL}_y3*?@$~bz^K@`J-t>5`D==47)ImlPBq=83=p-d3Bmok05CXQA z5&}CqfItp1;`a7%(4X^VRehc8{JngQyu3XAwo-_PM}VKd@A2y!;xfY0H}2}#Ik#En1AR@U|3B=@Jfq{SWKGSX6DAqht@Ng*dE2|FQsM`^H-H1K6F z0g`Z(5*6qE*JQ_k)Aipf6LYi^l>teBg(Mv$B!ncKoE(K@?4=xqq@^6~;0|DrjI=n2 zTjU>#G+q4w83+ICWE(p9{OifX71#qf8nSi{$EQ?*+s_Ul{CMNzm;X-n-};u30K*-m z#lS-1b`Hn-mUIxZmjb946>|iM!r=}eDbauCBVC+;CI3;I__5l=BmvF+t&6|5@$-WF zBkg>h?l}Vn{kLg6`1^F+5SJ7d6Z_AW@z*>D7dua9C%|`#{K@IDhxtF}=f7vae{J|* z`1!xu)_;#5DPht720J3h{QRXUk-yC9Ka>cVrN67chtt2bc+bQCKlBNBOIZ&5MSWdGOKJzrlE1Gh1|bq6!dzXK=<0xY4>PrgNIJQwCXK) zp$h+-6S+6+Prjsi`(#G$(ZgRuIjy$)6FPU_lG#EGjXtR8Pv)dPIfYmA7)Qw^9j)Hh zQ#qUIh+O(2KhPICBO+zD@MRi{H6&4IwqF|xnb&ImdBM^smyCXFJ^9yjJLV5t+CLxP zxSTTm>)l;8TIRo=V$>g={`2W!?xp`321w$61oEdj{QuQL%t#!{&KL|oS@@Vu#3#bo ztJ;!$2A*=OB`fGbT|T96%^5r+vMCtLEcCgRep!_1BEQ7RKTpe7Boo2&3=GdZ-H$??P|o?+z;Ka|wS@Gb zvou||+D{y>NTWP>76`?UzaB`O_;V8)nyc|N^nblm`=4RQg82VTAUAMDz8f?&uj2kv zz)y-FiG${0(tmZTFJ^K7c^K;7hAD?fdq`3e(&NuM_)A`Nzn^XZCoI1h5BDQDkU9@|u9T5Uys8Z_i- ze!y)78@%7p7T!GRkeAO|?+~qcc!LOm;9l+sK$O5WxbKJIG&_9+8(!C{{IP} z!^P~42+ImK{_Jg>&cK!pG(EAGRY;9JJehc4@Lblb!Z|dR|ST|DWrsx zae7=h&BVH%{eVx3# zoxie>lwblY&*F54at7!fZCR8Y%&0s&?=OaosLt29-O5MhU@?m-{E89wNus3Z z%Or8W@XyJQ296-u^Q6@mLe8D8ryX5fnMKOH9YgFqS*~y^0`wE%){BtHe4Qab1x0nu zn5t&|OV$G^5&{aijh8z|eQO)wr(80;muBGbDaUcoDIYQze>!oF=+N01n3Em3IsZZkv)0p_^3a8T6hg$$i}E^9hAps@9 zc-1o%GZ=gHHsqkXUgcrk&W4;346B_f@e3KecTKsp31wv3Vui=@Ad+<@e4lNughOVN z{h_ZnGgu$VhV&B_;yoN74nqsUu|`6}$!Cpbh7cnUNt`_=r=THe`9f)>zOGM;`&#@y z`8@X&uQ^*y=_4!ztVFvhoG447dz)wQ$3fxFrGd_~FZ^QYc^V26Gy4VkAI07ueb{`- z_ATF;_^fx!X0#ni0};H$R(da4*^1jVRPi5;8a8}HQc$rFDz03yJ*M)x+q3c_9k%O> zlZY-to{Hm<|EAH>ykj!-Bq)q&8^4=;tQNx1E-2Mhn~XImCbs0}#b(E`{^bhqaroERf3e&VD_~ znuU@5x7J2))`eV`jF=2#j#v^!AS`Gz!E>ujNE|n{%_5%0{nNm#f!e|-alXeQ^MO9B zK^lEjoxgh93J$M+-FnTDp_rYP*_^|+i!0&7B$M3^MN9gc?>231@&e8?1$ zErY;+%NtgorqV!)AR%^Fq_%A!$+yEk^v&Z2xk;RN7L$262;X(oxYDToYjP{1B+k~- zy=4EY{FiPb5t{c#iSRvdQWoEHELW;EWz+-D%WXQ|-xr?$xRLS)gbpGrDg<3gRLV3H zM6XMe+S>9vdDb%@_a}9|>xjJ_M(DGjnA2*acqUaCb6T<6Hc{N?=R;&G;#Rr?!)NY` zly7Ls$_XdE@|M^5Q)M?b0FsYXQ}(Qb7WAbYry#WjVpp6IrI;8g=Uq^{`PE)VKu^QW zu!p2Jz2=xH{-TVD56qsGt4)+HzEIZ@@iYWf&d%%2!Ms9Jf=+Lgtet4TUaqe?9tLN= z!)?_|aCknQXdx;lcj5y>zhH*{Qbj#mCg;FbV?`%awqQkEVYVUJU;8}UBqAx~)HxO{ z*}=YC8FI|5#gjV2jBBG6x+bSh`A%`owJ}sYU$0T~%fU04;J>NnsY3rP3A{N{zAB+G+-m5k&mRd4w<< z;^I*kIyp!PENVUcZl@%6^`tS0QwO>!ySU#5UzuQnI*2@vDI30hy&rWQu|ygx zbPYE&NG$cd4CTIcH*15$rF}*FQN7y3rSXgMCaA!1!Ym(E>wAw zIea%Re6=}cdROgwpB30;smA`()hcn3Qeybz&6e|ePVzo+inm-U7Q{WVLfI!thi4$j zd_`|UC^!316{j~@#Cx}e&m3PnL2wBE*2(uQzI)I#0h{Pe-Y7hmrO$VM=Q;Lb0NIeI zYk0fTf1lF*`NOwJuV%`wE6w?@#%HS@BsI$V_P)s)@SI3_P@Cn6%ClPhkjpFAL9lt@ zL*bZ>y~TS1KK-c+@^==`O(XUCRi5m_7_4}YwZ(as^sgW z3I9u<)ul5JEJp@;X{@>lQadB$g(g`-kUe1uj{PWIl5P#Udm9T&``+fc5T$zT_ zc;LaL!odvq^Mzh0n!jQWqV}!`o)&{BbHi5_0o)qQ^+K8oBBrZiCmV#}y zpuUA8rr>FP{KBp$gzJapsjxYc2$KBCwd7m4k>!Yu@;B1DcnP~zj18aA0sVv@F($DIPROm zOJK_%iQ;kDo12q)iReImiG)}F50V)Vk5c*NXJmm(*QPiu?AAj4MPFoUr^H8sP3D2k z;?Dr4c0wM05#CW+XqDdbk+45eU;Gk_-FV6N@{08?@Vkry#twlUKUtnRm^GJfC-Xyl z;3S%5yWy#v!o-aC*w-v3+h4oY^@4sm5#IzJv292e?h$SgE%fWs3NbD4$?rFFG9-gP zDviukB<%zhB6?gFJH-*$`hpd zpbyRxznxJ7UGFE*6_A4utln0>U5a;gmH2$Ny0}9{4B#LB{Es;yo#u_k7;-B>3~u4~ zDq}+X2?TujNpx-ui4DR&=AT9`YGYG+9geR$!kk2-v6N3GT*iz&-QT%Ds~>Z`YMM~5 zzjc4|6mH^_Qg~{T=eGf6dG=;U3mK7yrb?5)_}ia0mVHaH-wrHlZ9-{RQ?0w7m%7++ z=|V&F6GWORB7K6!Iy7ebk4}TD4KV25x*2}r7l0P7)rG_ zAifgkK2IbaCb>?XG&_NR5ysOhKU5!)kpXp94NB?q%M(e)dc8nt$d;do-E)pz zxMKY_r4MBb%f=weh0>s+N}H>z`lI~HUI{#|LEr&lar*nyGqvDc-{!8KGK;Xb?h1MCXg4CAr=%$)JlEtdm_I^QJyip1X`p ze4cCCv}-~>P2wC`d}1OnM_Mh4+B33#dx@HHCMGq=$vj(^o!hZ9@}f?|W@gW{xc8dt zEuJtl{1aXe?!r8Rc+eOJZ}h4>^YO2<(dvab zXnkb9p4Nc}iGy!k8hI3l7Fr=5`ID=YzPM05>!OVo$?KBmE5nlyoE0;&$+*;=i;xJt zfvupn%49|BwA8^w&5?Ug-uE;01a5`8v&7<`9yGarI!q=mkcnHhzxf~4;rGeCLljMU zn^7p!=XC=MgrqHH$lA#2XJv&853JTd#kk!CM35QWCQ#t62h%aoN zaw>yn2M(IkLxcK$yI=fHQ)8RQD}NRGjnQTgWwS-zFB>okij${;XVKw5H`hm=PAg!{ zNWHcD(tn_`&|U9YjXMc+MUj)&@5onscvqppr6=mIq?&gP{5S)dO-$wmQ4kTN4+NXn zj4QKz#IUErX>sI_4QZnB@1&-wZ`tU=c1DZ(*8CLKdYMb2r?y9|t=-@e9{L4~Lg2l6 zkI~gR@{m7v13mHjOzM5F)R5{n4SXtx!5`k4Xtk%Ifqa5(5T}^2LD!^kZXzw@qE?10 zoI$TSkbL%?WQ-dUvXQzI_1FZe_OL1*O=z1Gx$Ump512ciM&QEq)((s2$P!Gb;=5^H zH;p@SDF@I$ZBE5DMcr{K zYj5uB%MA|gZK5y|?^l0_+%Po`ex6T%n~F<;O-pnTqKmqzbS%a%e+E5KDL)IIlxRP{ z#TnrC`c)zSbAn;PJ?+Z~DG@id(}ma`KcaGqYar$|wh>1gAkwkwBHNahPi8*I81n?u z(hJUz$v1C$9vHENOFb3p+6 z4`#LPwDp7~YUa~uoi9|$cIovCrnZ7puzK39tk5foIgMX^^U!J|+Cv5BGt%FMYCo!s zKwPmsja1UY6IqcrF{KqT%2R#Z#h*SC=n7-)q9Gw~KW{OCD4UqJhX6zFW2L8-Y3RM= zS3Dt^ng=*ui2}v@_+8aN?61?{SrIl-w~k2m;l^5cq*#waMyXifxwe#w^FKB*{%U`e z26}gIuGDMK!324l-&G|yEUWr@?39StBwH|9KTSV$fu})2^76wBP8)gBHKQ(=+;fht zsxO&VII|q8>hyeRJbUXksAd%yd>Vx6=u;FX?j|Ynm+Zw<3j4mzpREhZq&M^`V(44b zf$tSu?kKf|)AtLWC_4+mwW-*YzgB#~pYSLZaCyeZUw8UtKp;!~XzbAvDDiym|A4c1$oDxUd=6U4)yd3##%4I1D!yrXUN8a zVzHaI7?A0&4!qu8>=R^LX~->F&-K428iHXItI;DjD47d<_~`|ryvd&{*|7B7`knI{ zb?2)U5SKqWEycO7>=wh?T6$=;{Q)Z%y%zq-t;Zp+Nu7gg``Z&)vhSH^_OphK6!`MG z$=$RNhvtKX?DZEu;sl4mTNNYh0b~S7&(d<{@o-h>V>I>N(`VK<;mj$l)lU?cQmA7E zB~O+w;2?MAa-Q)oNxw(jd+29~1QWbXOSz8`hI3#9NT`KfK)Wh&a26slnHb(CHLo#w#<^W6Hv z&xS@9%L=BcEw$Hi!Z@vz4(F7sfs!4Byp{cfz6~5X-OMsQnEBnAVE_*9I&EqPBrF$S zU@jKNtYoY-flrxkE#(xr^#q%2U?*QzE@NM6%$UGnOwfA`NtmE3VNDqRj53_7+5B(| z;Nx&qrIHM*u90PaHc1mCq}hG)`&$l*$`YwkTR|y%OWyV6QDG7#2Rt8(l*>q_*DAzBPoD5>ISAA#TmhTY=7q)wfb+JUDR6Lx!E3akezerP+`=?GyvomocNa_L8S16d{q$Q zuoTXGW%NZO#Y~^}mFsp6s79~OHxdYkXnP&zzlUE0Q3@v?WHf?#4Bev}&=Xnw7idSt z$q-o~9aZ-Un#UCbFe3-VOv-up8)exfh0d)a{A>kz3%h4{(eCfveQSl z2T~^luX5Q6fr0!l@^+RXY(#Fz_<-G3i29q}VE0q7$$EeC8!kYx8-BOLQ2VweIkd*w z=j80VU&YUVM->3Mad4H9+CV&*!fM+!_TlFs8A#R}uk`ldQZgo&yyJj)KzYoMoLE?; z5bf7jR*+&t3RiJ^k_l>=9POKb*t!mc+76H{Qo+ zddPLmR^cDH2*Jj~uj!3jq`T~CJ85hraZJb;4)MdE(hrqqv&MTPpTlIr06uujgj>!J z>9Gcf3m$oRqb!QDS+njs&*m%`h~!kHRupp$2_@1ZZSLqK^e=Mp1reWZ@~4|=6As*> zbTl?&Lgz1RoQHJwuf4%{lrAQqeWOg~e(9d8e2iAJ4SsN*y~^!UOqr+YJ$Xq<(HcE} z_aLS|5BLSYPqq650+k0{A1{3G;nVteK#s}Mk0ZTsVU~YaD7Gaw@xA-zLQ%JY^bY>x zt&MoM#*30cQeyin)Uc-aJBI4_2cDgM$F}t-Uf1+RNI(E1D7FN7(RngdVs%z{s2=(b z)m{Blswi^QIOl7d={~>Mo>)yz@ph;LcmYvRPEa@zUOPibn(=_6&}J`_{Rs*)+j*1X zMW(vJ7+FANU0)CRUi4@ITwa+K6b`M}tTBJ}h0dNh84hPwsBE@AA5sJ#-`S=&XO3kA zkg|G-qh{6Y^a#RxxqJZ=oYyAFeqw|iy(R|EaLYx`f7Uh1l$ig(T7;O+DG1Hcx>HGI zVnS+QKw&})^^~jXfyE2h;sZD8aHeS5LZ4uF6!NuEO0`MLNL|nnm&?{(Gl9!cZQsIV zjn(i?acx{ZZPR^+l_ULV@fsNpOAc#x5G4|=^Wv&%xMb2!>Mt+Q)LVvZ)ux1AdvsXo zdX`=0GWRJ{7x(Q-xnCW_@{NO6&_cHfGX9Ar1@T~ESYB)DA-Y?WJaH`4$hBpsk!Ktu z=!#cYP5AuAzYwBJp-q$@#DLq;(T1n31;PXu&LOmuSC(&Hhwo8p9h+ZPwN(cCk`qdG zg^YFtuiL?$GbeH-my512sWkl-*L`MKHT5Z%Oz`sCxGyJK6M>%a2Vt?9Y3C~v&{SF8 zCqPj$m6VNY5|h0~%RD&)B((=4K)652qh2ia>jziwn{aXeM1gq>qp|(>OXT~DBhSuk z{FnnI`8)AUnx%w&y6g+InwrJV*H4Sm0hMGVbkPdaCVM})@5NP==_fL_?}qL z+?kl$QJFIHzUvPO0GrFO|HfFvcwKsLn2x7Q;>8`h1cmi4kL0ogKeTh`d3|yj*&n;^ zt-2CFGFxgkIOVkRRiSe22amo$4UB;~v)f@cl=2rceXq^&D%E`6O`eA&1YZUNK@Y-r)$T0Q;@-=$VrEuDd zbQlw77a;wX5Do`736UA!)Z{Pe-zqgckc$^Jv3cMxeL_$Iv_9+DG|-?8%?wxY?is4Q z-nhUj$2d)lD>N4D!@-Y~zF^@;k$Fx2=kW*IzolK5vrVY80MRSMi|{bL^~mrI}aK=HMDKdZ&)h%gLG1S zS2N~Ee>C#ml_l8zg9Dk`4msC$i`Yg@-xhS($39=H2QK%oD=lK)G|F}2mknxtKZ!PL zl%5paB};ReasY()j+PdC#4MhZQa616NG{6<|0e58s38dHI0L54CSj^|SC_0pC3LSw zlL7+9j8e)v=y+P+7E%rzmAqJBKJHSD>wd%gZP`9kJ*BAc2i7^CAH2Leq%mf=nnj3$ zR%TS!E+;W_vWuT--C9~Ygfk~EP9|V`)Udb2NgZ_1oG;_|>HRinWbqtH=2nB(y6BbH zF>0~RyHwhK37553YS3n0K_N@b@b=Qy*!)pC4~zwaFQE67o_`KsAA3O%A@J%(8NpnQ zeu2o&pPZpckESE9PfN22>ivF2q!}HCCoJs29|&8jzaAQO-7f=YA5I9Sg!>L0qRTh; zeQmJcH%`J|*Kc*T5)y$zR9F7TqauvCdly0kS6(DFv~V~r4Iq74Bd4dSJ*<@v5pHmD zX=D>c?l$$t(2q*7npDXIB-ylkbY)3*$A7B5FFa2;jm=HTyz`IvnMMF5u~tBmqi-Y1 z_AXR1iq=3!3a6=7tO6-GcX+(9*)g7@w`12|MV#srOn5o?R{Z)EMOSzwx4SZ-xLt+M zKIB{4Z3RW39O1=LyFEe2IJy6NQ6(m%4;wPeRd90taOZ5yb2g=G&0`}%?pirR&pIQ1 z;$KG3f>ZNAkKV=WplFU+~|j(5Sx4m?J|E+SV+ zt6G!y%8{4nMnIS<^E!zj`qWE%Qq<>X8jjKt=wlmn2$8lf_XK@` zk?rmdIKr@CHGd`dtT3}>giE=??Wj1M2YpEl?*{wcyA$q29cSoBAfSg)eW#gQjM(iF z1E&7s9t=h@ro?SzC4xEL7>Iu|{0deKL4|R_qu)OsqvGKO{HYA#Dq1Nf1LNV90#!dS znED(^SMM?HUq0ls*OxTeUK4Np4uJ2u2i!m==D5nI`tJ0&2M5Gq_2QmKmrobwD|KnV zkTAOf4Zo;=bE}gXLAncyok=mPbv-?*(YMy?=0G}}E`F_f1#Ph&4w^EuD(f29TGW3u zvVYQfzEvW7wtT08_T-uG7$c2n?sk<=w4*tqNJkKGZQ*$)HwWTqQOAU@b3)jw|Gk`#qt0~Uvj-RH};;DG@GQp+Udu*9QU5k+Tsj@y=G>=FrNIO1DFu!T>Y z5wEp-7?6Oj3wv6L-QX=Ls1)gaUXa%6De(0_+C(m5?rFqP?7>r?knSr=a8i|xSN*(@9?cN{b z9Nvh`1_JYdqZbN=p1ec{;;llS3f)&@5zgZ?V z(@`o~k}?Mqm+Un7RD5`|Wo2TR`Lt=Zqg>&s*^$SHx*D4z%tzg`mG;W*foRDHvxaCB zWx_O-kR<%&s`b-H==xYjpl@rk`ot>n=!jTJsWGyu{;t;C>~~m4w?lcP00cMcB8qbr z7^(7#73ZxslNdynC_w+M3vTSU7+W1aovMQg_N=O%9)*Dhrb83kJdgn3uP8uF1{>Rk zLGjr`dNZ3|VH1g-Nf}}qr~5O2BnpU#zD+~Iy5Jgf@TmSHF<7mdlXh<-O1@`i>@z|O zNITlb<`MM_$i!G$FVFXdiXL#Wu=>Tx;uG$2pk6U>MIec@_lZN3H={+SSp|Y6^1R?q zG`8oF&-GOXt+)Dupa_%LnLEad3BXzO$O`8AYL~9^O!v~rgF)1MAY_|vBXIC}BoZ5B z8bNX)rJ%lOiu|h9&Xa=X%K;DK+wgI}9)+?sA5lny3NF4HIFY8Z1AcBOcA zl)|7Xg4KezqjjdK>nqpg9M4yFpjkUgm!KieQ3XW^tOQmqch***wt2%0D^pQl(U-TFl}(wnc!3bU_s>+l zB3g}vh$bUP=gYR8vzzvBQYQ`piwY|?*iB*MubKe?sBFWYGgoswHWBj3hH(r%QL#cp zI;38Br2A~_6;SwUZ7^^B{SC$XxYo2YGjso%ya zS$ZbZWq&V-p;S27kqoWid`BseyEs9`TZ`mnpt?8{wPIFYUb($n_<2bgBE>khbih*V zVKM5>HQVPuwOBc>p8n8Uw1$}E`!q}rOKE33N9*^*#Llg>v-Eb)Ih_kn+cAWk)K`)g z7b31rJYKKO%aapqiQ)T@37`Cp1R+>Hb`ZvV$jD~O7Af4#@;ZQfHEy#&F6LwQm)Dz3 zRwz>mb19QSIDOF*g8s2f`S3*_`<>pK3nFq(AAj{qZuB!OS&JQ(G?~QZ2=eJ#M-2B~ z{>{ZOwG`!IYlV3PwhH^=GgVNiqZBO7lOMKS7qi|b26|+0uqt)yT5arCk8nNFK6_<0+JVJ0G4HH=MrP6QVnUY30&K?Gh zD@t83Mz5r+{~S1T(Ud(a{|CcN8++F|lbxX6xhf;J+0tb+AivzDQzfT|eEq6WVTy9Hkw>nOVt?oo6hiE~@;fq3 z{AR1IsdqZ}TB|>t^I#wL=Bh+|t6v@6JQ&)8;?0G2tM}Er@6pqhgmLikrH+4pv5J)B zRaH|5(mz6Oh26E%&dVLnvV^`LCEMRIwW|l8B3<-IitPlc0P>Y-sw^_X0N+ykd*j!z zUIjvAnzjJAl51+GXd(WmBQzv<({@&YjLC-xoTq6|Af{$qCdMx564tkx43V*}j&#ri zDnE$~i%q*iLODpN2zLO7%SIr66frC(P6#Z~2b$b!6K+&YS+XT=X-{QeTikI2m0WgJ zDeND3wX?206>b=X=)UGHam1}+<1w7OUK?2~f0!gYq|RXtIV;ge&?snwNfq2=As57I zFWgR4ILEb0#^w!=4%@Odo|!jSg9G-tBgq&pFti?^+!u1oSOqF4z zwq$G$k))R|c_DeopgpBxoaU=MP$64?ho@i9$M7>;N4fF9s_%Q%8pRth-P2=&UXpyl zZ(+Gz+qlC=)%e}|OUYz!e~8?=c4X7Po=WiTnkxIQh=GOaz|<~g(DPU+0(OBj-$HTL zlLHnUuGV5!78MWz>;30yL24{AzGgdY^Lmdq)Yl zbz1a#Gmi^TVM%Ru2Ndf)n&2M}-zO-)-vXu3^ir8%IkI1fr9nb69w>*^yWJv%H{?q> zkNXR11ht~Uc(01tX(}BU2Hk^6+=)m;*V%uzwh*>>>94+%;ywr005FzC1S>K^6W`6R zab5J{>W9TheOOY7(!9+Aw@vqpHfa)PBq%)HZm0ommQYgD*@E1cK)VM@=%SJ_UM{aTQRjr0@(2z^C?4*s~nlnR3qfIPnswf&jo}t$e#V`kq`C6U^+A zcbqoN;#S%d%h=M!QNg?I=6hZ#7^PAJBMGb9u!e!A(6lbSVmpeZk}d}L7?;jP0K4Ac zhC~mvIeG{#VMaD1BdUO+1cws7>k3OLf9gVuA;!A#`;;k_JQ7VK@LLsQFTPL!n1e@w z$lbhz@J#|Hx7w+$QA4>OSjj>=s#yoaS(-nK!4Gf?W=aMxmyH|^Hz#6yUOei_PWhvu z+YHXEGYEvhgK+^H7cvttrNG_j1qz#W-?g9QZ|*tn3HeC4ZoBRRpEaWMB|CtGgm4{w zd!MTa_Lpqyj3w+67knV7f>f?zR$Auc7Fm0s6_r(jWSPu8Zf;o(ZpJXvS@xc`Fgsbc zsC!hJem3AaW+fN6xib|!a@eikH9mfWVGR8-BZ%!s7PvJ`q^&*Z{k1$t7j`6s!3-2t zxiwjZ{U8s<()7Y$eh&Kvb+eU5z`{D9JFMxrbc#<1b5+&{O-JR zW?1YhdIEFih9e*gvYTBkV{g?y2xy;0yz?esDtRRThawzqvo=q&Dm6$q&^NCF>G2`E zoc30U(uw%gmn(by9If$o6Uy={K?zoq`jc&_`Y&4mKUgzrRB7Ld5srDFJbg~NS)L~5L=f*~y%xPlvNT>{32<+s}p3XH;ThFb%9 zqM5Oe_`$eFc9GVUF-GyGYfbnWQ|M9K16aTZph?KRGQ`~ksAP0Mf6E1~$t?X}8VtNGe$m@OXn;lvKJ-pPx8h8C2+24 zzJ320tD&U1E6*g5qA$cIrZAIPEb@Q?K&Z~nuW=`!q;X_5Q4hp=8i{ZFK_0S&`xnGP zCXc46`z3>6Y@fdx%u3WOcfE|q_T+!{^CO*%@qL8ug4i^iF`Y^`s|-=7zlBU}%a)09 zUxUNP;NzZvCNqrnf6qxY1(-oE*Pa3FJk`PM)K{bJN`)J`&$x8BdKu%QfLbRgmAepU zST#~BRXZc!6Qz0Z=?rDIg}N3yOIEfag#tXr!;|B!nX)BTUzaOZHH%~fk1}662=zS% zQNJ3E86-h(bBH>8Rxj1Y7v+eHA08PV_nj&PfAO+KT{<6XU~tOEbbd8f;IxHTtHj)r zR*Gwm_S^`57UH=I0F+mGU_*fgN}A1vSwg%+KVB5{a5YC_y_iR4N?<2$p8t9iKuLOF zw!0%Qw$ggOcfK7I4Tp3Yo5z?6jX9JaF_|jp+_aoP9KLJ3Guky3Q7J`foqIG3eBPid ztiieoAGNITd+I#Q;9?0zGArPVachMpTkr>k6wC@GX{LHw{lM@M(}+-~GXM|0<4b52 zgU^@s8o^NHniB$tzZ;AgG9_Ifc;Hr>P*2JRk!!oGl0bh`E9|r{naH>7Sw{idk3Yi~ zNk?gnzaMRefl$aaV_TrJ+o!tNp^5VN+T|>}A@WA}H+>EM5`8B5mR1vz2VqKYA#at)HTuTZ0FI z+67gU&1?4A6$+a*4_8|4(tN=a>Xp2g4sP~4BP$`*PX-7Mu0QfGWJW-*yJSp+0X>B{ zkYNy0F5358bJP5pfzke+SecF|#<2^D*AHL2VwaUkZFc52tDvMX+OP72P~{V4_-TQ? zWts8G)fM;|3m;0ar2IFzRr$a{l*!n*KZQ+;oVTHt(=YG0jnV+>SkpUx%ZbZ%0k8;9 zY3i#>^v55Ouu7^oa)6#YSCt*;X>N?ZRr@RoQvj2?p5uzuyIPwsUMN8e4cKPdnHt&m z&AK=&{*;x_Pq4X7G$giW`dPf`+`3J7a2y|_F4RY@hgktWsCZ{^Eb_?kn3UPjwOn%d z<@YJ9dEL_rtJ4i&eBbXLhEt{&2c!`TIElt}^%G@0LszTQ(bO)&K!W-qtEAElLCF!0 zdq6v_5Q1M%_rY7d)L9?gZLXXBkPMf!$PzJeSUqx_6qbDg^zfaz1a9VjoBUxV>e~I2 z{e$?g?*5Av+ zl_g5qYhCQ7w{pKXUlRE;?&VN^#w(F2ec}o!T&-|1RHKQ)(zllL`6FS$Cs?zvKL-Ji zFpxP+LD4;Ub=DNC-ym@<0`sUXNf{{1m_k?_UuRG70|mp8I3~p(6&2=Fee>Mgt|Ubm zKAxe%oF+!);b{rmaqF=P_96P+KH}3$=B>5wv28UhI`{=0p;WnDlbtoU4`&#r13L$= za8?{cuDRNjLjg>s@uaf6#=~7-2t`v?FPI~#1ickuc>4MSjHlJ)q!8daiO$|9C+*yR zni$pv-HG~`Sm8R}7uw;l)8%wj1C#5Obd^91FGr*}Gl9KhUURT=#7m$I{Jma-wzA+hZ4<$`ktTrV`%- zb4yShNLkAJ(+UZ4m0$cr@fUD9RLhNL*|mISZl52V1Zrp42(h7B*EfNuOxt~UL$f8Jl8{ zLrbp$T|z_O0==>%UB8wy%xK)f%5Yj&_kj$Vj4Nma#*&_JWdi1P=V}AMtT(zLCebS+GY?J&GHHg_^}bRA$B4yPy!6@^4tQ1#?3HLGP}n80@(?Y+x_7bu zJ7PoK{ps)YTi#dVf!lUW4|>G5PoVNTB$Mj@FgZm~z*K$tT(zGy9ae?zx}9arbpvM@ z{K_6RY;HhUF{IB!+R^q6O8lYiy6b`zw3f}VH3Xu;d@+U zPlVGmUDbzA6GVa5O6$yyl1n8Sm=Wla@#ybed&q{hS|!Gmzi}40N|8yTspG!ncSd31 z-LJ%O7F+b{dZ8cqVbRco(8)1%-S!y}P`bzg*Y{e@U540v^j2%r3M{P=DW84ldff&h{XgousJ!!Lj^IikD^`bR1M zff<9TXHoWUm{I9roBid%1{iFM_qSsK|A{y;3}*S_4nEtfX044G==GWHZ7AR$J-}jC z0-z^a_)oMf2~%4IDH95QN4bzqBn&nMMU4%p?Lhf4n8irUuMO@V`6E^Q?j>``!OZL4ZYpwI zaSUe*AVAu>DI5x|-Wo67QP-e>&ra?{0Ui9+^HLj`1r)=Ag>MPrf{6kOM268 zLS9{$)9EOaSObxYA%jNh?6lk|e}NN%j$rR1khO)7*+IkYV)J)9mBVk`x}@Gs8Mp-W zzwSj+xA8{~0AoF;8vN{?7PLIso_%LDRPa-c~N_h zF!jn`{fRS>7p|40guTC2guA)tUJ0bfEm(-*gYcBLC4s^mi86Ln-LP;53-B2MyST;7 za>+r09=Ik&v2f)Z$z3bsjrTA#{M zE~d4$A#w9J&>_`(Nb3mXf2r4`_iD$4S#;w=nwv4d-e8J0`DQ)}XUPRLnDRFC0U&d% ztE@h4z%aij7tf&F7a-B?A$t9>WLMHg$_4q-(YLjOAr15Z$-eS zxrB>T)&g*%)~k=Yro-qVTqdmZjeCz>3cju^4$&9`Epj?y-qD71#WA0w_*3KJJv#xx zt5*!t|HuXKFkC~C$hCTM-QWUoZRAy_262m2Bf^ncurfcmJ#$z&7d>5%%OA_*yACGQit6u>6;MWQT7KI$(iOrOLK5e(U1&cpyTWx|Z?cr)b zE?pA=d>|BrcWgpY!Mm;$eG6dlT}^LL=V3VGva0U-HTae7dy9mQIS2Wxrq8`8}xi2g3r+jVNJ7)0Le;8{rB z&Pg>3O3v`AWYMzwt{LRW8E%$1Mq73rXi!)*tt-MBxPSvlOsKM-?P1H-V{ys;&G`W) zemR4g#07xR>SvtXHD3rJKLFG*u^>(#LwJ_S)`G~R9rYTztS<2qP5nw(aQn^<@;Ged z-TM}EzVf=KYhH<3wPQ$W+*BgOa-m-kV@oUXzuG(VcPQV#|6AHcNm7Io$`WZvvW)7j z8nPSvQe^D=G8&B~sSu*j*kdd+MAk5dBq>JL$vR{=mO+N8nTGrHzQ5n&_#WRM?myr@ zKEGT)%rDOCbY9o>I-jrS^Kr&t*sY+;HIREaW8w)%384TNwUhBsHE${e2_Zl>!Z<8> zcRTsk?&3VAkS)DA`}#q55oJEg(xSy3vP=o4SSXKk^I(7i1qbgRU{19NbCuTfj@2gI zdUND15V}q8Sr76oB_AH8bW@-v$cV{Z#h~#Qyt*bXlh?y3X7(y1|I;zocq`bR(Db;^fts?)Ma1y?SE~WO%RpQ*jj=dM)J#$?+$D#1-v`Xzyc7!=NXz z#|I2Ri^!+_l=hI^fX725SH|KL6JM2O&D+)ne2e#4ay`Rd98k@N+QV<=e;-s038kCR zANw+jCI9L^?K1RboOH0$+X2;86ZYKe_Q9^w=LMh9#RDW~>n~p1lnWNEi~3_|QJ}5+ zBClPV#>ZoxSZK3k?)P!`c=k5WKPeW&AUB)TcboKbtdsVl$JyPXr|jDn{(R`F_q6>6 zcDHix;ztT2N4@P@w^YRf#`2d2eW43kReffa=5e!O1ae3?i=Owm((3KHv->&3R$Mw> zh2;dK=)Dc_MAKB8Ts-v+#|m}f?r6LgUj3*&=L%>jWGxEetRK)DJ+{L8flD+)V|9_u z43xMM*ey3G-Y}mskrWuFI5>A(rO#&G_KLT4#_8hi#unBPawdtf^&xhHgFM}@LOEi7 zPIM+K%WY#-k>13dGK0iX3s*uo&phk8>?O3Ii`d#o$5*k=wfE^fIPvOf|M8B~e3z@V z4Ey%I6bDYGZxelcTD+u)-rFjy@DSi0f%XlmpK53qB!kAmRoTsw;j%PUf_h<47Z3?^ z@VumJ$fX6S3a*84DxE2?`3*F;oHvwQC?nlrbqaEfl~jwEd+8R?`hO5@>cP? zA1nga^6^*#4FDvbhEH%Xm-l08)qha(kMU5X3Lnnttw+XV>aPCL-(=ZBX zXqOcnj-$B0DBGidT^enwo>1AqZtH)-)uFSj^PFD_h-kp#J&LuFI zvcV^A!Sjh~voM}ZZ{m8}wDpbO9zhNpq&RA2iJ-t9MGUa^#VbVjzg#yRG%A?JxAlL=&lmA(62-fJ|D}ePm}QmgJ_8c#;AyxF zuLx+n-!y}N&_n4PPApYUbjgOF=&f$Lb`QL`Dev)Kz~bG~1*gj5tecTQwCDUqYX4eQ z+q^uH_i($*WYbcLZJ#2Rh9>}PaC*rz!`uFhuFIRcF=r1)vDD{AD9;L(vqt|1gq`IMMIe8x8U+3qf!Egz|LrOJpBW(9f~;r_!tp&C&uGUYGaA z02%!pQJ)2}FK@Z(h5Q-v=h>j^p4)J&25dmo_gWh2HZP$peN{cI+fbLRmyb6{*+;(&l1sL3-I#>85%R~ z@jKN=L7d0Gw&Ojus;ksNU=M}eH}H{2E>o?t8vqgl-gYA8em{^1R}YWsa2|O7%a@^s z)6p(rOFs=BQ1Pp^3o?#KnP$BppIRyk^Re4~4D*_K-$a`KNyCavYhUI{-a9C6JsQ~s zZVF?DHtKU>TG}1;iD>W6Y8b^WascJiefxPUZA@POv07Oq_n=q*v&3tQ+r=oRA$}QU zpy(|!UDYgRI)cc&hO#p4>TTCL2J(E#S`uy{&@Gv9zmypdPByVbG{21D{aLt|Cji;a?;3KGo49~`FqTJ;pp6aMqPS0DKbFt{cNDjm)~oSH6@+H> z=$~qQ<1|QB7kO?B-AZSDSCNrbxR={$6q)jj;3G=Din+3L6B2h{1TX;d7wMG>#UEpF zU!LJ=5>%>W7H@#SDa^{+-6%4;!8kHHnWWF#q-gEiI?0n>5qXy2&`^j?EIP8(9$!|1t zjurs9D3|_H)|a#TOJcu&Q}J{68e|>o3u5bi!{EL}zTT!hGR=&ELIbeM;-}*`X13au z?ZY}jEJew)h4*&mP~+NSvc8n&1!_P0KzZIwNQuFba&yF-l?~}-wSL#R+G!Wm+9I$^ z>qDJCe{*|y^PJX)-h(_pywO9Q_7K>QT z={RXWn>jRe*c9T0+)%#Cq0RHgs`0!-HhAKlwN*p&8*Y|xr_>$2W=ioe+a2GHh-kz^ zM-?9l{u|3;`VB&3Av3)o^HXWj8Od}4dv>VLS6bR5t3C?*5ocln#0M164C;Riy# zytsbX_cUY<_GHrE#b{Mg74OOT+jdoJU-I*c%=!-xuS{K((tLd*aofystfubQV(faN z>V3w@cT&~(UqX6nJlm!Z&aR&X9%fQ%pkdT#;;jnTF&q^c1V67#n)=Q$~U}Gvn(|iUO(yToS}Ik#}PEeP(AhZqk5#p}XcT-b{7S zSG<2$qt3a!rSx2S)Ac2W4LXLCnD3XXnu|9w)BHvqV6EI7)n72DJ`h9Yc`O()ZatLC z{66uAg;E>fPb{jxYq#gdsHY<)RTJaoLe_gtqL9P|QR+V)_qdS)AE1t)yrnllO@%NH zt?0k98!KM<0w>J+rFk8N?@vp&9sf3_Zu~N&>$$yG)`fK3P7Zed!O)gXpMP&Z#8YJ9 zht{*4Zsiu0%+u`EYo0P&02Xp0xfYHL@qX$Rys`J?G*E$zioC-v=eL3m%O@dTaDkZS zuSfE3*{Ho(b1zt2h!jK@3nwi_2+Lina$Vb@jIoSg%7G-;SLA6BublfrcL-(-BF40O zQ~)zz9hK@QbR{Y9af3EqVP$|vT$5_J(;sY8X$b%sam>|kL~-pbuoC$9t)g1Ck`c;7 znd+!uQB6jNCR7Dna3rf3ps1^*BX=rW2-MZe?*&g)Ls%0=Xn0Ba{CMQ!M>n%381iXX zIqM}iH#mC!l&_p`U>yi*+DRHv={JYK$-E|MevEnJnimG3Ra|=|ByD5xRAc8}A#Bs# za*v;7j-SSVgN%AgDBhaz+|4IGq-){ek!BoJ^)$rKTpk_f5H-4UKk7rfKWRC%cTk=Y zcKqiR2*E*=L~?ZW>@WPX_34V6l|aM~v!Pqi9^1STrU|mU!n0m#482jUOc?#_hm<3J zp$r-`YC>G;kClv0gZAw^5TAxB=(k;a|5VCv^gF@7+{0s}#@jcHm$9A{Hm<<@*;w7; zWcJpO52kBu5^Z)wRnss zW;&7B0j3lJQtT^-$7dTYb}w>LcIT;Z-f>E|l$XjXp6FU zdBt-twqMe=;V@JGRi2)^pbF)Q`jW*;2S@>;R#}4{>LD-kl%{QMI+8pts&=tdaJSE| z0b5O>Kifx})9Fog2lAvAg!zW*u}R;Xe_pHd3;0wrUeWJ88u?f&;W_FJdVvALbw^6A z=`UFs4c$HjUn_UB`?EBLV7WNdpP}k9o;X!UsAq8kLz=6YzF<{zFoTD}ZmVY;S!p8y z(iEIVdEUVa4sRl`O8TmojTgPu@F6*)xK|fJtj|qtaen|9xhJrN9GA!qgTu+e4&V+# zUt+%{+y5o8>}jj``R=rpHi%C^KHhy5xFZGn={>n1b*Fv@|NVs$v71TJSlTGsA)vMf z|2Z1`3NM@bTT=$E7(=iq@JqqBXioTakD^eJ&g~VJ(p!HZJZ&s(Z?E44D`_#o82Kj? zgVqJik`FQu&fI8BT?ICUwQ=$oS|7$LJXC+S#U_cIKo z3-FKa52`Zrl2PiAIH%~^DcVh*vc}l6lI!W?$7tJS)lVwP+x)LeyO#Ze4j{#)WfXoT znnmtAe9~wFb{1?NletT;tLIcT$!48{uTuXsu>|jB2@sFZ*%4R6MaHjdw1NiPC?1c$ z>Nlc`ca)fkW1YPBZf0XP{1uV!{Wew?`U+oMO$Y&bLgbGf(fso?vHR0wv)M1RXb#Cg zRIAQ|;5fS@JvC5O_R|{>hR?$UQ6lxNPY7@;B*eSf5bM6RfJEM-IGqT%7~uH4mU^-bDpt%w>2(AL&u;eF zkMjrM$Rvn}UxYiD6x3K~9PvK$@tPkn9ij^-)Sqgd3L0R0;PF%V9-7H!q*SDNKUXQV z1fQq7s*6j9u(9`h6AxnQSmfkjQ{>e_(lva&uR|ScssC8b82pqTCG!)e?7(q~W?5Z( zHI7@7n0rJues<%g>1u-n|GkuUN=^>`y(7xrn_ug+2cja7=t>LwXOaKU>6DTJPIKTv z)cwOZyJYhR>TXKgP99j2MR5xO$!d4t#{UjnSQhT0H!?TeV%E%E3eteLy9ub~x*L&) zck{N&_nKiT;{#7P|QFnyvN(4yqk9T&5Dx+euQ`X^k-^hdxb<|zGEY>|Pc#5WkLh}UGW^{S(52}CjNRlch!?l?A_d?dt zt!T^he_u?`_^%$fe+M`1Q5_;}A-?^_9+TT>SFH4%=tPkVFi!^KJXDKRxR zZ*$sly@N~i`=3f2(RNvi_Ein`*VYmR*nO$_ZQq6tBb(2t7<#uSEv))8EG0HO2iAV~ z2#01h<{d2mw!l|G$77|3M85c82|2Iv*_AOyUnq*Y-Gjjg%PTKcIbfY9)N}-gyxi_w zPnu%Ojkil`4tP%r_8i`0`4#cgz-8!T*7x5bsOx2+^1r(CF~5OQQS=aL<$cG3y>#e8 z{@}5W<1Oo4L1oXnyho?{GxR8IuX?0w5E^JNNF>y!zGv~P z^p&vf2tsosj1kZ0j0iU%BDZaZ0@zn!8)NLNz!LX#-kWZfbLL&{7 zUC@ohoK(g~>kdl0B1XdQb$9{eXw97adv1%jyPi4nWKnxbqi`c%xacyx+`(={4c z>uPaW%T`T%j9%*2E|;QBKd%KcK(1P>0V-9xWcp143)5S2H*5C6r;_Zp>rI5!*G%Es zn^K<^==_PjI#fIZZG1_VTGWZwa6>dzs)LH`4$6&bsY=u$Dr|l{CGExyTf*J#)=N2e zduE)!S_l%_K{@xiUVZbetg(h_dr@LIA+$R6x1X>^#qWnrc((#-y{2SzcZS}GwaMvj ze-ry0*ZqFs_oK9@9<$1*=f*`q8Poo~)vchtk=}Gvj>r5PWxC=Qp5C;^44iaq4Ls|# zCs8Wb2RK&x<=*d0$oi#$V`j=`oq=;_TdBZp_Wr<_1nm+? zwk2}Eq1g7)j}=X32cQh0?!Uu*KWee+;DRuFSoWCEetkz}QGPvrW8SRdxVT3FPvrVg zSmcOJBQoJqm744xLCcj^fZ{kRmx`^;*Z~pg5d9rf=7+0aL#&t~&YbXMQM*XNG4@63 zr`zfeW$j+Go$y1kjJ*!tJzwJb0;Z(wew|$87ql!E5wvv0M>-Y#(>hj_@yMA50cN3v z&(h=2#m%h1k3+7x{65dcUj;tWXGKdtcZSilYNFCdvTujfpb)|D2;pbq|I&`bubpyh ze?=$9#OdX{sgJYOdpt_;mU(9Gr@j^&TRE$3}u$)Qe!2xAdH*3qQy~t zXRxrvwJcISlbIRmWSI3@_^9{LXF2g_-OUMW_$&?o5$Wc*L#637?8H6fl|f{26bXK|AYmL@SQX_KOS>s z!x-i=Gk%b6O^0wX+emq7y-MIuu6!do#%zVT!5|#;=Tc8xQ%`b^%U3*d0gt5UY5l9c zPsKp9!XW`LrhZpvuBoZWqhn+fr@x$rvQW5mIIP)1uzwZ59|&|zMtZPFyv6;t*IB_o zWKten6zEE#pl>kg#Uw%$ISxVAZpI9s!ntj_?V`5YNwh#l-P(G}7>g_St{LD8jQT<)*@cCKM_vuTLh;y6NeVyQ9>_^FDR8mB`{=s;1WX`j zglyNFe~w{Lov%D8ccb(bC=bn-EPzdArJH1s7O32LuvZ+hMHaTv=UBJ|3f}X6zgVf@ z_Kr<^^m6X{JXYMEtih7E`>^Y4y|1PxhKp{}WlSnKtPzH{zv?S1_Y0W-jc=wt@D?8`8`!^|A%^g{&e zf`LfVnlM-oNumOyNqgcI{XQhrXs_RHIxy2u2=HT~E_*jS0Y9$w#05838oEW70$8ra zmuTCLun%`eG#E{};hmg(!BJ;8=A4;{PLU^e}B6zV>Ud^JdDN3zut}Wz4dV1}Z2G6Kh`6~{c~MUZ@KPoTEIB)%j%r$Pt^1r(G8f1Y9eKt@zUID}iS z0awU+cvJu1aSy9(D`Zd)L0AvLxc4Epv{Ck9>HAh|STR`9{G8)0g1Yw*A9UjMVDmHM zLg1Sa$8*mH;DQ-V9ABe2c%))}{X#0%O|AuBtT zX}OH96Gjt2 zY8sL-ng~R%oC~3PnU~0zul@)59JAtyK+cVyy$1*S0Zw>H0C%W4^1E~uofO)z5a0Nu}EU> z&O!6}B9%=8W-D0R#kane&&Zbg%#25RyC9UVp#@-}{o)=qG+3+5)IYzu5w&L+e4Oag zroT5bef6&CnW!FK1<2E=-%V-a$B*rJAp{`uSnGHvljdFCz4 zUn^tkk|)&h7}InkYc<~bxFEK`PbPf_U}?dw zFM~{lvF{n~DEY9`wtnH!UQSEp8loA7W;UMnTp9I05#BK0u_^)>W!; zspOi2ik6zbfPVk;%ekG`fTe1#P&%olfS=UrIRlA<-SF$NN;m9NEdF{%E`8A_N&3XC z)mPyqtk2|Sp1xllo22DEkfj0V)cQ22|J!o{kPIgTtIcGnI#VHaq^9b?6hIQ4>SEeEmn7NcSOXgq(Ui~}!^9Fw*K(8A`1aHZGu2z0 zKlgyGxp%HP5EpD8++y#-oA1QAG+q>?n-}cZ{%t=G5XXUyYh3>VoF_0H$%B?#Crh%! zN;TO=HnjB*GageMD_uZ%qz%MI*Ur%3ha|L;Zp7H0Z7DL%eR^e@7BwX)kM<YSVc_?pnp$$aDb&qb=*MU7!V(N4N`Y#)lBVLl&bnyIoXI$(W#+caq*(+C?CU$nSFnRpEnpcwv`9NucvDK#0Vi0#O zP^#c6KJhKuI79m<*-OgzO$`g?*NC{jnKHV@+!H)+Jqtxu5=bHV=#sO$J=VSdUgJ%S zVMIUcf1hlX Date: Fri, 15 May 2026 17:27:24 +0300 Subject: [PATCH 04/26] add hint bar for all interactive command and add async live update for list --- .gitignore | 2 + AGENTS.md | 48 +++++ CLAUDE.md | 59 ++++++ go.mod | 8 + go.sum | 2 - internal/verda-cli/cmd/auth/use.go | 3 +- internal/verda-cli/cmd/banner.go | 16 +- internal/verda-cli/cmd/cmd.go | 51 +---- internal/verda-cli/cmd/cmd_test.go | 12 +- internal/verda-cli/cmd/doctor/doctor.go | 12 +- internal/verda-cli/cmd/registry/delete.go | 5 +- internal/verda-cli/cmd/registry/ls.go | 3 +- internal/verda-cli/cmd/serverless/CLAUDE.md | 49 +++-- .../cmd/serverless/batchjob_actions.go | 4 +- .../cmd/serverless/batchjob_create.go | 23 +-- .../cmd/serverless/batchjob_create_test.go | 32 +++ .../cmd/serverless/batchjob_describe.go | 6 +- .../cmd/serverless/container_actions.go | 7 +- .../cmd/serverless/container_create.go | 40 ++-- .../cmd/serverless/container_create_test.go | 39 +++- .../cmd/serverless/container_describe.go | 18 +- .../cmd/serverless/container_list.go | 192 +++++++++++++++--- .../cmd/serverless/container_status_cache.go | 10 +- internal/verda-cli/cmd/serverless/shared.go | 9 +- .../cmd/serverless/wire_format_test.go | 22 +- internal/verda-cli/cmd/ssh/ssh.go | 3 +- internal/verda-cli/cmd/sshkey/delete.go | 3 +- internal/verda-cli/cmd/startupscript/add.go | 2 +- .../verda-cli/cmd/startupscript/delete.go | 3 +- internal/verda-cli/cmd/template/create.go | 3 +- internal/verda-cli/cmd/template/edit.go | 14 +- internal/verda-cli/cmd/template/show.go | 3 +- internal/verda-cli/cmd/util/agent_prompter.go | 14 ++ internal/verda-cli/cmd/util/factory.go | 20 ++ internal/verda-cli/cmd/util/helpers.go | 31 +++ internal/verda-cli/cmd/util/versionhint.go | 16 +- internal/verda-cli/cmd/vm/action.go | 4 +- internal/verda-cli/cmd/vm/list.go | 44 +++- internal/verda-cli/cmd/vm/template_apply.go | 3 +- internal/verda-cli/cmd/volume/action.go | 4 +- internal/verda-cli/cmd/volume/create.go | 4 +- 41 files changed, 590 insertions(+), 253 deletions(-) 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/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..8391baf 100644 --- a/go.mod +++ b/go.mod @@ -102,3 +102,11 @@ require ( golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.35.0 // indirect ) + +// TEMPORARY local replace — required while verdagostack hosts the new +// SelectConfig hint-bar fields (WithShowHints) and the LiveList primitive +// in its working tree but has not yet cut a release. Drop this directive +// and bump the require line above once verdagostack publishes the version +// containing both APIs. The branch will not build on a fresh machine +// without ../verdagostack checked out and up-to-date. +replace github.com/verda-cloud/verdagostack => ../verdagostack diff --git a/go.sum b/go.sum index 0871a65..f9ded78 100644 --- a/go.sum +++ b/go.sum @@ -189,8 +189,6 @@ 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/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= 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 index 2990525..454caa8 100644 --- a/internal/verda-cli/cmd/banner.go +++ b/internal/verda-cli/cmd/banner.go @@ -27,11 +27,9 @@ import ( //go:embed verda-logo.png var verdaLogoPNG []byte -// printBanner renders the embedded Verda logo via the iTerm2 inline image -// escape sequence when out is a TTY on a known-supporting terminal. Skipped -// silently in every other case — pipes, narrow terminals, non-supporting -// terminals like VS Code or stock Terminal.app — because terminals that do -// not understand OSC 1337 would print the sequence as garbage. +// 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()) { @@ -41,16 +39,14 @@ func printBanner(out io.Writer) { return } enc := base64.StdEncoding.EncodeToString(verdaLogoPNG) - // height in cells; width auto via preserveAspectRatio. + // 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 reports whether the current terminal accepts -// iTerm2's inline image escape. Detection is by-name only: spoofable, but -// the failure mode (escape printed verbatim) is purely visual, and an -// unrecognized terminal silently shows no banner. +// 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": diff --git a/internal/verda-cli/cmd/cmd.go b/internal/verda-cli/cmd/cmd.go index cb6d07d..d7b4a94 100644 --- a/internal/verda-cli/cmd/cmd.go +++ b/internal/verda-cli/cmd/cmd.go @@ -75,18 +75,13 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op 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 @@ -96,21 +91,14 @@ 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 reads ONLY from the - // on-disk cache — never the network. `verda` and `verda --help` - // must feel instant, so we don't pay 1–5s for a GitHub fetch on - // the help path. The cache is refreshed by `verda doctor`, which - // runs its own check (with a spinner) and does block on the - // network because the user invoked it explicitly. `verda update` - // fetches the latest tag directly to drive its own flow but does - // not write the version cache. + // Update hint: read disk cache only (doctor refreshes it over the network). if opts.Agent || opts.Output != "table" { return } @@ -215,20 +203,13 @@ 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. +// s3Enabled hides the S3 subtree unless VERDA_S3_ENABLED is 1/true (pre-GA). func s3Enabled() bool { v := os.Getenv("VERDA_S3_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. +// 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" @@ -261,26 +242,14 @@ func skipCredentialResolution(cmd *cobra.Command) bool { return false } -// shouldCheckVersion returns true for commands where it makes sense to print a -// cached "Update available" hint. The PostRun hook never touches the network — -// it only reads the on-disk cache populated by `verda doctor`. -// -// Included: -// - `verda help` / help on any subcommand via `help` verb (user is reading docs) -// - bare `verda` (no args, prints help — new-user first run) -// -// Excluded: -// - `doctor` / `update` — they print version info themselves (with spinners) -// and a trailing duplicate hint just adds noise. -// - every business command (`vm list`, `volume rm`, etc.) — they must feel -// instant and never pay any cost, even cache I/O, for a cosmetic hint. +// 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 "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 7d34069..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,19 +54,14 @@ func TestShouldCheckVersion(t *testing.T) { cmd *cobra.Command want bool }{ - // ---- Yes: help paths read from the cache to print a hint. ---- {"help", newCmd("help"), true}, {"verda root (bare)", newCmd("verda"), true}, - // ---- No: doctor / update print their own version info (with - // spinners) — a trailing duplicate hint would just be noise. {"doctor", newCmd("doctor"), false}, {"update", newCmd("update"), false}, - // ---- No: resource / business commands. They must NEVER do a - // network fetch or even read the cache to print a cosmetic hint. {"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}, @@ -77,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 c657a2b..5ffe779 100644 --- a/internal/verda-cli/cmd/doctor/doctor.go +++ b/internal/verda-cli/cmd/doctor/doctor.go @@ -82,9 +82,7 @@ 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) - // The CLI-version check is the slowest step (GitHub API round-trip). - // Show a spinner so users know the command is working — without it, - // doctor sits silent for up to ~2s. + // 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 }) @@ -93,10 +91,10 @@ func runDoctor(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream credResult, apiResult, authResult, - versionResult, // 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/serverless/CLAUDE.md b/internal/verda-cli/cmd/serverless/CLAUDE.md index 069c6db..ed9415b 100644 --- a/internal/verda-cli/cmd/serverless/CLAUDE.md +++ b/internal/verda-cli/cmd/serverless/CLAUDE.md @@ -2,16 +2,32 @@ > 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 -- Parent: `verda serverless` (no aliases) -- Subcommands: - - `verda serverless container` → `/container-deployments` (continuous endpoints, supports spot) - - `verda serverless batchjob` → `/job-deployments` (one-shot jobs, deadline-based, **no spot**) +- **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: - - `serverless.go` — Parent command. Registered in `cmd/cmd.go` under the "Serverless Commands" group. **No feature gate, no `Hidden: true`** — this is a GA feature, unlike s3/registry. - - `container.go`, `batchjob.go` — Subcommand parents. + - `container.go`, `batchjob.go` — Top-level command builders. `NewCmdContainer` and `NewCmdBatchjob` are exported and called directly from `cmd/cmd.go`. **No feature gate, no `Hidden: true`** — this is a GA feature, unlike s3/registry. - `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. @@ -21,7 +37,7 @@ - `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/port/path (3 sub-steps with `ShouldSkip` on the parent), min-replicas, concurrency, queue-load preset + custom override, CPU/GPU util triggers, scale-up/down delays. 22 total steps in the container flow. + - `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. @@ -62,17 +78,18 @@ Both `container create` and `batchjob create` call `verda.IsLatestTag(image)` vi `[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 defaults are fixed today +### 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. -General storage (`/data`, 500 GiB) and SHM (`/dev/shm`, 64 MiB) are labeled "fixed for now" in the web UI. The wizard does NOT prompt for them — `renderContainerSummary` shows them as "(fixed)" in the review card, and the create request always includes both mounts with the default sizes. Flags `--general-storage-size` and `--shm-size` exist for the future when the API unlocks them; today they default to the fixed values. +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`: +Mount types in `ContainerVolumeMount.Type` that the CLI currently sends: -- `"secret"` — from `--secret-mount NAME:PATH`; `SecretName` set -- `"shared"` — general `/data` storage; `SizeInMB` set -- `"shm"` — `/dev/shm`; `SizeInMB` set +- `"scratch"` — auto-allocated `/data`; no `SizeInMB`, no `VolumeID`. See `buildVolumeMounts` in `container_create.go`. +- `"secret"` — from `--secret-mount NAME:PATH`; `SecretName` set. -See `buildVolumeMounts` in `container_create.go`. +`"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 @@ -98,12 +115,12 @@ Describe cards (`renderContainerDeploymentCard`, `renderJobDeploymentCard`) prin ## Gotchas & Edge Cases -- **Wizard omits healthcheck sub-prompts when Off.** Steps `healthcheck-port` and `healthcheck-path` have `ShouldSkip: c["healthcheck"] == "off"`. Don't call them unconditionally; the engine wires the skip gate via `DependsOn`. +- **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 serverless registry-creds` command. The design doc notes this as future work. +- **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`. diff --git a/internal/verda-cli/cmd/serverless/batchjob_actions.go b/internal/verda-cli/cmd/serverless/batchjob_actions.go index 8a8976f..37b28d5 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_actions.go +++ b/internal/verda-cli/cmd/serverless/batchjob_actions.go @@ -26,8 +26,8 @@ import ( type batchjobActionFn func(ctx context.Context, client *verda.Client, name string) error -// detailMsg is a Sprintf template with one %q for the deployment name; it is -// only consulted for destructive verbs (used in the confirm prompt detail). +// 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{ diff --git a/internal/verda-cli/cmd/serverless/batchjob_create.go b/internal/verda-cli/cmd/serverless/batchjob_create.go index d54c786..6b12baf 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_create.go +++ b/internal/verda-cli/cmd/serverless/batchjob_create.go @@ -27,9 +27,7 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) -// batchjobCreateOptions is the simpler sibling of containerCreateOptions: no -// spot flag (jobs never run on spot), no continuous-scaling parameters, and a -// required deadline. Otherwise mirrors the container shape. +// batchjobCreateOptions: like containerCreateOptions minus spot/scaling knobs; deadline required. type batchjobCreateOptions struct { Name string Image string @@ -107,11 +105,7 @@ func newCmdBatchjobCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra } func runBatchjobCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *batchjobCreateOptions) error { - client, err := f.VerdaClient() - if err != nil { - return err - } - + // Same agent-mode ordering as container create (flags before client). if f.AgentMode() { if missing := missingBatchjobCreateFlags(opts); len(missing) > 0 { return cmdutil.NewMissingFlagsError(missing) @@ -122,6 +116,11 @@ func runBatchjobCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. } } + client, err := f.VerdaClient() + if err != nil { + return err + } + req, err := opts.request() if err != nil { return cmdutil.UsageErrorf(cmd, "%v", err) @@ -160,9 +159,7 @@ func runBatchjobCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil. return nil } -// runBatchjobWizard drives the batchjob create flow and fills any fields the -// user hasn't pre-set via flags. Shares nine of its ten steps with the -// container wizard; the only batchjob-specific step is the deadline. +// 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(), @@ -226,9 +223,7 @@ func (o *batchjobCreateOptions) request() (*verda.CreateJobDeploymentRequest, er } } - // CreateJobDeploymentRequest.ContainerRegistrySettings is a pointer (unlike - // the container variant, which is a value with IsPrivate:false for public). - // Pass nil for public images so the field is omitted from the request body. + // Nil omits registry JSON for public pulls (job requests use a pointer field). registry := (*verda.ContainerRegistrySettings)(nil) if o.RegistryCreds != "" { registry = &verda.ContainerRegistrySettings{ diff --git a/internal/verda-cli/cmd/serverless/batchjob_create_test.go b/internal/verda-cli/cmd/serverless/batchjob_create_test.go index d70c368..dda29d8 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_create_test.go +++ b/internal/verda-cli/cmd/serverless/batchjob_create_test.go @@ -15,9 +15,16 @@ 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 { @@ -84,3 +91,28 @@ func TestBatchjobMissingFlags(t *testing.T) { } } } + +// 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_describe.go b/internal/verda-cli/cmd/serverless/batchjob_describe.go index 6aad52e..ef17b84 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_describe.go +++ b/internal/verda-cli/cmd/serverless/batchjob_describe.go @@ -23,6 +23,7 @@ import ( "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" ) @@ -85,7 +86,7 @@ func selectBatchjobDeployment(ctx context.Context, f cmdutil.Factory, ioStreams } labels = append(labels, "Cancel") - idx, err := f.Prompter().Select(ctx, "Select batch-job deployment", labels) + idx, err := f.Prompter().Select(ctx, "Select batch-job deployment", labels, tui.WithShowHints(true)) if err != nil { if isPromptCancel(err) { return "", nil @@ -121,8 +122,7 @@ func runBatchjobDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmduti cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployment:", job) - // See container_describe.go for the embed-vs-explicit-fields tradeoff — - // same caveat applies if verda.JobDeployment ever grows a Status field. + // 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"` diff --git a/internal/verda-cli/cmd/serverless/container_actions.go b/internal/verda-cli/cmd/serverless/container_actions.go index 2641240..0db5046 100644 --- a/internal/verda-cli/cmd/serverless/container_actions.go +++ b/internal/verda-cli/cmd/serverless/container_actions.go @@ -27,11 +27,8 @@ import ( // containerActionFn executes a lifecycle action on a named container deployment. type containerActionFn func(ctx context.Context, client *verda.Client, name string) error -// newContainerActionCmd builds a `verda container ` command -// whose only behavior is to call the given SDK method with the resolved name. -// All four lifecycle commands (pause/resume/restart/purge-queue) share this shape. -// detailMsg is a Sprintf template with one %q for the deployment name; it is -// only consulted for destructive verbs (used in the confirm prompt detail). +// 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{ diff --git a/internal/verda-cli/cmd/serverless/container_create.go b/internal/verda-cli/cmd/serverless/container_create.go index ab70589..6d9db9c 100644 --- a/internal/verda-cli/cmd/serverless/container_create.go +++ b/internal/verda-cli/cmd/serverless/container_create.go @@ -28,9 +28,7 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) -// Queue-load preset constants. The CLI exposes four named profiles (Instant, -// Balanced, Cost saver, Custom); all four map to an integer threshold written -// into ScalingTriggers.QueueLoad. +// Queue-load presets map CLI names to ScalingTriggers.QueueLoad thresholds. const ( presetInstant = "instant" presetBalanced = "balanced" @@ -41,9 +39,7 @@ const ( queueLoadBalanced = 3 queueLoadCostSaver = 6 - // Auto-allocated general-storage mount. The server sizes it; the CLI - // only sends the mount path and "scratch" type. /dev/shm is provided - // by the runtime and the CLI does not send a mount for it. + // Server-provisioned scratch at /data (size omitted). Runtime supplies /dev/shm. defaultGeneralStoragePath = "/data" defaultExposedPort = 80 @@ -54,9 +50,7 @@ const ( defaultRequestTTL = 300 * time.Second ) -// containerCreateOptions collects every field the CreateDeploymentRequest needs. -// Flag parsing populates these; later (follow-up task) the wizard will fill -// remaining gaps. request() turns these into the SDK payload. +// containerCreateOptions holds flags/wizard state; request() builds the SDK payload. type containerCreateOptions struct { Name string Image string @@ -177,11 +171,7 @@ func newCmdContainerCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobr } func runContainerCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *containerCreateOptions) error { - client, err := f.VerdaClient() - if err != nil { - return err - } - + // 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) @@ -192,6 +182,11 @@ func runContainerCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil } } + client, err := f.VerdaClient() + if err != nil { + return err + } + req, err := opts.request() if err != nil { return cmdutil.UsageErrorf(cmd, "%v", err) @@ -199,7 +194,7 @@ func runContainerCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Request payload:", req) - // Interactive confirmation with summary. Agent mode relies on --yes. + // 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)) @@ -231,8 +226,7 @@ func runContainerCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil return nil } -// runContainerWizard drives the interactive 22-step create flow. Writes into -// opts in place; the caller then turns opts into a request via opts.request(). +// 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(), @@ -255,9 +249,7 @@ func missingContainerCreateFlags(opts *containerCreateOptions) []string { return missing } -// validate checks every option for well-formed values before the request is -// assembled. Split out from request() to keep cyclomatic complexity low — the -// cluster of range checks lives here, the assembly lives there. +// validate checks opts; request() performs assembly and SDK validation. func (o *containerCreateOptions) validate() error { if err := validateDeploymentName(o.Name); err != nil { return err @@ -289,8 +281,7 @@ func (o *containerCreateOptions) validate() error { return nil } -// request assembles a CreateDeploymentRequest from the options. Validation -// happens in validate(); assembly + the SDK's server-side-parity check live here. +// request builds CreateDeploymentRequest after validate(); runs ValidateCreateDeploymentRequest. func (o *containerCreateOptions) request() (*verda.CreateDeploymentRequest, error) { if err := o.validate(); err != nil { return nil, err @@ -407,10 +398,7 @@ func buildEnvVars(plain, secret []string) ([]verda.ContainerEnvVar, error) { return out, nil } -// buildVolumeMounts assembles the volume_mounts array sent to the API. Every -// deployment gets an auto-allocated scratch mount at /data — the server sizes -// it, the CLI just declares the mount. /dev/shm is provided by the runtime -// and is not sent as an explicit mount. Secret mounts come from --secret-mount. +// 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}, diff --git a/internal/verda-cli/cmd/serverless/container_create_test.go b/internal/verda-cli/cmd/serverless/container_create_test.go index aa1b1d1..5fe7ba4 100644 --- a/internal/verda-cli/cmd/serverless/container_create_test.go +++ b/internal/verda-cli/cmd/serverless/container_create_test.go @@ -15,14 +15,20 @@ 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 a containerCreateOptions that passes validate() — used as -// a baseline each test tweaks a single field on. +// validOpts returns defaults that satisfy validate(); tests tweak one field each. func validOpts() *containerCreateOptions { return &containerCreateOptions{ Name: "my-endpoint", @@ -80,8 +86,7 @@ func TestContainerRequest_HappyPath(t *testing.T) { if req.Scaling.ScalingTriggers.QueueLoad.Threshold != queueLoadBalanced { t.Errorf("balanced preset should map to %d, got %v", queueLoadBalanced, req.Scaling.ScalingTriggers.QueueLoad.Threshold) } - // One scratch mount at /data is always sent — the API allocates and - // sizes it server-side. /dev/shm is provided by the runtime. + // 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) } @@ -247,3 +252,29 @@ func TestContainerRequest_ValidationErrors(t *testing.T) { }) } } + +// 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_describe.go b/internal/verda-cli/cmd/serverless/container_describe.go index 7d60450..e8d9d00 100644 --- a/internal/verda-cli/cmd/serverless/container_describe.go +++ b/internal/verda-cli/cmd/serverless/container_describe.go @@ -23,6 +23,7 @@ import ( "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" ) @@ -44,9 +45,7 @@ func newCmdContainerDescribe(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *co return cmd } -// resolveContainerName returns the first positional arg when present; otherwise -// prompts interactively. Agent mode returns a MISSING_REQUIRED_FLAGS error when -// no name is supplied because prompts are blocked. +// 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 @@ -57,8 +56,7 @@ func resolveContainerName(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdut return selectContainerDeployment(cmd.Context(), f, ioStreams) } -// selectContainerDeployment loads all container deployments and runs a single-select -// picker. Returns "" (no error) when the user cancels or there are no deployments. +// 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 { @@ -90,7 +88,7 @@ func selectContainerDeployment(ctx context.Context, f cmdutil.Factory, ioStreams } labels = append(labels, "Cancel") - idx, err := f.Prompter().Select(ctx, "Select container deployment", labels) + idx, err := f.Prompter().Select(ctx, "Select container deployment", labels, tui.WithShowHints(true)) if err != nil { if isPromptCancel(err) { return "", nil @@ -119,7 +117,7 @@ func runContainerDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdut return fmt.Errorf("fetching deployment: %w", err) } - // Best-effort status fetch — don't fail the describe if status errors. + // Describe still succeeds if status RPC fails. var status string s, statusErr := client.ContainerDeployments.GetDeploymentStatus(ctx, name) if statusErr == nil && s != nil { @@ -128,11 +126,7 @@ func runContainerDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdut cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployment:", deployment) - // Embed promotes the SDK's fields plus our outer Status. encoding/json gives - // the shallowest field precedence, so if the SDK grows its own json:"status" - // the outer Status here still wins — but the embedded value would silently - // disappear from output. If the SDK ever exposes a Status on the deployment - // itself, drop the embed and enumerate fields explicitly here. + // 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"` diff --git a/internal/verda-cli/cmd/serverless/container_list.go b/internal/verda-cli/cmd/serverless/container_list.go index b388cef..cec61ea 100644 --- a/internal/verda-cli/cmd/serverless/container_list.go +++ b/internal/verda-cli/cmd/serverless/container_list.go @@ -19,14 +19,19 @@ import ( "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 } @@ -66,12 +71,7 @@ func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I statuses := newContainerStatusCache(containerStatusCacheTTL) deployments, err := cmdutil.WithSpinner(ctx, f.Status(), "Loading container deployments...", func() ([]verda.ContainerDeployment, error) { - deps, derr := client.ContainerDeployments.GetDeployments(ctx) - if derr != nil { - return nil, derr - } - statuses.refresh(ctx, client, deps) - return deps, nil + return client.ContainerDeployments.GetDeployments(ctx) }) if err != nil { return fmt.Errorf("fetching deployments: %w", err) @@ -79,7 +79,17 @@ func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployments:", deployments) - // Client-side status filter (API does not support it on the list endpoint). + 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] @@ -91,7 +101,6 @@ func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I deployments = filtered } - // Structured output (JSON/YAML): emit and return. if f.OutputFormat() != "table" { type row struct { *verda.ContainerDeployment @@ -111,8 +120,7 @@ func runContainerList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I return nil } - // Non-interactive table when piped, redirected, or in agent mode. - if !cmdutil.IsStdoutTerminal() || f.AgentMode() { + if !interactive { return printContainerTable(ioStreams.Out, deployments, statuses) } @@ -129,9 +137,71 @@ func runContainerListInteractive( 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)) + go pushContainerStatusUpdates(cmd.Context(), client, deployments, statuses, updates) + + idx, err := liveLister.LiveList(cmd.Context(), + "Select deployment (type to filter)", + rows, updates, + tui.WithLiveListShowHints(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 := 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(), "Refreshing statuses...", func() error { + _ = 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) @@ -145,9 +215,9 @@ func runContainerListInteractive( } labels = append(labels, "Exit") - idx, err := prompter.Select(cmd.Context(), "Select deployment (type to filter)", labels) + idx, err := prompter.Select(cmd.Context(), "Select deployment (type to filter)", labels, tui.WithShowHints(true)) if err != nil { - if isPromptCancel(err) { + if cmdutil.IsPromptCancel(err) { return nil } return err @@ -160,22 +230,98 @@ func runContainerListInteractive( _, _ = fmt.Fprintf(ioStreams.ErrOut, "Error: %v\n", derr) } - // Pause on the describe card until the user picks an explicit next step. - // Without this gate the loop re-enters Select immediately and the TUI - // redraw wipes the card. - nextIdx, nerr := prompter.Select(cmd.Context(), "", []string{"Back to list", "Exit"}) - if nerr != nil { - if isPromptCancel(nerr) { - return nil - } - return nerr + exit, perr := promptBackOrExit(cmd.Context(), prompter) + if perr != nil { + return perr } - if nextIdx == 1 { + 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() +} + +// promptBackOrExit: Esc → list; Ctrl+C exit (no confirm). +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 cmdutil.IsPromptInterrupt(nerr) { + return true, nil // Ctrl+C = exit + } + if cmdutil.IsPromptBack(nerr) { + return false, nil // Esc = back to list + } + return false, nerr + } + return nextIdx == 1, nil +} + 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") diff --git a/internal/verda-cli/cmd/serverless/container_status_cache.go b/internal/verda-cli/cmd/serverless/container_status_cache.go index 63d60bb..64b30d3 100644 --- a/internal/verda-cli/cmd/serverless/container_status_cache.go +++ b/internal/verda-cli/cmd/serverless/container_status_cache.go @@ -26,12 +26,10 @@ const ( containerStatusCacheTTL = 30 * time.Second containerStatusFetchConcurrency = 5 containerStatusUnknown = "-" + containerStatusLoading = "..." // placeholder until LiveList status RPC completes ) -// containerStatusCache holds deployment status strings keyed by name with a -// per-entry TTL. The API list endpoint does not include status, so the CLI -// fetches it per-deployment in parallel; the cache lets the interactive loop -// reuse those results across iterations without hammering the API. +// containerStatusCache: per-name status + TTL because list RPC omits status. type containerStatusCache struct { mu sync.Mutex entries map[string]containerStatusEntry @@ -84,9 +82,7 @@ func (c *containerStatusCache) anyStale(deployments []verda.ContainerDeployment) return false } -// refresh fetches status for any cache entries that are absent or stale, -// bounded by containerStatusFetchConcurrency. Fetch errors fall back to -// containerStatusUnknown so the row still renders. +// 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) diff --git a/internal/verda-cli/cmd/serverless/shared.go b/internal/verda-cli/cmd/serverless/shared.go index cadedb1..19fa9f7 100644 --- a/internal/verda-cli/cmd/serverless/shared.go +++ b/internal/verda-cli/cmd/serverless/shared.go @@ -28,10 +28,7 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) -// deploymentNameRE matches an RFC-1123 subset suitable for a public URL slug: -// lowercase alphanumerics and hyphens, must start and end with alphanumeric, -// max 63 characters. This is the contract the Verda backend enforces for -// deployment names (they become part of https://containers.datacrunch.io/). +// 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 @@ -48,9 +45,7 @@ func validateDeploymentName(name string) error { return nil } -// rejectLatestTag returns an error if the image reference uses the ':latest' -// tag (explicit or implicit). The API rejects latest-tagged deployments; we -// fail fast so users see a friendly error instead of a validation 400. +// 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) diff --git a/internal/verda-cli/cmd/serverless/wire_format_test.go b/internal/verda-cli/cmd/serverless/wire_format_test.go index 76607b5..76c9e6a 100644 --- a/internal/verda-cli/cmd/serverless/wire_format_test.go +++ b/internal/verda-cli/cmd/serverless/wire_format_test.go @@ -12,19 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. -// Wire-format tests for `verda container create` and `verda batchjob create`. -// -// HARD RULE: when you change the create flow — options struct, request() -// assembly, buildVolumeMounts/buildEnvVars/buildContainerScaling, mount-type -// constants, new flags that land in the payload — you MUST update the -// assertions in this file. These tests are the only layer that catches -// payload bugs the SDK's client-side validators miss (the production -// `volume_id`/mount-type 400 is the canonical example). -// -// Do NOT relax an assertion to make a failing test pass. If you cannot -// explain why the new payload is correct, the real API will reject it. -// See `CLAUDE.md` in this directory ("Wire-Format Tests Must Stay in Sync") -// for the full rule. +// Wire-format regression tests for create payloads; change assertions whenever +// request assembly changes (see cmd/serverless/CLAUDE.md). package serverless @@ -43,12 +32,7 @@ import ( cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util" ) -// recordingServer captures the JSON body posted to deployment-create endpoints -// so tests can assert on the exact wire format the CLI sends to the API. This -// is the layer where the volume_id / mount-type bug lived — opts.request() and -// the SDK's client-side validators both accepted the bad payload; only the -// real server rejected it. A wire-format test catches it without spending -// money on a live deployment. +// recordingServer stores POST bodies from create endpoints for JSON assertions. type recordingServer struct { mu sync.Mutex containerOK []byte 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/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 4f2254b..7878f54 100644 --- a/internal/verda-cli/cmd/util/factory.go +++ b/internal/verda-cli/cmd/util/factory.go @@ -23,6 +23,7 @@ import ( "regexp" "sort" "strings" + "time" "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" "github.com/verda-cloud/verdagostack/pkg/tui" @@ -32,6 +33,16 @@ 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.). var sensitiveJSONFieldRe = regexp.MustCompile( @@ -216,6 +227,15 @@ func (f *factoryImpl) VerdaClient() (*verda.Client, error) { } client, err := verda.NewClient(options...) + if err == nil { + // 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. + client.AddRequestMiddleware(verda.ExponentialBackoffRetryMiddleware( + retryMaxAttempts, retryInitialDelay, client.Logger, + )) + } if err != nil { return nil, err } diff --git a/internal/verda-cli/cmd/util/helpers.go b/internal/verda-cli/cmd/util/helpers.go index c0ab022..1c8f416 100644 --- a/internal/verda-cli/cmd/util/helpers.go +++ b/internal/verda-cli/cmd/util/helpers.go @@ -15,15 +15,46 @@ 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) +} + // 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/versionhint.go b/internal/verda-cli/cmd/util/versionhint.go index 00e3380..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,11 +122,8 @@ func FetchLatestVersion(ctx context.Context) (string, error) { return release.TagName, nil } -// CheckVersionFromCache returns the cached latest version and the current -// version without ever touching the network. Used by hot paths like `verda` -// and `verda --help` where blocking on a GitHub fetch would visibly slow the -// CLI. If the cache is empty or unreadable, latest is "" and the caller will -// simply print no hint. +// 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 { @@ -172,7 +166,7 @@ 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 = currentVersion() 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 52a0322..7363b1f 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,19 +143,31 @@ func runList(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, volumes := fetchInstanceVolumes(cmd.Context(), client, inst) _, _ = fmt.Fprint(ioStreams.Out, renderInstanceCard(inst, volumes...)) - // Pause on the instance card until the user picks an explicit next step. - // Without this gate the loop re-enters Select immediately and the TUI - // redraw wipes the card. - nextIdx, nerr := prompter.Select(cmd.Context(), "", []string{"Back to list", "Exit"}) - if nerr != nil { - return nil + exit, perr := promptBackOrExit(cmd.Context(), prompter) + if perr != nil { + return perr } - if nextIdx == 1 { + if exit { return nil } } } +// promptBackOrExit: Esc returns to the list; Ctrl+C exits without confirmation. +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 cmdutil.IsPromptInterrupt(nerr) { + return true, nil // Ctrl+C = exit + } + if cmdutil.IsPromptBack(nerr) { + return false, nil // Esc = back to list + } + return false, nerr + } + return nextIdx == 1, nil +} + // fetchInstanceVolumes fetches volume details for an instance's attached volumes. func fetchInstanceVolumes(ctx context.Context, client *verda.Client, inst *verda.Instance) []verda.Volume { ids := cmdutil.UniqueVolumeIDs(inst) 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/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..1a7703b 100644 --- a/internal/verda-cli/cmd/volume/create.go +++ b/internal/verda-cli/cmd/volume/create.go @@ -110,7 +110,7 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream 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}) + idx, err := prompter.Select(ctx, "Volume type", []string{nvmeLabel, hddLabel}, tui.WithShowHints(true)) if err != nil { return nil } @@ -161,7 +161,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 } From d92b4a6c0041a5eaad129057340ed016d1dcdee2 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 11:26:26 +0300 Subject: [PATCH 05/26] chore(deps): bump verdagostack to v1.4.1, drop local replace v1.4.1 ships the live-list panic guard + stale wizard hint-bar refresh, so the temporary ../verdagostack replace is no longer needed. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 14 +++----------- go.sum | 6 ++++-- 2 files changed, 7 insertions(+), 13 deletions(-) diff --git a/go.mod b/go.mod index 8391baf..4312b4a 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 ) @@ -100,13 +100,5 @@ require ( 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/text v0.36.0 // indirect ) - -// TEMPORARY local replace — required while verdagostack hosts the new -// SelectConfig hint-bar fields (WithShowHints) and the LiveList primitive -// in its working tree but has not yet cut a release. Drop this directive -// and bump the require line above once verdagostack publishes the version -// containing both APIs. The branch will not build on a fresh machine -// without ../verdagostack checked out and up-to-date. -replace github.com/verda-cloud/verdagostack => ../verdagostack diff --git a/go.sum b/go.sum index f9ded78..c602f8a 100644 --- a/go.sum +++ b/go.sum @@ -189,6 +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.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,8 +225,8 @@ 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/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= From 413aa18fd8f38c2b66f0f2ca2350992294537350 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 11:29:25 +0300 Subject: [PATCH 06/26] fix: adversarial-review fixes across factory, cmdutil, serverless - factory: propagate debug body-read errors; fix redaction regex for escaped quotes; standard early-return around retry middleware - cmdutil: hoist shared PromptBackOrExit (used by vm/list + serverless container_list) - serverless: bound best-effort describe status RPC with a sub-timeout under the spinner; cancel stale live-list status goroutines; collapse duplicate isPromptCancel; clarify env-var help Co-Authored-By: Claude Opus 4.8 (1M context) --- .../cmd/serverless/batchjob_create.go | 4 +- .../cmd/serverless/batchjob_describe.go | 20 +++++--- .../cmd/serverless/container_create.go | 4 +- .../cmd/serverless/container_describe.go | 22 +++++---- .../cmd/serverless/container_list.go | 23 ++------- .../cmd/serverless/container_status_cache.go | 4 ++ internal/verda-cli/cmd/serverless/shared.go | 9 ++-- internal/verda-cli/cmd/util/factory.go | 47 +++++++++++++------ internal/verda-cli/cmd/util/helpers.go | 18 +++++++ internal/verda-cli/cmd/vm/list.go | 17 +------ 10 files changed, 96 insertions(+), 72 deletions(-) diff --git a/internal/verda-cli/cmd/serverless/batchjob_create.go b/internal/verda-cli/cmd/serverless/batchjob_create.go index 6b12baf..423dbb3 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_create.go +++ b/internal/verda-cli/cmd/serverless/batchjob_create.go @@ -88,8 +88,8 @@ func newCmdBatchjobCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra 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; repeat for multiple") - flags.StringArrayVar(&opts.EnvSecret, "env-secret", nil, "Secret-backed env KEY=SECRET_NAME; repeat for multiple") + 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") diff --git a/internal/verda-cli/cmd/serverless/batchjob_describe.go b/internal/verda-cli/cmd/serverless/batchjob_describe.go index ef17b84..ba09f5a 100644 --- a/internal/verda-cli/cmd/serverless/batchjob_describe.go +++ b/internal/verda-cli/cmd/serverless/batchjob_describe.go @@ -108,18 +108,26 @@ func runBatchjobDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmduti 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) { - return client.ServerlessJobs.GetJobDeploymentByName(ctx, name) + 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) } - var status string - if s, statusErr := client.ServerlessJobs.GetJobDeploymentStatus(ctx, name); statusErr == nil && s != nil { - status = s.Status - } - cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployment:", job) // Same embedded Status caveat as container describe (see container_describe.go). diff --git a/internal/verda-cli/cmd/serverless/container_create.go b/internal/verda-cli/cmd/serverless/container_create.go index 6d9db9c..a137fb3 100644 --- a/internal/verda-cli/cmd/serverless/container_create.go +++ b/internal/verda-cli/cmd/serverless/container_create.go @@ -147,8 +147,8 @@ func newCmdContainerCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobr 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; repeat for multiple") - flags.StringArrayVar(&opts.EnvSecret, "env-secret", nil, "Secret-backed env KEY=SECRET_NAME; repeat for multiple") + 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") diff --git a/internal/verda-cli/cmd/serverless/container_describe.go b/internal/verda-cli/cmd/serverless/container_describe.go index e8d9d00..372c29e 100644 --- a/internal/verda-cli/cmd/serverless/container_describe.go +++ b/internal/verda-cli/cmd/serverless/container_describe.go @@ -110,20 +110,26 @@ func runContainerDescribe(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdut 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) { - return client.ContainerDeployments.GetDeploymentByName(ctx, name) + 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) } - // Describe still succeeds if status RPC fails. - var status string - s, statusErr := client.ContainerDeployments.GetDeploymentStatus(ctx, name) - if statusErr == nil && s != nil { - status = s.Status - } - cmdutil.DebugJSON(ioStreams.ErrOut, f.Debug(), "Deployment:", deployment) // Embed adds Status alongside SDK fields; if SDK gains json:"status", switch to explicit fields. diff --git a/internal/verda-cli/cmd/serverless/container_list.go b/internal/verda-cli/cmd/serverless/container_list.go index cec61ea..bd595e9 100644 --- a/internal/verda-cli/cmd/serverless/container_list.go +++ b/internal/verda-cli/cmd/serverless/container_list.go @@ -158,13 +158,15 @@ func runContainerListLive( for { rows := buildContainerLiveRows(deployments, statuses) updates := make(chan tui.LiveListUpdate, len(deployments)) - go pushContainerStatusUpdates(cmd.Context(), client, deployments, statuses, updates) + 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 @@ -179,7 +181,7 @@ func runContainerListLive( _, _ = fmt.Fprintf(ioStreams.ErrOut, "Error: %v\n", derr) } - exit, perr := promptBackOrExit(cmd.Context(), prompter) + exit, perr := cmdutil.PromptBackOrExit(cmd.Context(), prompter) if perr != nil { return perr } @@ -230,7 +232,7 @@ func runContainerListEager( _, _ = fmt.Fprintf(ioStreams.ErrOut, "Error: %v\n", derr) } - exit, perr := promptBackOrExit(cmd.Context(), prompter) + exit, perr := cmdutil.PromptBackOrExit(cmd.Context(), prompter) if perr != nil { return perr } @@ -307,21 +309,6 @@ func pushContainerStatusUpdates( wg.Wait() } -// promptBackOrExit: Esc → list; Ctrl+C exit (no confirm). -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 cmdutil.IsPromptInterrupt(nerr) { - return true, nil // Ctrl+C = exit - } - if cmdutil.IsPromptBack(nerr) { - return false, nil // Esc = back to list - } - return false, nerr - } - return nextIdx == 1, nil -} - 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") diff --git a/internal/verda-cli/cmd/serverless/container_status_cache.go b/internal/verda-cli/cmd/serverless/container_status_cache.go index 64b30d3..44c7abf 100644 --- a/internal/verda-cli/cmd/serverless/container_status_cache.go +++ b/internal/verda-cli/cmd/serverless/container_status_cache.go @@ -27,6 +27,10 @@ const ( 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. diff --git a/internal/verda-cli/cmd/serverless/shared.go b/internal/verda-cli/cmd/serverless/shared.go index 19fa9f7..86b0382 100644 --- a/internal/verda-cli/cmd/serverless/shared.go +++ b/internal/verda-cli/cmd/serverless/shared.go @@ -109,12 +109,11 @@ const ( envTypeSecret = "secret" ) -// isPromptCancel reports whether err represents a clean prompter exit rather -// than a real failure. Ctrl+C surfaces as tui.ErrInterrupted, Esc as -// context.Canceled. Anything else (I/O errors, terminal disconnects, real -// context deadlines) should propagate so the failure isn't invisible. +// 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 errors.Is(err, tui.ErrInterrupted) || errors.Is(err, context.Canceled) + return cmdutil.IsPromptCancel(err) } // confirmDestructive renders a red-bold warning line and prompts the user to diff --git a/internal/verda-cli/cmd/util/factory.go b/internal/verda-cli/cmd/util/factory.go index 7878f54..45083d7 100644 --- a/internal/verda-cli/cmd/util/factory.go +++ b/internal/verda-cli/cmd/util/factory.go @@ -45,8 +45,11 @@ const ( // sensitiveJSONFieldRe matches "field": "value" JSON entries whose values must // not appear in debug output (OAuth credentials, bearer tokens, etc.). +// Value pattern allows escaped quotes (\") so values containing them are +// redacted whole — a bare [^"]* would stop at the first escaped quote and +// leak the remainder while emitting malformed JSON. var sensitiveJSONFieldRe = regexp.MustCompile( - `("(?:client_secret|access_token|refresh_token|id_token|password|api_key|bearer|authorization)")(\s*:\s*)"[^"]*"`) + `("(?: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""`) @@ -121,11 +124,15 @@ func (t *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { var reqBody []byte if req.Body != nil { - b, err := io.ReadAll(req.Body) + b, rerr := io.ReadAll(req.Body) _ = req.Body.Close() - if err == nil { - reqBody = b + 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)) } @@ -156,9 +163,14 @@ func (t *debugTransport) RoundTrip(req *http.Request) (*http.Response, error) { if resp.Body != nil { b, rerr := io.ReadAll(resp.Body) _ = resp.Body.Close() - if rerr == nil { - respBody = b + 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) @@ -227,19 +239,24 @@ func (f *factoryImpl) VerdaClient() (*verda.Client, error) { } client, err := verda.NewClient(options...) - if err == nil { - // 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. - client.AddRequestMiddleware(verda.ExponentialBackoffRetryMiddleware( - retryMaxAttempts, retryInitialDelay, client.Logger, - )) - } if err != nil { 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 1c8f416..b7240f8 100644 --- a/internal/verda-cli/cmd/util/helpers.go +++ b/internal/verda-cli/cmd/util/helpers.go @@ -55,6 +55,24 @@ 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/vm/list.go b/internal/verda-cli/cmd/vm/list.go index 7363b1f..d855187 100644 --- a/internal/verda-cli/cmd/vm/list.go +++ b/internal/verda-cli/cmd/vm/list.go @@ -143,7 +143,7 @@ func runListInteractive( volumes := fetchInstanceVolumes(cmd.Context(), client, inst) _, _ = fmt.Fprint(ioStreams.Out, renderInstanceCard(inst, volumes...)) - exit, perr := promptBackOrExit(cmd.Context(), prompter) + exit, perr := cmdutil.PromptBackOrExit(cmd.Context(), prompter) if perr != nil { return perr } @@ -153,21 +153,6 @@ func runListInteractive( } } -// promptBackOrExit: Esc returns to the list; Ctrl+C exits without confirmation. -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 cmdutil.IsPromptInterrupt(nerr) { - return true, nil // Ctrl+C = exit - } - if cmdutil.IsPromptBack(nerr) { - return false, nil // Esc = back to list - } - return false, nerr - } - return nextIdx == 1, nil -} - // fetchInstanceVolumes fetches volume details for an instance's attached volumes. func fetchInstanceVolumes(ctx context.Context, client *verda.Client, inst *verda.Instance) []verda.Volume { ids := cmdutil.UniqueVolumeIDs(inst) From 2fae3d1df614a7c1be8111d1a96ef7d1606a79c1 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 11:29:26 +0300 Subject: [PATCH 07/26] feat: remove deprecated HDD storage option from vm/volume create HDD is deprecated and no longer provisionable. Drop it from the vm and volume create wizards (default NVMe); --type/--storage-type still pass through. Display of existing HDD volumes unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/vm/wizard.go | 2 +- internal/verda-cli/cmd/vm/wizard_subflows.go | 29 ++------------------ internal/verda-cli/cmd/volume/README.md | 2 +- internal/verda-cli/cmd/volume/create.go | 25 ++++------------- 4 files changed, 10 insertions(+), 48 deletions(-) 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/create.go b/internal/verda-cli/cmd/volume/create.go index 1a7703b..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}, tui.WithShowHints(true)) - 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. From f5162ee37e9aa6624a8d7930d00215cb1bbe73b3 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 11:29:26 +0300 Subject: [PATCH 08/26] feat: register s3 unconditionally (GA); gate serverless pre-release - s3 always registered now (object storage shipped to prod); VERDA_S3_ENABLED gate removed - container/batchjob gated behind VERDA_SERVERLESS_ENABLED + Hidden until GA (serverlessEnabled in cmd.go; Hidden on parents; gate_test asserts it) Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/cmd.go | 38 +++++++++++-------- internal/verda-cli/cmd/serverless/batchjob.go | 3 ++ .../verda-cli/cmd/serverless/container.go | 5 +++ .../verda-cli/cmd/serverless/gate_test.go | 37 ++++++++++++++++++ 4 files changed, 67 insertions(+), 16 deletions(-) create mode 100644 internal/verda-cli/cmd/serverless/gate_test.go diff --git a/internal/verda-cli/cmd/cmd.go b/internal/verda-cli/cmd/cmd.go index d7b4a94..a5e82a8 100644 --- a/internal/verda-cli/cmd/cmd.go +++ b/internal/verda-cli/cmd/cmd.go @@ -135,14 +135,12 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op 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)) } @@ -161,32 +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,18 +205,22 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op return cmd, opts } -// s3Enabled hides the S3 subtree unless VERDA_S3_ENABLED is 1/true (pre-GA). -func s3Enabled() bool { - v := os.Getenv("VERDA_S3_ENABLED") - return v == "1" || v == "true" -} - // 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" } +// 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" +} + // skipCredentialResolution returns true for commands that should work // without valid credentials (diagnostics, profile switching, etc.). func skipCredentialResolution(cmd *cobra.Command) bool { diff --git a/internal/verda-cli/cmd/serverless/batchjob.go b/internal/verda-cli/cmd/serverless/batchjob.go index c97feb1..945b788 100644 --- a/internal/verda-cli/cmd/serverless/batchjob.go +++ b/internal/verda-cli/cmd/serverless/batchjob.go @@ -25,6 +25,9 @@ func NewCmdBatchjob(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comma 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 diff --git a/internal/verda-cli/cmd/serverless/container.go b/internal/verda-cli/cmd/serverless/container.go index a0e2634..16d5397 100644 --- a/internal/verda-cli/cmd/serverless/container.go +++ b/internal/verda-cli/cmd/serverless/container.go @@ -25,6 +25,11 @@ func NewCmdContainer(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comm 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 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") + } +} From 389d2cfe194aea299cbd1ee6e1f038405f2d5b09 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 11:29:26 +0300 Subject: [PATCH 09/26] feat(s3): resumable multipart upload, interactive pickers, profile fix - resumable multipart upload with local checkpoints (~/.verda/s3-uploads), --part-size/--concurrency/--no-resume, ls-uploads/abort-uploads cleanup - RGW checksum fix: disable aws-sdk default integrity trailers on client + manager + custom uploader (else 400 XAmzContentSHA256Mismatch) - interactive bucket picker (selectBucket) wired into rb/ls-uploads/abort-uploads via dual-mode resolveBucketArg (omit target on a TTY -> picker) - honor 'verda auth use' active profile in s3 (options.ActiveProfile); previously s3 ignored it and used [default] - complete s3 GA (unhide); expanded test coverage (show, recursive cp/mv, interactive rm) Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/README.md | 2 - internal/verda-cli/cmd/s3/abortuploads.go | 237 ++++++++ internal/verda-cli/cmd/s3/checkpoint.go | 198 +++++++ internal/verda-cli/cmd/s3/checkpoint_test.go | 191 +++++++ internal/verda-cli/cmd/s3/client.go | 12 + internal/verda-cli/cmd/s3/cp.go | 106 ++++ internal/verda-cli/cmd/s3/cp_resume_test.go | 218 +++++++ internal/verda-cli/cmd/s3/cp_test.go | 52 ++ internal/verda-cli/cmd/s3/errors.go | 17 + internal/verda-cli/cmd/s3/helper.go | 26 +- internal/verda-cli/cmd/s3/lsuploads.go | 239 ++++++++ internal/verda-cli/cmd/s3/mpupload.go | 405 +++++++++++++ internal/verda-cli/cmd/s3/mpupload_test.go | 562 +++++++++++++++++++ internal/verda-cli/cmd/s3/mv_test.go | 183 ++++++ internal/verda-cli/cmd/s3/picker.go | 92 +++ internal/verda-cli/cmd/s3/picker_test.go | 84 +++ internal/verda-cli/cmd/s3/rb.go | 10 +- internal/verda-cli/cmd/s3/rm_test.go | 99 ++++ internal/verda-cli/cmd/s3/s3.go | 7 +- internal/verda-cli/cmd/s3/show.go | 2 +- internal/verda-cli/cmd/s3/show_test.go | 149 +++++ internal/verda-cli/cmd/s3/transfer.go | 75 ++- internal/verda-cli/cmd/s3/uploads_test.go | 451 +++++++++++++++ internal/verda-cli/options/options.go | 16 + internal/verda-cli/options/options_test.go | 39 ++ 25 files changed, 3458 insertions(+), 14 deletions(-) create mode 100644 internal/verda-cli/cmd/s3/abortuploads.go create mode 100644 internal/verda-cli/cmd/s3/checkpoint.go create mode 100644 internal/verda-cli/cmd/s3/checkpoint_test.go create mode 100644 internal/verda-cli/cmd/s3/cp_resume_test.go create mode 100644 internal/verda-cli/cmd/s3/lsuploads.go create mode 100644 internal/verda-cli/cmd/s3/mpupload.go create mode 100644 internal/verda-cli/cmd/s3/mpupload_test.go create mode 100644 internal/verda-cli/cmd/s3/picker.go create mode 100644 internal/verda-cli/cmd/s3/picker_test.go create mode 100644 internal/verda-cli/cmd/s3/show_test.go create mode 100644 internal/verda-cli/cmd/s3/uploads_test.go diff --git a/internal/verda-cli/cmd/s3/README.md b/internal/verda-cli/cmd/s3/README.md index 80e9c8e..734a066 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 | 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/checkpoint.go b/internal/verda-cli/cmd/s3/checkpoint.go new file mode 100644 index 0000000..f41c63f --- /dev/null +++ b/internal/verda-cli/cmd/s3/checkpoint.go @@ -0,0 +1,198 @@ +// 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 checkpoint files 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 gcCheckpoints(maxAge time.Duration) error { + if maxAge <= 0 { + maxAge = checkpointMaxAge + } + dir, err := checkpointDir() + if err != nil { + return err + } + 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..d7df8e8 100644 --- a/internal/verda-cli/cmd/s3/cp.go +++ b/internal/verda-cli/cmd/s3/cp.go @@ -66,6 +66,9 @@ type cpOptions struct { Exclude []string Dryrun bool ContentType string + PartSize string + Concurrency int + NoResume bool } // transferEntry is the structured shape for a single completed (or previewed) @@ -140,6 +143,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", "", "Multipart part size for large uploads, e.g. 32MiB (default auto)") + flags.IntVar(&opts.Concurrency, "concurrency", defaultConcurrency, "Parallel part uploads for large files") + flags.BoolVar(&opts.NoResume, "no-resume", false, "Ignore any checkpoint and restart the upload from scratch") return cmd } @@ -194,6 +200,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 +236,92 @@ 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 + } + key := singleTargetKey(dst.Key, filepath.Base(src)) + rel := filepath.Base(src) + + client, err := buildClient(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + + ropts := resumableOptions{ + AbsPath: absPath, + Bucket: dst.Bucket, + Key: key, + ContentType: inferContentType(absPath, opts.ContentType), + FileSize: info.Size(), + MTime: info.ModTime(), + PartSize: flagPartSize, + Concurrency: opts.Concurrency, + NoResume: opts.NoResume, + } + + announceResume(ctx, f, ioStreams, client, &ropts) + + var sp interface{ Stop(string) } + if status := f.Status(); status != nil { + sp, _ = status.Spinner(ctx, fmt.Sprintf("Uploading %s...", rel)) + } + started := time.Now() + err = resumableUpload(ctx, client, &ropts) + if sp != nil { + sp.Stop("") + } + if err != nil { + return err + } + elapsed := time.Since(started) + + payload := newCpPayload(false) + payload.Transfers = append(payload.Transfers, transferEntry{ + Source: absPath, + Destination: URI{Bucket: dst.Bucket, Key: key}.String(), + Bytes: info.Size(), + DurationMs: elapsed.Milliseconds(), + Status: "ok", + }) + if !isStructured(f.OutputFormat()) { + _, _ = fmt.Fprintf(ioStreams.Out, "✓ uploaded %s (%s)\n", rel, humanBytes(info.Size())) + } + 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 { 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..9ae86c3 100644 --- a/internal/verda-cli/cmd/s3/cp_test.go +++ b/internal/verda-cli/cmd/s3/cp_test.go @@ -443,6 +443,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..3c63b75 100644 --- a/internal/verda-cli/cmd/s3/errors.go +++ b/internal/verda-cli/cmd/s3/errors.go @@ -42,6 +42,8 @@ func translateError(err error) error { return cmdutil.NewAuthError(apiErr.ErrorMessage()) case "BucketAlreadyOwnedByYou", "BucketAlreadyExists": return cmdutil.NewValidationError("bucket", apiErr.ErrorMessage()) + case "NoSuchUpload": + return cmdutil.NewNotFoundError("upload", apiErr.ErrorMessage()) } } @@ -52,6 +54,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/lsuploads.go b/internal/verda-cli/cmd/s3/lsuploads.go new file mode 100644 index 0000000..c0333cb --- /dev/null +++ b/internal/verda-cli/cmd/s3/lsuploads.go @@ -0,0 +1,239 @@ +// 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) + 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/mpupload.go b/internal/verda-cli/cmd/s3/mpupload.go new file mode 100644 index 0000000..9d6c8ef --- /dev/null +++ b/internal/verda-cli/cmd/s3/mpupload.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 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 +} + +// 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) +} + +// 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) + + 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 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() + } + if existing.FileSize != opts.FileSize || !existing.MTime.Equal(opts.MTime) { + 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 + 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() + } + } + 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..a65a7f2 --- /dev/null +++ b/internal/verda-cli/cmd/s3/mpupload_test.go @@ -0,0 +1,562 @@ +// 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) + 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) + } +} + +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) + 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_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..850069d --- /dev/null +++ b/internal/verda-cli/cmd/s3/picker.go @@ -0,0 +1,92 @@ +// 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/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" +) + +// 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"}) + } + if !cmdutil.IsStdoutTerminal() { + 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 +} 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..772a436 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) }, } 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/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/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/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) + } + }) +} From 959db3803d97ca30bae1398d68fc7d6124c4a4d7 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 17:41:06 +0300 Subject: [PATCH 10/26] feat(s3): interactive ls browser with multi-select download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `verda s3 ls` with no arg on a terminal opens a navigable explorer: buckets -> folders -> objects (one delimiter level at a time), Esc=up / Ctrl+C=exit, per-object Download/Info/Delete, plus a 'Download files here…' multi-select. Explicit args, --agent, pipes and -o json keep the static, scriptable listing. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/browse.go | 373 +++++++++++++++++++++++ internal/verda-cli/cmd/s3/browse_test.go | 153 ++++++++++ internal/verda-cli/cmd/s3/ls.go | 5 + 3 files changed, 531 insertions(+) create mode 100644 internal/verda-cli/cmd/s3/browse.go create mode 100644 internal/verda-cli/cmd/s3/browse_test.go diff --git a/internal/verda-cli/cmd/s3/browse.go b/internal/verda-cli/cmd/s3/browse.go new file mode 100644 index 0000000..4d2302f --- /dev/null +++ b/internal/verda-cli/cmd/s3/browse.go @@ -0,0 +1,373 @@ +// 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" + "path/filepath" + "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 +) + +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 { + 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) (string, bool, 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: + if err := browseDownloadMulti(ctx, f, ioStreams, *cur, payload); err != nil { + return false, err + } + case rowObject: + if err := objectActionMenu(ctx, f, ioStreams, client, URI{Bucket: cur.Bucket, Key: row.value}, row.size); err != nil { + return false, err + } + } + 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). +func objectActionMenu(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, obj URI, size int64) 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 nil + } + return err + } + switch idx { + case actDownload: + return browseDownload(ctx, f, ioStreams, obj) + case actInfo: + return browseInfo(ctx, f, ioStreams, client, obj) + case actDelete: + return browseDelete(ctx, f, ioStreams, client, obj) + default: + return nil + } +} + +// browseDownload streams one object to ./ via the transfer manager. +func browseDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, obj URI) error { + tr, err := transporterBuilder(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + local, n, err := downloadObjectTo(ctx, tr, obj) + if err != nil { + return err + } + _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)\n", obj.String(), local, humanBytes(n)) + return nil +} + +// downloadObjectTo streams obj to ./ and returns the local path + byte +// count. Shared by the single- and multi-file browser download paths. +func downloadObjectTo(ctx context.Context, tr Transporter, obj URI) (string, int64, error) { + local := filepath.Base(obj.Key) + out, err := os.Create(local) // #nosec G304 -- destination is the object basename in the cwd + if err != nil { + return "", 0, fmt.Errorf("create local file: %w", err) + } + defer func() { _ = out.Close() }() + + n, err := tr.Download(ctx, out, &s3.GetObjectInput{Bucket: aws.String(obj.Bucket), Key: aws.String(obj.Key)}) + if err != nil { + return "", 0, translateError(err) + } + return local, n, nil +} + +// browseDownloadMulti multi-selects objects at the current level and downloads +// the ticked set to the cwd. Objects only (folders are not selectable in v1); +// non-destructive, so no confirmation. +func browseDownloadMulti(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, cur URI, payload objectsPayload) error { + var objs []objectEntry + var labels []string + 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 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 nil + } + return err + } + if len(idxs) == 0 { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Nothing selected.") + return nil + } + + tr, err := transporterBuilder(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + var total int64 + for _, ix := range idxs { + obj := URI{Bucket: cur.Bucket, Key: objs[ix].Key} + local, n, derr := downloadObjectTo(ctx, tr, obj) + if derr != nil { + return fmt.Errorf("downloading %s: %w", obj.String(), derr) + } + total += n + _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)\n", obj.String(), local, humanBytes(n)) + } + _, _ = fmt.Fprintf(ioStreams.Out, "Downloaded %d file(s), %s total\n", len(idxs), humanBytes(total)) + return nil +} + +// 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..531015c --- /dev/null +++ b/internal/verda-cli/cmd/s3/browse_test.go @@ -0,0 +1,153 @@ +// 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" + "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 +} + +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 +} + +// 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/transporter/cwd state + t.Chdir(t.TempDir()) // isolate the cwd that downloads write into + + fake := &browseFakeAPI{} + fakeT := &cpFakeTransporter{downloadWrite: []byte("XYZ")} + restoreT := withFakeTransporter(fakeT) + defer restoreT() + + // Selects: bucket(0) -> folder data/(1) -> Download-files-here(1) -> Exit(3) + // MultiSelect: tick the single object [0]. + mock := tuitest.New(). + AddSelect(0).AddSelect(1).AddSelect(1).AddSelect(3). + 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) + } + + if len(fakeT.downloads) != 1 { + t.Fatalf("Download calls = %d, want 1", len(fakeT.downloads)) + } + if k := aws.ToString(fakeT.downloads[0].Key); k != "data/file.txt" { + t.Errorf("downloaded key = %q, want data/file.txt", k) + } + if !strings.Contains(out.String(), "Downloaded 1 file(s)") { + t.Errorf("stdout missing multi-download summary:\n%s", out.String()) + } +} + +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/ls.go b/internal/verda-cli/cmd/s3/ls.go index 6e7623c..2cffbd3 100644 --- a/internal/verda-cli/cmd/s3/ls.go +++ b/internal/verda-cli/cmd/s3/ls.go @@ -116,6 +116,11 @@ func runLs(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o } if len(args) == 0 { + // No bucket on an interactive terminal -> the TUI explorer; otherwise + // (pipes, --agent, -o json) the static, scriptable bucket list. + if cmdutil.IsStdoutTerminal() && !f.AgentMode() && f.OutputFormat() == "table" { + return runLsBrowser(ctx, f, ioStreams, client) + } return runLsBuckets(ctx, f, ioStreams, client) } From a8a9c943f78fae8862c2be0e77f076cffe8b7c97 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 17:41:06 +0300 Subject: [PATCH 11/26] feat(s3): interactive upload wizard, resume picker, same-host lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upload wizard: `verda s3 cp` (no/partial args on a TTY) guides source -> bucket (+create) -> folder (+new) -> confirm; auto --recursive for directories; Esc steps back, Ctrl+C exits; confirm defaults to Yes - ls-uploads: pick an in-progress upload to resume — uses a matching local checkpoint or prompts for the file, infers the original part size, and adopts the existing UploadId (no new upload/orphan) - same-host flock guard (~/.verda/s3-uploads/.lock) so two cp/resume of the same object can't race the checkpoint - part-level progress bar for large/resumable uploads - bulk transfers run on cmd.Context(), not the 30s --timeout, so large up/downloads no longer fail with 'context deadline exceeded' Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/cp.go | 65 ++-- internal/verda-cli/cmd/s3/lock.go | 57 ++++ internal/verda-cli/cmd/s3/lock_test.go | 43 +++ internal/verda-cli/cmd/s3/lock_windows.go | 23 ++ internal/verda-cli/cmd/s3/lsuploads.go | 7 + internal/verda-cli/cmd/s3/mpupload.go | 24 ++ internal/verda-cli/cmd/s3/resume_uploads.go | 210 ++++++++++++ .../verda-cli/cmd/s3/resume_uploads_test.go | 131 ++++++++ internal/verda-cli/cmd/s3/upload_wizard.go | 316 ++++++++++++++++++ .../verda-cli/cmd/s3/upload_wizard_test.go | 135 ++++++++ 10 files changed, 992 insertions(+), 19 deletions(-) create mode 100644 internal/verda-cli/cmd/s3/lock.go create mode 100644 internal/verda-cli/cmd/s3/lock_test.go create mode 100644 internal/verda-cli/cmd/s3/lock_windows.go create mode 100644 internal/verda-cli/cmd/s3/resume_uploads.go create mode 100644 internal/verda-cli/cmd/s3/resume_uploads_test.go create mode 100644 internal/verda-cli/cmd/s3/upload_wizard.go create mode 100644 internal/verda-cli/cmd/s3/upload_wizard_test.go diff --git a/internal/verda-cli/cmd/s3/cp.go b/internal/verda-cli/cmd/s3/cp.go index d7df8e8..36bde30 100644 --- a/internal/verda-cli/cmd/s3/cp.go +++ b/internal/verda-cli/cmd/s3/cp.go @@ -131,9 +131,19 @@ func NewCmdCp(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { # 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 := cmdutil.IsStdoutTerminal() && !f.AgentMode() && f.OutputFormat() == "table" + 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 ") }, } @@ -156,8 +166,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: @@ -245,18 +258,14 @@ func runResumableUpload(ctx context.Context, f cmdutil.Factory, ioStreams cmduti if err != nil { return err } - key := singleTargetKey(dst.Key, filepath.Base(src)) - rel := filepath.Base(src) - client, err := buildClient(ctx, f, ClientOverrides{}) if err != nil { return err } - ropts := resumableOptions{ AbsPath: absPath, Bucket: dst.Bucket, - Key: key, + Key: singleTargetKey(dst.Key, filepath.Base(src)), ContentType: inferContentType(absPath, opts.ContentType), FileSize: info.Size(), MTime: info.ModTime(), @@ -264,17 +273,35 @@ func runResumableUpload(ctx context.Context, f cmdutil.Factory, ioStreams cmduti Concurrency: opts.Concurrency, NoResume: opts.NoResume, } + return runResumable(ctx, f, ioStreams, client, &ropts, filepath.Base(src)) +} - announceResume(ctx, f, ioStreams, client, &ropts) +// 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) - var sp interface{ Stop(string) } + // A part-level progress bar (table output only); SetPercent is driven from + // the uploader's serialized result loop, so it's race-free. + var prog interface { + SetPercent(float64) + Stop(string) + } if status := f.Status(); status != nil { - sp, _ = status.Spinner(ctx, fmt.Sprintf("Uploading %s...", rel)) + if ph, perr := status.Progress(ctx, fmt.Sprintf("Uploading %s", displayName)); perr == nil { + prog = ph + ropts.OnProgress = func(done, total int32) { + if total > 0 { + ph.SetPercent(float64(done) / float64(total)) + } + } + } } started := time.Now() - err = resumableUpload(ctx, client, &ropts) - if sp != nil { - sp.Stop("") + err := resumableUpload(ctx, client, ropts) + if prog != nil { + prog.Stop("") } if err != nil { return err @@ -283,14 +310,14 @@ func runResumableUpload(ctx context.Context, f cmdutil.Factory, ioStreams cmduti payload := newCpPayload(false) payload.Transfers = append(payload.Transfers, transferEntry{ - Source: absPath, - Destination: URI{Bucket: dst.Bucket, Key: key}.String(), - Bytes: info.Size(), + 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)\n", rel, humanBytes(info.Size())) + _, _ = fmt.Fprintf(ioStreams.Out, "✓ uploaded %s (%s)\n", displayName, humanBytes(ropts.FileSize)) } return finalizeCp(ioStreams, f, &payload, started, false) } diff --git a/internal/verda-cli/cmd/s3/lock.go b/internal/verda-cli/cmd/s3/lock.go new file mode 100644 index 0000000..3b7dbba --- /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" +) + +// acquireUploadLock 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 acquireUploadLock(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 { + _ = 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) + _ = 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..9802162 --- /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 := acquireUploadLock(id) + if err != nil || !acquired { + t.Fatalf("first acquire: acquired=%v err=%v", acquired, err) + } + + if _, ok, err2 := acquireUploadLock(id); err2 != nil || ok { + t.Errorf("second acquire while held: ok=%v err=%v, want ok=false", ok, err2) + } + + release() + + release2, ok3, err3 := acquireUploadLock(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..36da066 --- /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 + +// acquireUploadLock 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 acquireUploadLock(_ string) (release func(), acquired bool, err error) { + return func() {}, true, nil +} diff --git a/internal/verda-cli/cmd/s3/lsuploads.go b/internal/verda-cli/cmd/s3/lsuploads.go index c0333cb..246ff29 100644 --- a/internal/verda-cli/cmd/s3/lsuploads.go +++ b/internal/verda-cli/cmd/s3/lsuploads.go @@ -112,6 +112,13 @@ func runLsUploads(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStr } 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 := cmdutil.IsStdoutTerminal() && !f.AgentMode() && f.OutputFormat() == "table" + if interactive && len(payload.Uploads) > 0 { + return promptResumeFromUploads(cmd.Context(), f, ioStreams, client, uri.Bucket, payload.Uploads) + } return nil } diff --git a/internal/verda-cli/cmd/s3/mpupload.go b/internal/verda-cli/cmd/s3/mpupload.go index 9d6c8ef..2ee12e3 100644 --- a/internal/verda-cli/cmd/s3/mpupload.go +++ b/internal/verda-cli/cmd/s3/mpupload.go @@ -50,6 +50,10 @@ type resumableOptions struct { 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 @@ -118,6 +122,17 @@ func resumableUpload(ctx context.Context, client API, opts *resumableOptions) er 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 := acquireUploadLock(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 @@ -129,6 +144,9 @@ func resumableUpload(ctx context.Context, client API, opts *resumableOptions) er } total := numParts(opts.FileSize, partSize) + if opts.OnProgress != nil { + opts.OnProgress(int32(len(done)), total) // reflect parts already on the server + } if err := uploadMissingParts(ctx, client, opts, identity, cp, partSize, total, done); err != nil { return err } @@ -326,6 +344,7 @@ func uploadMissingParts(ctx context.Context, client API, opts *resumableOptions, var mu sync.Mutex var firstErr error + completed := int32(len(done)) // parts already on the server count toward progress for res := range results { if res.err != nil { if firstErr == nil { @@ -340,6 +359,11 @@ func uploadMissingParts(ctx context.Context, client API, opts *resumableOptions, if err != nil && firstErr == nil { firstErr = err cancel() + continue + } + completed++ + if opts.OnProgress != nil { + opts.OnProgress(completed, total) } } return firstErr 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/upload_wizard.go b/internal/verda-cli/cmd/s3/upload_wizard.go new file mode 100644 index 0000000..6d70679 --- /dev/null +++ b/internal/verda-cli/cmd/s3/upload_wizard.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. + +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, real := classifyNav(err, sourceFromArg); real != nil { + return real + } 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, real := classifyNav(err, false); real != nil { + return real + } else if exit { + return nil + } else if back { + step = stepBucket + continue + } + step = stepConfirm + + case stepConfirm: + dstURI := URI{Bucket: bucket, Key: prefix} + 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) { + step = stepLocation + continue + } + if cmdutil.IsPromptInterrupt(cerr) { + return nil + } + return cerr + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + return 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, real 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) (string, bool, 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 := []string{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..bcb492b --- /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 + real 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, real := classifyNav(tc.err, tc.firstStep) + if back != tc.back || exit != tc.exit || real != tc.real { + t.Errorf("%s: classifyNav = (back=%v exit=%v real=%v), want (%v %v %v)", + tc.name, back, exit, real, tc.back, tc.exit, tc.real) + } + } +} From 3ec81f21bd2d5111fe3ca41dc4fbc7b8dae3f660 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 17:43:46 +0300 Subject: [PATCH 12/26] feat(s3): show transfer rate on upload completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Append '@ X/s' to the upload result line, computed over the bytes moved this run (a resume reports its true session throughput, not an inflated figure). A live in-bar rate isn't possible yet — tui.ProgressHandle exposes only SetPercent/Stop, no label update. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/cp.go | 36 +++++++++++++++++++++++++-------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/internal/verda-cli/cmd/s3/cp.go b/internal/verda-cli/cmd/s3/cp.go index 36bde30..0a05a86 100644 --- a/internal/verda-cli/cmd/s3/cp.go +++ b/internal/verda-cli/cmd/s3/cp.go @@ -282,8 +282,11 @@ func runResumableUpload(ctx context.Context, f cmdutil.Factory, ioStreams cmduti func runResumable(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, ropts *resumableOptions, displayName string) error { announceResume(ctx, f, ioStreams, client, ropts) - // A part-level progress bar (table output only); SetPercent is driven from - // the uploader's serialized result loop, so it's race-free. + partSize := computePartSize(ropts.FileSize, ropts.PartSize) + + // A part-level progress bar (table output only). The OnProgress callback is + // always installed (to measure throughput), and drives the bar when present; + // it runs on the uploader's serialized result loop, so it's race-free. var prog interface { SetPercent(float64) Stop(string) @@ -291,13 +294,20 @@ func runResumable(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt if status := f.Status(); status != nil { if ph, perr := status.Progress(ctx, fmt.Sprintf("Uploading %s", displayName)); perr == nil { prog = ph - ropts.OnProgress = func(done, total int32) { - if total > 0 { - ph.SetPercent(float64(done) / float64(total)) - } - } } } + var firstDone, lastDone int32 + firstSet := false + ropts.OnProgress = func(done, total int32) { + if !firstSet { + firstDone, firstSet = done, true + } + lastDone = done + if prog != nil && total > 0 { + prog.SetPercent(float64(done) / float64(total)) + } + } + started := time.Now() err := resumableUpload(ctx, client, ropts) if prog != nil { @@ -308,6 +318,16 @@ func runResumable(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt } 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, @@ -317,7 +337,7 @@ func runResumable(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt Status: "ok", }) if !isStructured(f.OutputFormat()) { - _, _ = fmt.Fprintf(ioStreams.Out, "✓ uploaded %s (%s)\n", displayName, humanBytes(ropts.FileSize)) + _, _ = fmt.Fprintf(ioStreams.Out, "✓ uploaded %s (%s)%s\n", displayName, humanBytes(ropts.FileSize), rateSuffix) } return finalizeCp(ioStreams, f, &payload, started, false) } From fb262c486f51bbb300f7df373cfda8617af05e76 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 17:58:20 +0300 Subject: [PATCH 13/26] feat(s3): live upload progress line with bar, percent, and rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the tui progress bar (which can only show a percentage) with a self-rendered single line on an interactive terminal: 'Uploading ████░░░ 62% 9.1 MB/s', overwritten in place via \r. The rate is measured over bytes moved this session, so a resume shows its true throughput. Gated on table output + a stderr TTY (cmdutil.IsStderrTerminal); pipes/--agent/-o json print nothing live. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/cp.go | 26 +++---- internal/verda-cli/cmd/s3/upload_progress.go | 70 +++++++++++++++++++ .../verda-cli/cmd/s3/upload_progress_test.go | 49 +++++++++++++ internal/verda-cli/cmd/util/iostreams.go | 6 ++ 4 files changed, 136 insertions(+), 15 deletions(-) create mode 100644 internal/verda-cli/cmd/s3/upload_progress.go create mode 100644 internal/verda-cli/cmd/s3/upload_progress_test.go diff --git a/internal/verda-cli/cmd/s3/cp.go b/internal/verda-cli/cmd/s3/cp.go index 0a05a86..263accd 100644 --- a/internal/verda-cli/cmd/s3/cp.go +++ b/internal/verda-cli/cmd/s3/cp.go @@ -283,18 +283,15 @@ func runResumable(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt announceResume(ctx, f, ioStreams, client, ropts) partSize := computePartSize(ropts.FileSize, ropts.PartSize) + started := time.Now() - // A part-level progress bar (table output only). The OnProgress callback is - // always installed (to measure throughput), and drives the bar when present; - // it runs on the uploader's serialized result loop, so it's race-free. - var prog interface { - SetPercent(float64) - Stop(string) - } - if status := f.Status(); status != nil { - if ph, perr := status.Progress(ctx, fmt.Sprintf("Uploading %s", displayName)); perr == nil { - prog = ph - } + // 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 *uploadProgress + if f.Status() != nil && cmdutil.IsStderrTerminal() { + prog = newUploadProgress(ioStreams.ErrOut, displayName, ropts.FileSize, partSize, started) } var firstDone, lastDone int32 firstSet := false @@ -303,15 +300,14 @@ func runResumable(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt firstDone, firstSet = done, true } lastDone = done - if prog != nil && total > 0 { - prog.SetPercent(float64(done) / float64(total)) + if prog != nil { + prog.update(done, total) } } - started := time.Now() err := resumableUpload(ctx, client, ropts) if prog != nil { - prog.Stop("") + prog.finish() } if err != nil { return err diff --git a/internal/verda-cli/cmd/s3/upload_progress.go b/internal/verda-cli/cmd/s3/upload_progress.go new file mode 100644 index 0000000..dcbe907 --- /dev/null +++ b/internal/verda-cli/cmd/s3/upload_progress.go @@ -0,0 +1,70 @@ +// Copyright 2026 Verda Cloud Oy +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package s3 + +import ( + "fmt" + "io" + "strings" + "time" +) + +// progressBarWidth is the cell width of the in-line upload bar. +const progressBarWidth = 24 + +// uploadProgress renders a single in-place line (overwritten via \r) showing a +// bar, percentage, and live transfer rate. The tui progress component only +// accepts a percentage and can't carry a rate, so the upload path renders its +// own line. The rate is measured over bytes moved this session, so a resume +// reports its true throughput (the already-on-server baseline is excluded). +type uploadProgress struct { + w io.Writer + name string + fileSize int64 + partSize int64 + started time.Time + baseline int32 + baseSet bool +} + +func newUploadProgress(w io.Writer, name string, fileSize, partSize int64, started time.Time) *uploadProgress { + return &uploadProgress{w: w, name: name, fileSize: fileSize, partSize: partSize, started: started} +} + +// update redraws the line for the given completed/total part counts. +func (p *uploadProgress) update(done, total int32) { + if !p.baseSet { + p.baseline, p.baseSet = done, true + } + pct := 0.0 + if total > 0 { + pct = float64(done) / float64(total) + } + filled := min(int(pct*progressBarWidth), progressBarWidth) + bar := strings.Repeat("█", filled) + strings.Repeat("░", progressBarWidth-filled) + + rate := "" + if sent := int64(done-p.baseline) * p.partSize; sent > 0 { + if secs := time.Since(p.started).Seconds(); secs > 0 { + rate = humanBytes(int64(float64(min(sent, p.fileSize))/secs)) + "/s" + } + } + _, _ = fmt.Fprintf(p.w, "\r Uploading %s %s %3.0f%% %-11s", p.name, bar, pct*100, rate) +} + +// finish clears the progress line so the final result line prints cleanly. +func (p *uploadProgress) finish() { + _, _ = fmt.Fprint(p.w, "\r\033[K") +} diff --git a/internal/verda-cli/cmd/s3/upload_progress_test.go b/internal/verda-cli/cmd/s3/upload_progress_test.go new file mode 100644 index 0000000..b94b5e5 --- /dev/null +++ b/internal/verda-cli/cmd/s3/upload_progress_test.go @@ -0,0 +1,49 @@ +// 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 TestUploadProgress_RendersBarPercentRate(t *testing.T) { + t.Parallel() + buf := &bytes.Buffer{} + // started 2s ago so the rate divisor is non-zero and deterministic-ish. + up := newUploadProgress(buf, "model.bin", 100*minPartSize, minPartSize, time.Now().Add(-2*time.Second)) + + up.update(1, 4) // first call sets the baseline; no rate yet + buf.Reset() + up.update(3, 4) // 2 new parts moved over ~2s -> a rate appears + + out := buf.String() + for _, want := range []string{"model.bin", "75%", "/s", "█", "\r"} { + if !strings.Contains(out, want) { + t.Errorf("progress line missing %q:\n%q", want, out) + } + } +} + +func TestUploadProgress_FinishClearsLine(t *testing.T) { + t.Parallel() + buf := &bytes.Buffer{} + newUploadProgress(buf, "x", 10, 5, time.Now()).finish() + if !strings.Contains(buf.String(), "\r") { + t.Errorf("finish should rewrite the line, got %q", buf.String()) + } +} 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()) +} From 0b7f068f2e9428531330dfd4fc8105a9560b9785 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 18:06:11 +0300 Subject: [PATCH 14/26] feat(s3): download progress bar + speed; unify transfer progress - downloads now show a live bar + percent + transfer rate (same renderer as uploads); total comes from HeadObject. Downloads were already 5-way concurrent via the SDK transfer manager. - generalized the upload progress into a byte-based transferProgress shared by both directions; a concurrency-safe countingWriterAt feeds download bytes from the manager's parallel ranged GETs - HeadObject is issued only when actually rendering, so --agent/pipes/sync pay no extra request; sync suppresses per-file progress (quietProgress) - download result line now shows '@ X/s' too Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/cp.go | 45 ++++++-- internal/verda-cli/cmd/s3/mv.go | 2 +- internal/verda-cli/cmd/s3/sync.go | 4 +- .../verda-cli/cmd/s3/transfer_progress.go | 108 ++++++++++++++++++ .../cmd/s3/transfer_progress_test.go | 74 ++++++++++++ internal/verda-cli/cmd/s3/upload_progress.go | 70 ------------ .../verda-cli/cmd/s3/upload_progress_test.go | 49 -------- 7 files changed, 221 insertions(+), 131 deletions(-) create mode 100644 internal/verda-cli/cmd/s3/transfer_progress.go create mode 100644 internal/verda-cli/cmd/s3/transfer_progress_test.go delete mode 100644 internal/verda-cli/cmd/s3/upload_progress.go delete mode 100644 internal/verda-cli/cmd/s3/upload_progress_test.go diff --git a/internal/verda-cli/cmd/s3/cp.go b/internal/verda-cli/cmd/s3/cp.go index 263accd..b21fc8c 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" @@ -69,6 +70,10 @@ type cpOptions struct { 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) @@ -289,19 +294,19 @@ func runResumable(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt // 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 *uploadProgress + var prog *transferProgress if f.Status() != nil && cmdutil.IsStderrTerminal() { - prog = newUploadProgress(ioStreams.ErrOut, displayName, ropts.FileSize, partSize, started) + prog = newTransferProgress(ioStreams.ErrOut, "Uploading", displayName, ropts.FileSize, started) } var firstDone, lastDone int32 firstSet := false - ropts.OnProgress = func(done, total int32) { + ropts.OnProgress = func(done, _ int32) { if !firstSet { firstDone, firstSet = done, true } lastDone = done if prog != nil { - prog.update(done, total) + prog.update(min(int64(done)*partSize, ropts.FileSize)) } } @@ -473,7 +478,7 @@ func runDownload(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioS } } else { localPath := resolveDownloadPath(dst, src.Key) - if err := downloadOne(ctx, f, ioStreams, transporter, src, localPath, src.Key, opts, &payload); err != nil { + if err := downloadOne(ctx, f, ioStreams, transporter, apiClient, src, localPath, src.Key, opts, &payload); err != nil { return err } } @@ -499,7 +504,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 } } @@ -507,7 +512,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 { @@ -532,9 +537,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 } @@ -556,8 +579,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 } diff --git a/internal/verda-cli/cmd/s3/mv.go b/internal/verda-cli/cmd/s3/mv.go index 38d7e43..97334a1 100644 --- a/internal/verda-cli/cmd/s3/mv.go +++ b/internal/verda-cli/cmd/s3/mv.go @@ -296,7 +296,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/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_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/upload_progress.go b/internal/verda-cli/cmd/s3/upload_progress.go deleted file mode 100644 index dcbe907..0000000 --- a/internal/verda-cli/cmd/s3/upload_progress.go +++ /dev/null @@ -1,70 +0,0 @@ -// 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" - "time" -) - -// progressBarWidth is the cell width of the in-line upload bar. -const progressBarWidth = 24 - -// uploadProgress renders a single in-place line (overwritten via \r) showing a -// bar, percentage, and live transfer rate. The tui progress component only -// accepts a percentage and can't carry a rate, so the upload path renders its -// own line. The rate is measured over bytes moved this session, so a resume -// reports its true throughput (the already-on-server baseline is excluded). -type uploadProgress struct { - w io.Writer - name string - fileSize int64 - partSize int64 - started time.Time - baseline int32 - baseSet bool -} - -func newUploadProgress(w io.Writer, name string, fileSize, partSize int64, started time.Time) *uploadProgress { - return &uploadProgress{w: w, name: name, fileSize: fileSize, partSize: partSize, started: started} -} - -// update redraws the line for the given completed/total part counts. -func (p *uploadProgress) update(done, total int32) { - if !p.baseSet { - p.baseline, p.baseSet = done, true - } - pct := 0.0 - if total > 0 { - pct = float64(done) / float64(total) - } - filled := min(int(pct*progressBarWidth), progressBarWidth) - bar := strings.Repeat("█", filled) + strings.Repeat("░", progressBarWidth-filled) - - rate := "" - if sent := int64(done-p.baseline) * p.partSize; sent > 0 { - if secs := time.Since(p.started).Seconds(); secs > 0 { - rate = humanBytes(int64(float64(min(sent, p.fileSize))/secs)) + "/s" - } - } - _, _ = fmt.Fprintf(p.w, "\r Uploading %s %s %3.0f%% %-11s", p.name, bar, pct*100, rate) -} - -// finish clears the progress line so the final result line prints cleanly. -func (p *uploadProgress) finish() { - _, _ = fmt.Fprint(p.w, "\r\033[K") -} diff --git a/internal/verda-cli/cmd/s3/upload_progress_test.go b/internal/verda-cli/cmd/s3/upload_progress_test.go deleted file mode 100644 index b94b5e5..0000000 --- a/internal/verda-cli/cmd/s3/upload_progress_test.go +++ /dev/null @@ -1,49 +0,0 @@ -// 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 TestUploadProgress_RendersBarPercentRate(t *testing.T) { - t.Parallel() - buf := &bytes.Buffer{} - // started 2s ago so the rate divisor is non-zero and deterministic-ish. - up := newUploadProgress(buf, "model.bin", 100*minPartSize, minPartSize, time.Now().Add(-2*time.Second)) - - up.update(1, 4) // first call sets the baseline; no rate yet - buf.Reset() - up.update(3, 4) // 2 new parts moved over ~2s -> a rate appears - - out := buf.String() - for _, want := range []string{"model.bin", "75%", "/s", "█", "\r"} { - if !strings.Contains(out, want) { - t.Errorf("progress line missing %q:\n%q", want, out) - } - } -} - -func TestUploadProgress_FinishClearsLine(t *testing.T) { - t.Parallel() - buf := &bytes.Buffer{} - newUploadProgress(buf, "x", 10, 5, time.Now()).finish() - if !strings.Contains(buf.String(), "\r") { - t.Errorf("finish should rewrite the line, got %q", buf.String()) - } -} From 0dca0c6a22467f010968d5355e6983288efe0b3b Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 18:16:37 +0300 Subject: [PATCH 15/26] feat(s3): resumable parallel download (chunk manifest, progress, rate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - single-object downloads use a custom concurrent downloader: N-way ranged GETs writing each chunk at its offset (replaces the transfer manager for this path; same 5-way default) - resumable via a local checkpoint (~/.verda/s3-downloads) + a .part file. Re-running the same `verda s3 cp s3://… ./dst` resumes, fetching only the missing chunks - If-Match (ETag) guards against the object changing mid-download; an ETag/size mismatch restarts cleanly - live progress bar + transfer rate; same-host lock (acquireTransferLock, renamed from acquireUploadLock) blocks concurrent downloads of the same object - recursive / sync / mv downloads still use the transfer manager (per-file resume is a follow-up) Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/cp.go | 93 +++++- internal/verda-cli/cmd/s3/cp_test.go | 28 +- internal/verda-cli/cmd/s3/lock.go | 4 +- internal/verda-cli/cmd/s3/lock_test.go | 6 +- internal/verda-cli/cmd/s3/lock_windows.go | 4 +- internal/verda-cli/cmd/s3/mpdownload.go | 332 +++++++++++++++++++ internal/verda-cli/cmd/s3/mpdownload_test.go | 132 ++++++++ internal/verda-cli/cmd/s3/mpupload.go | 2 +- 8 files changed, 577 insertions(+), 24 deletions(-) create mode 100644 internal/verda-cli/cmd/s3/mpdownload.go create mode 100644 internal/verda-cli/cmd/s3/mpdownload_test.go diff --git a/internal/verda-cli/cmd/s3/cp.go b/internal/verda-cli/cmd/s3/cp.go index b21fc8c..e862c70 100644 --- a/internal/verda-cli/cmd/s3/cp.go +++ b/internal/verda-cli/cmd/s3/cp.go @@ -460,6 +460,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 @@ -468,22 +474,93 @@ 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() + rel := filepath.Base(localPath) 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 + if opts.Dryrun { + if !isStructured(f.OutputFormat()) { + _, _ = fmt.Fprintf(ioStreams.Out, "(dry run) would download %s -> %s\n", srcStr, localPath) } - } else { - localPath := resolveDownloadPath(dst, src.Key) - if err := downloadOne(ctx, f, ioStreams, transporter, apiClient, src, localPath, src.Key, opts, &payload); err != nil { - return err + payload.Transfers = append(payload.Transfers, transferEntry{Source: srcStr, Destination: localPath, Status: "dryrun"}) + return finalizeCp(ioStreams, f, &payload, started, true) + } + + client, err := buildClient(ctx, f, ClientOverrides{}) + if err != nil { + return err + } + partSize, err := parseByteSize(opts.PartSize) + if err != nil { + return err + } + + dlOpts := &resumableDownloadOptions{ + Bucket: src.Bucket, + Key: src.Key, + DestPath: localPath, + PartSize: partSize, + Concurrency: opts.Concurrency, + NoResume: opts.NoResume, + } + + // Track session bytes for the final rate; render the live bar lazily once + // the total is known (it comes from HeadObject inside the downloader). + enabled := f.Status() != nil && cmdutil.IsStderrTerminal() && !opts.quietProgress + var prog *transferProgress + var firstBytes, lastBytes int64 + firstSet := false + dlOpts.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) } } - return finalizeCp(ioStreams, f, &payload, started, opts.Dryrun) + n, err := resumableDownload(ctx, client, dlOpts) + if prog != nil { + prog.finish() + } + if err != nil { + return err + } + elapsed := time.Since(started) + + rateSuffix := "" + if moved := lastBytes - firstBytes; moved > 0 && elapsed.Seconds() > 0 { + rateSuffix = fmt.Sprintf(" @ %s/s", humanBytes(int64(float64(moved)/elapsed.Seconds()))) + } + payload.Transfers = append(payload.Transfers, transferEntry{ + Source: srcStr, + Destination: localPath, + Bytes: n, + DurationMs: elapsed.Milliseconds(), + Status: "ok", + }) + if !isStructured(f.OutputFormat()) { + _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s (%s)%s\n", rel, humanBytes(n), rateSuffix) + } + return finalizeCp(ioStreams, f, &payload, started, false) } // downloadTree lists src.Key and downloads each matching object into dstDir diff --git a/internal/verda-cli/cmd/s3/cp_test.go b/internal/verda-cli/cmd/s3/cp_test.go index 9ae86c3..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) diff --git a/internal/verda-cli/cmd/s3/lock.go b/internal/verda-cli/cmd/s3/lock.go index 3b7dbba..d3af7e4 100644 --- a/internal/verda-cli/cmd/s3/lock.go +++ b/internal/verda-cli/cmd/s3/lock.go @@ -24,13 +24,13 @@ import ( "syscall" ) -// acquireUploadLock takes a non-blocking advisory exclusive lock keyed by the +// 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 acquireUploadLock(identity string) (release func(), acquired bool, err error) { +func acquireTransferLock(identity string) (release func(), acquired bool, err error) { dir, err := checkpointDir() if err != nil { return nil, false, err diff --git a/internal/verda-cli/cmd/s3/lock_test.go b/internal/verda-cli/cmd/s3/lock_test.go index 9802162..d692a4d 100644 --- a/internal/verda-cli/cmd/s3/lock_test.go +++ b/internal/verda-cli/cmd/s3/lock_test.go @@ -24,18 +24,18 @@ func TestAcquireUploadLock_ExclusiveThenReleasable(t *testing.T) { withTempVerdaHome(t) const id = "deadbeef" - release, acquired, err := acquireUploadLock(id) + release, acquired, err := acquireTransferLock(id) if err != nil || !acquired { t.Fatalf("first acquire: acquired=%v err=%v", acquired, err) } - if _, ok, err2 := acquireUploadLock(id); err2 != nil || ok { + 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 := acquireUploadLock(id) + release2, ok3, err3 := acquireTransferLock(id) if err3 != nil || !ok3 { t.Fatalf("re-acquire after release: ok=%v err=%v", ok3, err3) } diff --git a/internal/verda-cli/cmd/s3/lock_windows.go b/internal/verda-cli/cmd/s3/lock_windows.go index 36da066..1edb57f 100644 --- a/internal/verda-cli/cmd/s3/lock_windows.go +++ b/internal/verda-cli/cmd/s3/lock_windows.go @@ -16,8 +16,8 @@ package s3 -// acquireUploadLock is a no-op on Windows: syscall.Flock is unavailable and the +// 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 acquireUploadLock(_ string) (release func(), acquired bool, err error) { +func acquireTransferLock(_ string) (release func(), acquired bool, err error) { return func() {}, true, nil } diff --git a/internal/verda-cli/cmd/s3/mpdownload.go b/internal/verda-cli/cmd/s3/mpdownload.go new file mode 100644 index 0000000..f516fb1 --- /dev/null +++ b/internal/verda-cli/cmd/s3/mpdownload.go @@ -0,0 +1,332 @@ +// 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) +} + +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) + } +} + +// 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) +} + +// 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() + + partPath := opts.DestPath + ".part" + cp := loadDownloadCheckpoint(identity) + done := map[int32]bool{} + if !opts.NoResume && cp != nil && cp.ETag == etag && cp.TotalSize == total && cp.PartSize == partSize && fileExists(partPath) { + for _, n := range cp.Chunks { + done[n] = true + } + } else { + _ = 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 0, err + } + } + + 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..e254b78 --- /dev/null +++ b/internal/verda-cli/cmd/s3/mpdownload_test.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. + +//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 + 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} + + n, err := resumableDownload(context.Background(), fake, &resumableDownloadOptions{ + Bucket: "b", Key: "k", DestPath: "out.bin", PartSize: 1024, Concurrency: 1, + }) + if err != nil { + t.Fatalf("resumableDownload: %v", err) + } + 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() + n, err := resumableDownload(context.Background(), fake, &resumableDownloadOptions{ + Bucket: "b", Key: "k", DestPath: dst, PartSize: 1024, Concurrency: 1, + }) + if err != nil { + t.Fatalf("resume: %v", err) + } + 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) + } +} diff --git a/internal/verda-cli/cmd/s3/mpupload.go b/internal/verda-cli/cmd/s3/mpupload.go index 2ee12e3..ced4200 100644 --- a/internal/verda-cli/cmd/s3/mpupload.go +++ b/internal/verda-cli/cmd/s3/mpupload.go @@ -124,7 +124,7 @@ func resumableUpload(ctx context.Context, client API, opts *resumableOptions) er // 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 := acquireUploadLock(identity) + release, acquired, err := acquireTransferLock(identity) if err != nil { return err } From 6f3f839fc4f8e60999365cce22f8e01016bd31b8 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 18:31:33 +0300 Subject: [PATCH 16/26] feat(s3): make ls-browser downloads resumable; document resume in cp help + README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the ls browser's per-object Download and multi-select 'Download files here…' now go through the resumable downloader (shared downloadToLocal helper), so re-selecting Download on an interrupted object resumes from its .part — giving downloads a discoverable interactive resume entry point (cp s3://… ./ is the param-way equivalent) - cp --help and the s3 README document resumable transfers, --no-resume/--concurrency/--part-size (which apply to uploads AND single-object downloads), and the two download entry points (cp + the browser) Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/README.md | 39 +++++++++ internal/verda-cli/cmd/s3/browse.go | 47 +++------- internal/verda-cli/cmd/s3/browse_test.go | 36 +++++--- internal/verda-cli/cmd/s3/cp.go | 107 +++++++++++++---------- 4 files changed, 140 insertions(+), 89 deletions(-) diff --git a/internal/verda-cli/cmd/s3/README.md b/internal/verda-cli/cmd/s3/README.md index 734a066..6d8e87c 100644 --- a/internal/verda-cli/cmd/s3/README.md +++ b/internal/verda-cli/cmd/s3/README.md @@ -71,8 +71,47 @@ 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 +``` + +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`. +- Recursive (`--recursive`), `sync`, and `mv` transfers are not yet resumable + per-file. + ## Moving Same flag surface as `cp`; source is removed on success. diff --git a/internal/verda-cli/cmd/s3/browse.go b/internal/verda-cli/cmd/s3/browse.go index 4d2302f..e6491e7 100644 --- a/internal/verda-cli/cmd/s3/browse.go +++ b/internal/verda-cli/cmd/s3/browse.go @@ -17,7 +17,6 @@ package s3 import ( "context" "fmt" - "os" "path" "path/filepath" "strings" @@ -146,7 +145,7 @@ func browseLevel(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStr case rowFolder: cur.Key = row.value case rowDownloadMulti: - if err := browseDownloadMulti(ctx, f, ioStreams, *cur, payload); err != nil { + if err := browseDownloadMulti(ctx, f, ioStreams, client, *cur, payload); err != nil { return false, err } case rowObject: @@ -235,7 +234,7 @@ func objectActionMenu(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil. } switch idx { case actDownload: - return browseDownload(ctx, f, ioStreams, obj) + return browseDownload(ctx, f, ioStreams, client, obj) case actInfo: return browseInfo(ctx, f, ioStreams, client, obj) case actDelete: @@ -245,41 +244,23 @@ func objectActionMenu(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil. } } -// browseDownload streams one object to ./ via the transfer manager. -func browseDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, obj URI) error { - tr, err := transporterBuilder(ctx, f, ClientOverrides{}) - if err != nil { - return err - } - local, n, err := downloadObjectTo(ctx, tr, obj) +// browseDownload downloads one object to ./ via the resumable +// downloader, so re-selecting Download on an interrupted object resumes from +// its .part file. +func browseDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, obj URI) error { + local := filepath.Base(obj.Key) + n, rate, err := downloadToLocal(ctx, f, ioStreams, client, obj, local, 0, defaultConcurrency, false, false) if err != nil { return err } - _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)\n", obj.String(), local, humanBytes(n)) + _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)%s\n", obj.String(), local, humanBytes(n), rate) return nil } -// downloadObjectTo streams obj to ./ and returns the local path + byte -// count. Shared by the single- and multi-file browser download paths. -func downloadObjectTo(ctx context.Context, tr Transporter, obj URI) (string, int64, error) { - local := filepath.Base(obj.Key) - out, err := os.Create(local) // #nosec G304 -- destination is the object basename in the cwd - if err != nil { - return "", 0, fmt.Errorf("create local file: %w", err) - } - defer func() { _ = out.Close() }() - - n, err := tr.Download(ctx, out, &s3.GetObjectInput{Bucket: aws.String(obj.Bucket), Key: aws.String(obj.Key)}) - if err != nil { - return "", 0, translateError(err) - } - return local, n, nil -} - // browseDownloadMulti multi-selects objects at the current level and downloads // the ticked set to the cwd. Objects only (folders are not selectable in v1); // non-destructive, so no confirmation. -func browseDownloadMulti(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, cur URI, payload objectsPayload) error { +func browseDownloadMulti(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, cur URI, payload objectsPayload) error { var objs []objectEntry var labels []string for i := range payload.Objects { @@ -307,19 +288,15 @@ func browseDownloadMulti(ctx context.Context, f cmdutil.Factory, ioStreams cmdut return nil } - tr, err := transporterBuilder(ctx, f, ClientOverrides{}) - if err != nil { - return err - } var total int64 for _, ix := range idxs { obj := URI{Bucket: cur.Bucket, Key: objs[ix].Key} - local, n, derr := downloadObjectTo(ctx, tr, obj) + n, rate, derr := downloadToLocal(ctx, f, ioStreams, client, obj, filepath.Base(obj.Key), 0, defaultConcurrency, false, false) if derr != nil { return fmt.Errorf("downloading %s: %w", obj.String(), derr) } total += n - _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)\n", obj.String(), local, humanBytes(n)) + _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)%s\n", obj.String(), filepath.Base(obj.Key), humanBytes(n), rate) } _, _ = fmt.Fprintf(ioStreams.Out, "Downloaded %d file(s), %s total\n", len(idxs), humanBytes(total)) return nil diff --git a/internal/verda-cli/cmd/s3/browse_test.go b/internal/verda-cli/cmd/s3/browse_test.go index 531015c..5301d20 100644 --- a/internal/verda-cli/cmd/s3/browse_test.go +++ b/internal/verda-cli/cmd/s3/browse_test.go @@ -17,6 +17,9 @@ package s3 import ( "bytes" "context" + "fmt" + "io" + "os" "strings" "testing" @@ -33,6 +36,7 @@ import ( 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) { @@ -55,6 +59,21 @@ func (b *browseFakeAPI) DeleteObject(ctx context.Context, in *s3.DeleteObjectInp 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. @@ -97,13 +116,11 @@ func TestBrowse_DrillDownDeleteAndExit(t *testing.T) { // 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/transporter/cwd state + // no t.Parallel — prompter/cwd/~/.verda state + withTempVerdaHome(t) // resumable downloader writes checkpoint/lock here t.Chdir(t.TempDir()) // isolate the cwd that downloads write into - fake := &browseFakeAPI{} - fakeT := &cpFakeTransporter{downloadWrite: []byte("XYZ")} - restoreT := withFakeTransporter(fakeT) - defer restoreT() + fake := &browseFakeAPI{dlBody: []byte("hello-world")} // Selects: bucket(0) -> folder data/(1) -> Download-files-here(1) -> Exit(3) // MultiSelect: tick the single object [0]. @@ -118,11 +135,10 @@ func TestBrowse_MultiDownload(t *testing.T) { t.Fatalf("runLsBrowser: %v", err) } - if len(fakeT.downloads) != 1 { - t.Fatalf("Download calls = %d, want 1", len(fakeT.downloads)) - } - if k := aws.ToString(fakeT.downloads[0].Key); k != "data/file.txt" { - t.Errorf("downloaded key = %q, want data/file.txt", k) + // The resumable downloader wrote file.txt (basename of data/file.txt) to cwd. + got, err := os.ReadFile("file.txt") + 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()) diff --git a/internal/verda-cli/cmd/s3/cp.go b/internal/verda-cli/cmd/s3/cp.go index e862c70..96fd349 100644 --- a/internal/verda-cli/cmd/s3/cp.go +++ b/internal/verda-cli/cmd/s3/cp.go @@ -117,7 +117,13 @@ 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(` @@ -127,7 +133,14 @@ func NewCmdCp(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { # Download a single object verda s3 cp s3://my-bucket/report.csv ./report.csv - # Copy between buckets + # 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 @@ -158,9 +171,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", "", "Multipart part size for large uploads, e.g. 32MiB (default auto)") - flags.IntVar(&opts.Concurrency, "concurrency", defaultConcurrency, "Parallel part uploads for large files") - flags.BoolVar(&opts.NoResume, "no-resume", false, "Ignore any checkpoint and restart the upload from scratch") + 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 } @@ -509,52 +522,15 @@ func runResumableDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdu return err } - dlOpts := &resumableDownloadOptions{ - Bucket: src.Bucket, - Key: src.Key, - DestPath: localPath, - PartSize: partSize, - Concurrency: opts.Concurrency, - NoResume: opts.NoResume, - } - - // Track session bytes for the final rate; render the live bar lazily once - // the total is known (it comes from HeadObject inside the downloader). - enabled := f.Status() != nil && cmdutil.IsStderrTerminal() && !opts.quietProgress - var prog *transferProgress - var firstBytes, lastBytes int64 - firstSet := false - dlOpts.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) - } - } - - n, err := resumableDownload(ctx, client, dlOpts) - if prog != nil { - prog.finish() - } + n, rateSuffix, err := downloadToLocal(ctx, f, ioStreams, client, src, localPath, partSize, opts.Concurrency, opts.NoResume, opts.quietProgress) if err != nil { return err } - elapsed := time.Since(started) - - rateSuffix := "" - if moved := lastBytes - firstBytes; moved > 0 && elapsed.Seconds() > 0 { - rateSuffix = fmt.Sprintf(" @ %s/s", humanBytes(int64(float64(moved)/elapsed.Seconds()))) - } payload.Transfers = append(payload.Transfers, transferEntry{ Source: srcStr, Destination: localPath, Bytes: n, - DurationMs: elapsed.Milliseconds(), + DurationMs: time.Since(started).Milliseconds(), Status: "ok", }) if !isStructured(f.OutputFormat()) { @@ -563,6 +539,49 @@ func runResumableDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdu return finalizeCp(ioStreams, f, &payload, started, false) } +// 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) (int64, string, error) { + rel := filepath.Base(localPath) + enabled := !quiet && f.Status() != nil && cmdutil.IsStderrTerminal() + var prog *transferProgress + var firstBytes, lastBytes int64 + firstSet := false + started := time.Now() + + n, err := resumableDownload(ctx, client, &resumableDownloadOptions{ + Bucket: src.Bucket, Key: src.Key, DestPath: localPath, + PartSize: partSize, Concurrency: concurrency, NoResume: noResume, + 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 // preserving the relative path below src.Key. Each resolved local path is // verified to stay within dstDir to block adversarial keys with ".." From 70603d9b06260907a12bbc22196ffd0551e5b704 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 22:32:59 +0300 Subject: [PATCH 17/26] feat(s3): show full local path on download completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Download result lines now print the absolute destination path (cp and the ls browser, single + multi) so it's clear where the file landed: '✓ downloaded s3://… -> /abs/path (size) @ rate'. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/browse.go | 4 ++-- internal/verda-cli/cmd/s3/cp.go | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/internal/verda-cli/cmd/s3/browse.go b/internal/verda-cli/cmd/s3/browse.go index e6491e7..1efacfb 100644 --- a/internal/verda-cli/cmd/s3/browse.go +++ b/internal/verda-cli/cmd/s3/browse.go @@ -253,7 +253,7 @@ func browseDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IO if err != nil { return err } - _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)%s\n", obj.String(), local, humanBytes(n), rate) + _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)%s\n", obj.String(), absOrSelf(local), humanBytes(n), rate) return nil } @@ -296,7 +296,7 @@ func browseDownloadMulti(ctx context.Context, f cmdutil.Factory, ioStreams cmdut return fmt.Errorf("downloading %s: %w", obj.String(), derr) } total += n - _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)%s\n", obj.String(), filepath.Base(obj.Key), humanBytes(n), rate) + _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)%s\n", obj.String(), absOrSelf(filepath.Base(obj.Key)), humanBytes(n), rate) } _, _ = fmt.Fprintf(ioStreams.Out, "Downloaded %d file(s), %s total\n", len(idxs), humanBytes(total)) return nil diff --git a/internal/verda-cli/cmd/s3/cp.go b/internal/verda-cli/cmd/s3/cp.go index 96fd349..f472e61 100644 --- a/internal/verda-cli/cmd/s3/cp.go +++ b/internal/verda-cli/cmd/s3/cp.go @@ -501,7 +501,6 @@ func runDownload(ctx context.Context, cmd *cobra.Command, f cmdutil.Factory, ioS 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() - rel := filepath.Base(localPath) payload := newCpPayload(opts.Dryrun) started := time.Now() @@ -534,11 +533,20 @@ func runResumableDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdu Status: "ok", }) if !isStructured(f.OutputFormat()) { - _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s (%s)%s\n", rel, humanBytes(n), rateSuffix) + _, _ = 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 From 3e9d2ab5646b6872ec4bba5b47ce691cbab2bf3a Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 22:42:14 +0300 Subject: [PATCH 18/26] fix(s3): don't bound the ls browser by --timeout (navigation + downloads) runLs wrapped the interactive browser in a 30s context, so a large browser download died with 'context deadline exceeded' (and long navigation could too). The browser now runs on cmd.Context() like the other bulk-transfer paths; only the static, scriptable listings keep the per-request --timeout. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/ls.go | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/internal/verda-cli/cmd/s3/ls.go b/internal/verda-cli/cmd/s3/ls.go index 2cffbd3..216cb22 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 && cmdutil.IsStdoutTerminal() && !f.AgentMode() && f.OutputFormat() == "table" { + 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,13 +125,7 @@ func runLs(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o if err != nil { return err } - if len(args) == 0 { - // No bucket on an interactive terminal -> the TUI explorer; otherwise - // (pipes, --agent, -o json) the static, scriptable bucket list. - if cmdutil.IsStdoutTerminal() && !f.AgentMode() && f.OutputFormat() == "table" { - return runLsBrowser(ctx, f, ioStreams, client) - } return runLsBuckets(ctx, f, ioStreams, client) } @@ -128,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) } From 9d69c1397c869a5d61f1745139116eb7cd018738 Mon Sep 17 00:00:00 2001 From: lei Date: Sat, 30 May 2026 22:51:08 +0300 Subject: [PATCH 19/26] feat(s3): announce resuming download; comprehensive cp --help examples - downloads now print 'Resuming download of X (A of B, P% already on disk)' when continuing from a checkpoint, mirroring the upload 'Resuming upload' line (was silent before). Driven by a new OnResume callback on the resumable downloader. - expanded 'verda s3 cp --help' examples: upload-into-folder, download-into-directory, recursive prefix download, exclude filter, content-type override (alongside the existing upload/download/resume/tuning/copy/dryrun examples) - test: OnResume fires with the already-on-disk bytes on resume, and does NOT fire on a fresh download Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/cp.go | 26 ++++++++++++++++++++ internal/verda-cli/cmd/s3/mpdownload.go | 7 ++++++ internal/verda-cli/cmd/s3/mpdownload_test.go | 10 ++++++++ 3 files changed, 43 insertions(+) diff --git a/internal/verda-cli/cmd/s3/cp.go b/internal/verda-cli/cmd/s3/cp.go index f472e61..63a71cb 100644 --- a/internal/verda-cli/cmd/s3/cp.go +++ b/internal/verda-cli/cmd/s3/cp.go @@ -130,9 +130,18 @@ func NewCmdCp(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { # 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 + # 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 @@ -146,6 +155,12 @@ func NewCmdCp(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { # 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 `), @@ -559,9 +574,20 @@ func downloadToLocal(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.I 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 diff --git a/internal/verda-cli/cmd/s3/mpdownload.go b/internal/verda-cli/cmd/s3/mpdownload.go index f516fb1..dff2a96 100644 --- a/internal/verda-cli/cmd/s3/mpdownload.go +++ b/internal/verda-cli/cmd/s3/mpdownload.go @@ -63,6 +63,9 @@ type resumableDownloadOptions struct { // 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 { @@ -207,6 +210,10 @@ func resumableDownload(ctx context.Context, client API, opts *resumableDownloadO } } + 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 } diff --git a/internal/verda-cli/cmd/s3/mpdownload_test.go b/internal/verda-cli/cmd/s3/mpdownload_test.go index e254b78..d83a598 100644 --- a/internal/verda-cli/cmd/s3/mpdownload_test.go +++ b/internal/verda-cli/cmd/s3/mpdownload_test.go @@ -70,12 +70,17 @@ func TestResumableDownload_Fresh(t *testing.T) { 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)) } @@ -113,12 +118,17 @@ func TestResumableDownload_BreakThenResume(t *testing.T) { 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)) } From 0e1c28410f2460a99d68d964f5af957bfa40aaa8 Mon Sep 17 00:00:00 2001 From: lei Date: Mon, 1 Jun 2026 15:54:04 +0300 Subject: [PATCH 20/26] feat(s3): interactive TUI for mb/rb/rm/mv; ~/Downloads default; review fixes Extend the dual-mode interactive pattern (omit the target on a TTY) to the remaining S3 subcommands, mirroring ls/cp: - mb: prompt for the new bucket name - rb: bucket picker + destructive confirm (extracted to confirmRbDeletion) - rm: folder browser with multi-select delete (rm_browse.go), reusing the shared confirm + batch-delete path; deletes only the ticked keys - mv: stepped S3->S3 move/rename wizard (move_wizard.go). Every prompt is an index-navigable step, so Esc steps back exactly one prompt and Ctrl+C exits, with a "Step N of M" header and a one-time intro banner Browser downloads now default to the OS Downloads folder and never overwrite an unrelated local file (resolveLocalDest), while still resuming an interrupted download of the same object; each finishes on a Back/Exit summary. Adversarial-review fixes: - upload resume restarts (not corrupts) when --part-size changes on an unchanged file; download .part path keyed off the absolute destination - gate every interactive entry on interactiveTTY so `-o json` on a TTY never launches a TUI; rm refuses --recursive/--dryrun/--yes/--include/--exclude without an explicit URI instead of silently dropping them - emptyBucket fails fast on per-object DeleteObjects errors (accurate count); BucketNotEmpty maps to a friendly error - wizard-facing pickers return the raw prompter error so Esc=back works; stale download checkpoints + lock files are GC'd from the download path make build / make lint (0 issues) / make test all pass. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/browse.go | 151 ++++++++-- internal/verda-cli/cmd/s3/browse_test.go | 63 ++++- internal/verda-cli/cmd/s3/checkpoint.go | 18 +- internal/verda-cli/cmd/s3/cp.go | 25 +- internal/verda-cli/cmd/s3/errors.go | 2 + internal/verda-cli/cmd/s3/lock.go | 4 +- internal/verda-cli/cmd/s3/ls.go | 2 +- internal/verda-cli/cmd/s3/lsuploads.go | 2 +- internal/verda-cli/cmd/s3/mb.go | 15 +- internal/verda-cli/cmd/s3/move_wizard.go | 212 ++++++++++++++ internal/verda-cli/cmd/s3/mpdownload.go | 70 +++-- internal/verda-cli/cmd/s3/mpdownload_test.go | 49 ++++ internal/verda-cli/cmd/s3/mpupload.go | 9 +- internal/verda-cli/cmd/s3/mpupload_test.go | 44 +++ internal/verda-cli/cmd/s3/mv.go | 21 +- internal/verda-cli/cmd/s3/picker.go | 129 ++++++++- internal/verda-cli/cmd/s3/rb.go | 44 ++- internal/verda-cli/cmd/s3/rm.go | 37 ++- internal/verda-cli/cmd/s3/rm_browse.go | 195 +++++++++++++ .../verda-cli/cmd/s3/tui_interactive_test.go | 263 ++++++++++++++++++ internal/verda-cli/cmd/s3/upload_wizard.go | 76 ++--- .../verda-cli/cmd/s3/upload_wizard_test.go | 10 +- 22 files changed, 1319 insertions(+), 122 deletions(-) create mode 100644 internal/verda-cli/cmd/s3/move_wizard.go create mode 100644 internal/verda-cli/cmd/s3/rm_browse.go create mode 100644 internal/verda-cli/cmd/s3/tui_interactive_test.go diff --git a/internal/verda-cli/cmd/s3/browse.go b/internal/verda-cli/cmd/s3/browse.go index 1efacfb..0a437e0 100644 --- a/internal/verda-cli/cmd/s3/browse.go +++ b/internal/verda-cli/cmd/s3/browse.go @@ -16,9 +16,12 @@ package s3 import ( "context" + "errors" "fmt" + "os" "path" "path/filepath" + "strconv" "strings" "charm.land/lipgloss/v2" @@ -38,6 +41,7 @@ const ( rowFolder rowObject rowDownloadMulti + rowDeleteMulti ) type browseRow struct { @@ -52,6 +56,11 @@ type browseRow struct { // 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 == "" { @@ -77,7 +86,7 @@ func runLsBrowser(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOSt // browseBuckets shows the bucket list. Returns (chosen bucket, exit, err); // exit is true when the user chose Exit / Ctrl+C / Esc at the root. -func browseBuckets(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API) (string, bool, error) { +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{}) }) @@ -145,13 +154,21 @@ func browseLevel(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStr case rowFolder: cur.Key = row.value case rowDownloadMulti: - if err := browseDownloadMulti(ctx, f, ioStreams, client, *cur, payload); err != nil { + exit, err := browseDownloadMulti(ctx, f, ioStreams, client, *cur, payload) + if err != nil { return false, err } + if exit { + return false, nil + } case rowObject: - if err := objectActionMenu(ctx, f, ioStreams, client, URI{Bucket: cur.Bucket, Key: row.value}, row.size); err != nil { + 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 } @@ -216,7 +233,8 @@ func relName(prefix, full string) string { } // objectActionMenu presents the per-object actions (Download / Info / Delete). -func objectActionMenu(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, obj URI, size int64) error { +// 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 @@ -228,39 +246,49 @@ func objectActionMenu(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil. 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 nil + return false, nil } - return err + return false, err } switch idx { case actDownload: return browseDownload(ctx, f, ioStreams, client, obj) case actInfo: - return browseInfo(ctx, f, ioStreams, client, obj) + return false, browseInfo(ctx, f, ioStreams, client, obj) case actDelete: - return browseDelete(ctx, f, ioStreams, client, obj) + return false, browseDelete(ctx, f, ioStreams, client, obj) default: - return nil + return false, nil } } -// browseDownload downloads one object to ./ via the resumable -// downloader, so re-selecting Download on an interrupted object resumes from -// its .part file. -func browseDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, obj URI) error { - local := filepath.Base(obj.Key) +// 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 err + return false, err } _, _ = fmt.Fprintf(ioStreams.Out, "✓ downloaded %s -> %s (%s)%s\n", obj.String(), absOrSelf(local), humanBytes(n), rate) - return nil + return cmdutil.PromptBackOrExit(ctx, f.Prompter()) } // browseDownloadMulti multi-selects objects at the current level and downloads -// the ticked set to the cwd. Objects only (folders are not selectable in v1); -// non-destructive, so no confirmation. -func browseDownloadMulti(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client API, cur URI, payload objectsPayload) error { +// 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) { var objs []objectEntry var labels []string for i := range payload.Objects { @@ -273,33 +301,98 @@ func browseDownloadMulti(ctx context.Context, f cmdutil.Factory, ioStreams cmdut } if len(objs) == 0 { _, _ = fmt.Fprintln(ioStreams.ErrOut, "No files to download at this level.") - return nil + 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 nil + return false, nil } - return err + return false, err } if len(idxs) == 0 { _, _ = fmt.Fprintln(ioStreams.ErrOut, "Nothing selected.") - return nil + 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} - n, rate, derr := downloadToLocal(ctx, f, ioStreams, client, obj, filepath.Base(obj.Key), 0, defaultConcurrency, false, false) - if derr != nil { - return fmt.Errorf("downloading %s: %w", obj.String(), derr) + 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(filepath.Base(obj.Key)), humanBytes(n), rate) + _, _ = 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)) } - _, _ = fmt.Fprintf(ioStreams.Out, "Downloaded %d file(s), %s total\n", len(idxs), humanBytes(total)) - return nil } // browseInfo prints object metadata via HeadObject. diff --git a/internal/verda-cli/cmd/s3/browse_test.go b/internal/verda-cli/cmd/s3/browse_test.go index 5301d20..ad8fe18 100644 --- a/internal/verda-cli/cmd/s3/browse_test.go +++ b/internal/verda-cli/cmd/s3/browse_test.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strings" "testing" @@ -117,15 +118,16 @@ func TestBrowse_DrillDownDeleteAndExit(t *testing.T) { // 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 - t.Chdir(t.TempDir()) // isolate the cwd that downloads write into + 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) -> Exit(3) + // 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(3). + AddSelect(0).AddSelect(1).AddSelect(1).AddSelect(1). AddMultiSelect([]int{0}) out := &bytes.Buffer{} @@ -135,8 +137,8 @@ func TestBrowse_MultiDownload(t *testing.T) { t.Fatalf("runLsBrowser: %v", err) } - // The resumable downloader wrote file.txt (basename of data/file.txt) to cwd. - got, err := os.ReadFile("file.txt") + // The resumable downloader wrote file.txt (basename of data/file.txt) to ~/Downloads. + got, err := os.ReadFile(filepath.Join(home, "Downloads", "file.txt")) if err != nil || !bytes.Equal(got, fake.dlBody) { t.Errorf("downloaded file.txt mismatch (err=%v, got=%q)", err, got) } @@ -145,6 +147,55 @@ func TestBrowse_MultiDownload(t *testing.T) { } } +// 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 }{ diff --git a/internal/verda-cli/cmd/s3/checkpoint.go b/internal/verda-cli/cmd/s3/checkpoint.go index f41c63f..9902963 100644 --- a/internal/verda-cli/cmd/s3/checkpoint.go +++ b/internal/verda-cli/cmd/s3/checkpoint.go @@ -163,17 +163,23 @@ func deleteCheckpoint(identity string) error { return nil } -// gcCheckpoints prunes checkpoint files 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. +// 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 { - if maxAge <= 0 { - maxAge = checkpointMaxAge - } 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) { diff --git a/internal/verda-cli/cmd/s3/cp.go b/internal/verda-cli/cmd/s3/cp.go index 63a71cb..e1578a4 100644 --- a/internal/verda-cli/cmd/s3/cp.go +++ b/internal/verda-cli/cmd/s3/cp.go @@ -43,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 { @@ -171,7 +185,7 @@ func NewCmdCp(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { if len(args) == 2 { return runCp(cmd, f, ioStreams, opts, args[0], args[1]) } - interactive := cmdutil.IsStdoutTerminal() && !f.AgentMode() && f.OutputFormat() == "table" + 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) @@ -536,6 +550,11 @@ func runResumableDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdu 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 @@ -566,7 +585,7 @@ func absOrSelf(p string) string { // 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) (int64, string, error) { +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 @@ -819,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/errors.go b/internal/verda-cli/cmd/s3/errors.go index 3c63b75..07612ee 100644 --- a/internal/verda-cli/cmd/s3/errors.go +++ b/internal/verda-cli/cmd/s3/errors.go @@ -42,6 +42,8 @@ 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()) } diff --git a/internal/verda-cli/cmd/s3/lock.go b/internal/verda-cli/cmd/s3/lock.go index d3af7e4..35d3029 100644 --- a/internal/verda-cli/cmd/s3/lock.go +++ b/internal/verda-cli/cmd/s3/lock.go @@ -43,7 +43,7 @@ func acquireTransferLock(identity string) (release func(), acquired bool, err er 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 { + 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 @@ -51,7 +51,7 @@ func acquireTransferLock(identity string) (release func(), acquired bool, err er return nil, false, fmt.Errorf("lock %q: %w", path, flockErr) } return func() { - _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = 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/ls.go b/internal/verda-cli/cmd/s3/ls.go index 216cb22..7e5ee72 100644 --- a/internal/verda-cli/cmd/s3/ls.go +++ b/internal/verda-cli/cmd/s3/ls.go @@ -110,7 +110,7 @@ func runLs(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, o // 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 && cmdutil.IsStdoutTerminal() && !f.AgentMode() && f.OutputFormat() == "table" { + if len(args) == 0 && interactiveTTY(f) { client, err := buildClient(cmd.Context(), f, ClientOverrides{}) if err != nil { return err diff --git a/internal/verda-cli/cmd/s3/lsuploads.go b/internal/verda-cli/cmd/s3/lsuploads.go index 246ff29..cab9da1 100644 --- a/internal/verda-cli/cmd/s3/lsuploads.go +++ b/internal/verda-cli/cmd/s3/lsuploads.go @@ -115,7 +115,7 @@ func runLsUploads(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStr // 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 := cmdutil.IsStdoutTerminal() && !f.AgentMode() && f.OutputFormat() == "table" + interactive := interactiveTTY(f) if interactive && len(payload.Uploads) > 0 { return promptResumeFromUploads(cmd.Context(), f, ioStreams, client, uri.Bucket, payload.Uploads) } 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 index dff2a96..0854ca3 100644 --- a/internal/verda-cli/cmd/s3/mpdownload.go +++ b/internal/verda-cli/cmd/s3/mpdownload.go @@ -127,6 +127,52 @@ func deleteDownloadCheckpoint(identity string) { } } +// 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 { @@ -136,7 +182,7 @@ func numChunks(total, partSize int64) int32 { if total%partSize != 0 { n++ } - return int32(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). @@ -192,22 +238,12 @@ func resumableDownload(ctx context.Context, client API, opts *resumableDownloadO } defer release() - partPath := opts.DestPath + ".part" - cp := loadDownloadCheckpoint(identity) - done := map[int32]bool{} - if !opts.NoResume && cp != nil && cp.ETag == etag && cp.TotalSize == total && cp.PartSize == partSize && fileExists(partPath) { - for _, n := range cp.Chunks { - done[n] = true - } - } else { - _ = 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 0, err - } + // 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 { diff --git a/internal/verda-cli/cmd/s3/mpdownload_test.go b/internal/verda-cli/cmd/s3/mpdownload_test.go index d83a598..080e287 100644 --- a/internal/verda-cli/cmd/s3/mpdownload_test.go +++ b/internal/verda-cli/cmd/s3/mpdownload_test.go @@ -140,3 +140,52 @@ func TestResumableDownload_BreakThenResume(t *testing.T) { 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 index ced4200..54275e8 100644 --- a/internal/verda-cli/cmd/s3/mpupload.go +++ b/internal/verda-cli/cmd/s3/mpupload.go @@ -145,7 +145,7 @@ func resumableUpload(ctx context.Context, client API, opts *resumableOptions) er total := numParts(opts.FileSize, partSize) if opts.OnProgress != nil { - opts.OnProgress(int32(len(done)), total) // reflect parts already on the server + 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 @@ -182,7 +182,10 @@ func resolveUpload(ctx context.Context, client API, opts *resumableOptions, iden if existing == nil || opts.NoResume { return fresh() } - if existing.FileSize != opts.FileSize || !existing.MTime.Equal(opts.MTime) { + // 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() } @@ -344,7 +347,7 @@ func uploadMissingParts(ctx context.Context, client API, opts *resumableOptions, var mu sync.Mutex var firstErr error - completed := int32(len(done)) // parts already on the server count toward progress + completed := int32(len(done)) //nolint:gosec // G115: part count is capped at 10000 for res := range results { if res.err != nil { if firstErr == nil { diff --git a/internal/verda-cli/cmd/s3/mpupload_test.go b/internal/verda-cli/cmd/s3/mpupload_test.go index a65a7f2..d87ab93 100644 --- a/internal/verda-cli/cmd/s3/mpupload_test.go +++ b/internal/verda-cli/cmd/s3/mpupload_test.go @@ -392,6 +392,50 @@ func TestResumableUpload_NoResume_AbortsAndRestarts(t *testing.T) { } } +// 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 diff --git a/internal/verda-cli/cmd/s3/mv.go b/internal/verda-cli/cmd/s3/mv.go index 97334a1..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) }, } diff --git a/internal/verda-cli/cmd/s3/picker.go b/internal/verda-cli/cmd/s3/picker.go index 850069d..e69a548 100644 --- a/internal/verda-cli/cmd/s3/picker.go +++ b/internal/verda-cli/cmd/s3/picker.go @@ -17,6 +17,7 @@ package s3 import ( "context" "fmt" + "strings" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/s3" @@ -26,6 +27,10 @@ import ( 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". @@ -71,7 +76,9 @@ func resolveBucketArg(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I if f.AgentMode() { return "", cmdutil.NewMissingFlagsError([]string{"s3://bucket"}) } - if !cmdutil.IsStdoutTerminal() { + // 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() } @@ -90,3 +97,123 @@ func resolveBucketArg(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.I } 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/rb.go b/internal/verda-cli/cmd/s3/rb.go index 772a436..57c92c9 100644 --- a/internal/verda-cli/cmd/s3/rb.go +++ b/internal/verda-cli/cmd/s3/rb.go @@ -106,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 } @@ -156,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) { @@ -182,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/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..798f0f6 --- /dev/null +++ b/internal/verda-cli/cmd/s3/rm_browse.go @@ -0,0 +1,195 @@ +// 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 { + var ( + keys []string + labels []string + ) + 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/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 index 6d70679..f72aad5 100644 --- a/internal/verda-cli/cmd/s3/upload_wizard.go +++ b/internal/verda-cli/cmd/s3/upload_wizard.go @@ -89,8 +89,8 @@ func runUploadWizard(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO case stepBucket: bucket, err = selectBucketOrCreate(ctx, f, ioStreams, client) - if back, exit, real := classifyNav(err, sourceFromArg); real != nil { - return real + if back, exit, fatal := classifyNav(err, sourceFromArg); fatal != nil { + return fatal } else if exit { return nil } else if back { @@ -105,8 +105,8 @@ func runUploadWizard(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO suggested = filepath.Base(source) } prefix, err = selectUploadLocation(ctx, f, ioStreams, client, bucket, suggested) - if back, exit, real := classifyNav(err, false); real != nil { - return real + if back, exit, fatal := classifyNav(err, false); fatal != nil { + return fatal } else if exit { return nil } else if back { @@ -116,43 +116,56 @@ func runUploadWizard(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IO step = stepConfirm case stepConfirm: - dstURI := URI{Bucket: bucket, Key: prefix} - 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)) + back, cerr := confirmAndRunUpload(ctx, cmd, f, ioStreams, source, isDir, URI{Bucket: bucket, Key: prefix}, opts) if cerr != nil { - if cmdutil.IsPromptBack(cerr) { - step = stepLocation - continue - } - if cmdutil.IsPromptInterrupt(cerr) { - return nil - } return cerr } - if !confirmed { - _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") - return nil + if back { + step = stepLocation + continue } - return runUpload(ctx, cmd, f, ioStreams, source, dstURI, opts) + 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, real error) { +func classifyNav(err error, firstStep bool) (back, exit bool, fatal error) { switch { case err == nil: return false, false, nil @@ -172,7 +185,7 @@ func classifyNav(err error, firstStep bool) (back, exit bool, real error) { // 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) (string, bool, error) { +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 { @@ -273,7 +286,8 @@ func selectUploadLocation(ctx context.Context, f cmdutil.Factory, ioStreams cmdu } for { - labels := []string{uploadBucketRootLabel} + labels := make([]string, 0, len(payload.CommonPrefixes)+2) + labels = append(labels, uploadBucketRootLabel) labels = append(labels, payload.CommonPrefixes...) labels = append(labels, uploadNewFolderLabel) diff --git a/internal/verda-cli/cmd/s3/upload_wizard_test.go b/internal/verda-cli/cmd/s3/upload_wizard_test.go index bcb492b..84c6a5a 100644 --- a/internal/verda-cli/cmd/s3/upload_wizard_test.go +++ b/internal/verda-cli/cmd/s3/upload_wizard_test.go @@ -117,7 +117,7 @@ func TestClassifyNav(t *testing.T) { firstStep bool back bool exit bool - real error + fatal error }{ {"nil advances", nil, false, false, false, nil}, {"ctrl+c exits", tui.ErrInterrupted, false, false, true, nil}, @@ -126,10 +126,10 @@ func TestClassifyNav(t *testing.T) { {"real error propagates", boom, false, false, false, boom}, } for _, tc := range cases { - back, exit, real := classifyNav(tc.err, tc.firstStep) - if back != tc.back || exit != tc.exit || real != tc.real { - t.Errorf("%s: classifyNav = (back=%v exit=%v real=%v), want (%v %v %v)", - tc.name, back, exit, real, tc.back, tc.exit, tc.real) + 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) } } } From 7b69c60a5ee47247ef4ac50afdbf7816524c73b5 Mon Sep 17 00:00:00 2001 From: lei Date: Mon, 1 Jun 2026 15:54:13 +0300 Subject: [PATCH 21/26] docs(s3): document interactive modes; add S3 coverage to AI skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - s3/README.md: rewrite the Interactive vs Non-Interactive section with the per-command trigger table, the ~/Downloads no-overwrite policy, resumable transfer notes, and the part-size-change-restarts behavior - internal/skills (verda-cloud, verda-reference): add Object Storage / S3 coverage for agents — intent mapping, per-command reference with verified flags and JSON field names, separate-credentials model, and the --yes / --dryrun rules for destructive ops - .ai/skills/new-command.md: codify the interactive-command contract — the mandatory hint bar, Esc=back / Ctrl+C=exit, the implicit-TTY trigger, the stepped-wizard pattern, and the picker-swallows-Esc pitfall Co-Authored-By: Claude Opus 4.8 (1M context) --- .ai/skills/new-command.md | 78 +++++++++++++++++++++--- internal/skills/files/verda-cloud.md | 34 ++++++++++- internal/skills/files/verda-reference.md | 35 +++++++++++ internal/verda-cli/cmd/s3/README.md | 42 +++++++++++-- 4 files changed, 173 insertions(+), 16 deletions(-) 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/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/s3/README.md b/internal/verda-cli/cmd/s3/README.md index 6d8e87c..774f5ca 100644 --- a/internal/verda-cli/cmd/s3/README.md +++ b/internal/verda-cli/cmd/s3/README.md @@ -94,6 +94,12 @@ verda s3 cp s3://my-bucket/models/model.safetensors ./model.safetensors 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 @@ -108,7 +114,16 @@ How it works: 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`. + 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. @@ -196,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 From bb6cf01c9ea89d592fed4eceae54d62e11a3092a Mon Sep 17 00:00:00 2001 From: lei Date: Mon, 1 Jun 2026 16:58:05 +0300 Subject: [PATCH 22/26] add claude.md in serverless modlue --- internal/verda-cli/cmd/serverless/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/verda-cli/cmd/serverless/CLAUDE.md b/internal/verda-cli/cmd/serverless/CLAUDE.md index ed9415b..a9f2439 100644 --- a/internal/verda-cli/cmd/serverless/CLAUDE.md +++ b/internal/verda-cli/cmd/serverless/CLAUDE.md @@ -27,7 +27,7 @@ This rule applies equally to Claude, Codex, Cursor, or a human editing the packa - 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`. **No feature gate, no `Hidden: true`** — this is a GA feature, unlike s3/registry. + - `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. From cae929ac5bab7c15350b1a48b91e80eb4018430f Mon Sep 17 00:00:00 2001 From: lei Date: Mon, 1 Jun 2026 17:02:10 +0300 Subject: [PATCH 23/26] ci: track Go version from go.mod instead of a hard-coded pin verdagostack v1.4.1 raises the module's go directive to 1.25.10 (propagated into verda-cli's go.mod), but the workflows pinned setup-go to 1.25.9, so CI failed with "go.mod requires go >= 1.25.10 (GOTOOLCHAIN=local)". Switch every setup-go step to go-version-file: go.mod so the CI toolchain always matches the module directive and won't drift on future bumps. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 12 ++++++------ .github/workflows/release.yml | 2 +- .github/workflows/security.yml | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) 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 From 72132c4831efead55d383eda3bec5e826cd1df42 Mon Sep 17 00:00:00 2001 From: lei Date: Mon, 1 Jun 2026 17:08:30 +0300 Subject: [PATCH 24/26] fix(deps): bump golang.org/x/sys to v0.45.0 (GO-2026-5024) osv-scanner flagged GO-2026-5024 in the transitive golang.org/x/sys@v0.43.0. Bump to v0.45.0 (past the fix). govulncheck confirms the advisory is cleared. Co-Authored-By: Claude Opus 4.8 (1M context) --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4312b4a..1eac9ec 100644 --- a/go.mod +++ b/go.mod @@ -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/sys v0.45.0 // indirect golang.org/x/text v0.36.0 // indirect ) diff --git a/go.sum b/go.sum index c602f8a..99d6ce7 100644 --- a/go.sum +++ b/go.sum @@ -223,8 +223,8 @@ 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/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= From 358c5049dff1f513a381b6add24881faa2d3f1c0 Mon Sep 17 00:00:00 2001 From: lei Date: Mon, 1 Jun 2026 17:08:30 +0300 Subject: [PATCH 25/26] fix(s3): annotate remaining int conversions for strict gosec scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security workflow runs gosec with --no-config, dropping the repo's .golangci.yaml exclusions (which normally skip gosec on _test.go). That exposed five int32/file-read conversions with no suppress directive — numParts (capped at maxParts) and four test-only loop/index conversions. Annotate with //nolint:gosec + reason; honored by both the strict scan and make lint. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/browse_test.go | 2 +- internal/verda-cli/cmd/s3/mpdownload_test.go | 2 +- internal/verda-cli/cmd/s3/mpupload.go | 2 +- internal/verda-cli/cmd/s3/mpupload_test.go | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/verda-cli/cmd/s3/browse_test.go b/internal/verda-cli/cmd/s3/browse_test.go index ad8fe18..9c3ae76 100644 --- a/internal/verda-cli/cmd/s3/browse_test.go +++ b/internal/verda-cli/cmd/s3/browse_test.go @@ -138,7 +138,7 @@ func TestBrowse_MultiDownload(t *testing.T) { } // The resumable downloader wrote file.txt (basename of data/file.txt) to ~/Downloads. - got, err := os.ReadFile(filepath.Join(home, "Downloads", "file.txt")) + 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) } diff --git a/internal/verda-cli/cmd/s3/mpdownload_test.go b/internal/verda-cli/cmd/s3/mpdownload_test.go index 080e287..e54e8a0 100644 --- a/internal/verda-cli/cmd/s3/mpdownload_test.go +++ b/internal/verda-cli/cmd/s3/mpdownload_test.go @@ -52,7 +52,7 @@ func (d *dlFakeAPI) GetObject(ctx context.Context, in *s3.GetObjectInput, opts . 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 + n := int32(start/d.partSize) + 1 //nolint:gosec // G115: test chunk index d.mu.Lock() d.getCalls++ d.mu.Unlock() diff --git a/internal/verda-cli/cmd/s3/mpupload.go b/internal/verda-cli/cmd/s3/mpupload.go index 54275e8..3b2c242 100644 --- a/internal/verda-cli/cmd/s3/mpupload.go +++ b/internal/verda-cli/cmd/s3/mpupload.go @@ -89,7 +89,7 @@ func numParts(fileSize, partSize int64) int32 { if n > maxParts { n = maxParts } - return int32(n) + return int32(n) //nolint:gosec // G115: capped at maxParts (10000) just above } // partRange returns the deterministic byte range [start,end) for part n diff --git a/internal/verda-cli/cmd/s3/mpupload_test.go b/internal/verda-cli/cmd/s3/mpupload_test.go index d87ab93..2fba703 100644 --- a/internal/verda-cli/cmd/s3/mpupload_test.go +++ b/internal/verda-cli/cmd/s3/mpupload_test.go @@ -208,7 +208,7 @@ func verifyCompletedParts(t *testing.T, parts []s3types.CompletedPart, wantN int t.Fatalf("completed parts = %d, want %d", len(parts), wantN) } for i := range parts { - wantNum := int32(i + 1) + 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) } @@ -597,7 +597,7 @@ func TestPartRange(t *testing.T) { {2 * ps, fileSize}, } for i := range wantRanges { - n := int32(i + 1) + 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]) From 677658ea34de44bf454fd71aa3b2ebb777bd1065 Mon Sep 17 00:00:00 2001 From: lei Date: Mon, 1 Jun 2026 18:14:00 +0300 Subject: [PATCH 26/26] fix(s3): preallocate multi-select slices (prealloc) golangci-lint v2.5.0 (CI's pinned version) flags the conditional-append loops in browseDownloadMulti and rmBrowseDeleteMulti under prealloc; newer local versions don't. Preallocate objs/labels/keys with len(payload.Objects) capacity so both versions are clean. Verified with golangci-lint v2.5.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/verda-cli/cmd/s3/browse.go | 4 ++-- internal/verda-cli/cmd/s3/rm_browse.go | 6 ++---- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/internal/verda-cli/cmd/s3/browse.go b/internal/verda-cli/cmd/s3/browse.go index 0a437e0..bca30af 100644 --- a/internal/verda-cli/cmd/s3/browse.go +++ b/internal/verda-cli/cmd/s3/browse.go @@ -289,8 +289,8 @@ func browseDownload(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IO // 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) { - var objs []objectEntry - var labels []string + 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 == "" { diff --git a/internal/verda-cli/cmd/s3/rm_browse.go b/internal/verda-cli/cmd/s3/rm_browse.go index 798f0f6..9385158 100644 --- a/internal/verda-cli/cmd/s3/rm_browse.go +++ b/internal/verda-cli/cmd/s3/rm_browse.go @@ -135,10 +135,8 @@ func buildRmRows(cur URI, payload objectsPayload) []browseRow { // 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 { - var ( - keys []string - labels []string - ) + 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 == "" {