Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions internal/verda-cli/cmd/s3/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,17 +19,21 @@ AWS-CLI-style object storage commands for Verda's S3-compatible endpoint. Uses a

## Configuration

Interactive wizard (prompts for access key, secret key, endpoint, region):
First create an access key in the Verda dashboard: **log in → select your
project → Project management → Credentials → Object Storage Access Keys.**

Interactive wizard — the endpoint and region come pre-filled with defaults
(`https://objects.fin-03.verda.storage`, `us-east-1`), so you normally just pick
a profile and paste the access key + secret:
```bash
verda s3 configure
```

Non-interactive:
Non-interactive (endpoint defaults if omitted; pass `--endpoint` for another region):
```bash
verda s3 configure \
--access-key AKIA... \
--secret-key ... \
--endpoint https://objects.lab.verda.storage
--secret-key ...
```

Show the active configuration (no secrets are printed):
Expand Down Expand Up @@ -229,7 +233,7 @@ omitted target returns the command help (or a structured error in `--agent`).

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.
- **`configure` wizard**: triggers when `--access-key` or `--secret-key` is missing. Endpoint and region default (so `configure --access-key X --secret-key Y` is fully non-interactive); pass `--endpoint`/`--region`/`--profile`/`--credentials-file` to override.
- **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 <prefix> --recursive` for bulk deletes across a whole prefix.
Expand Down Expand Up @@ -268,10 +272,13 @@ Business logic:

Wizard flow (`configure`):

1. Profile name (default `default`)
2. S3 access key ID (masked)
3. S3 secret access key (password prompt)
4. S3 endpoint URL (must start with `http://` or `https://`)
5. S3 region (default `us-east-1`)
1. Profile — **pick an existing profile** (each tagged "S3 configured" / "no S3 credentials yet") **or "+ Create new profile…"**. The selection defaults to the active profile (`--profile` / `VERDA_PROFILE` / `verda auth use`).
2. New profile name — only when "Create new" was chosen.
3. S3 access key ID
4. S3 secret access key (password prompt)
5. S3 endpoint URL (pre-filled with `https://objects.fin-03.verda.storage`; must start with `http://`/`https://`)
6. S3 region (default `us-east-1`)

Steps are skipped individually when the corresponding flag is already set — so `verda s3 configure --access-key X --endpoint Y` only prompts for the secret and region, and `--profile staging` skips the profile picker entirely (targeting `[staging]`).

All five steps are skipped individually when the corresponding flag is already set — so `verda s3 configure --access-key X --endpoint Y` only prompts for the secret and region.
> Note: `configure` writes to the profile you pick/name here; it does **not** auto-follow the active profile the way the read commands (`ls`/`cp`/…) do. The picker defaulting to the active profile keeps the two aligned, but if you create credentials for a non-active profile, pass `--profile` to the read commands or `verda auth use <name>` to switch.
6 changes: 3 additions & 3 deletions internal/verda-cli/cmd/s3/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ import (
"github.com/verda-cloud/verda-cli/internal/verda-cli/options"
)

// DefaultEndpoint is the bundled Verda S3 endpoint used when the user has not
// configured one explicitly. Update when the production URL is confirmed.
const DefaultEndpoint = "https://s3.verda.cloud"
// DefaultEndpoint is the bundled Verda S3 endpoint the configure wizard's
// pre-filled default and the fallback when no endpoint is set.
const DefaultEndpoint = "https://objects.fin-03.verda.storage"

// API is the minimal subset of the AWS S3 client used by this package.
// It exists to allow tests to inject fake clients without depending on
Expand Down
21 changes: 19 additions & 2 deletions internal/verda-cli/cmd/s3/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ func NewCmdConfigure(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comm
Long: cmdutil.LongDesc(`
Save S3 object storage credentials to the shared credentials file.

Create an access key first in the Verda dashboard: log in, select your
project, then open Project management → Credentials → Object Storage
Access Keys. The endpoint and region are pre-filled with sensible
defaults, so you usually only need to paste the access key and secret.

S3 credentials are stored alongside API credentials using verda_s3_
prefixed keys. Profile switching (--profile) works across both.

Expand All @@ -76,7 +81,10 @@ func NewCmdConfigure(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comm
`),
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(opts.AccessKey) == "" || strings.TrimSpace(opts.SecretKey) == "" || strings.TrimSpace(opts.Endpoint) == "" {
// Only the keys gate the wizard — endpoint/region have defaults, so
// `configure --access-key X --secret-key Y` is fully non-interactive.
if strings.TrimSpace(opts.AccessKey) == "" || strings.TrimSpace(opts.SecretKey) == "" {
printConfigureIntro(ioStreams)
flow := buildConfigureFlow(opts)
engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation())
if err := engine.Run(cmd.Context(), flow); err != nil {
Expand All @@ -90,8 +98,9 @@ func NewCmdConfigure(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comm
if strings.TrimSpace(opts.SecretKey) == "" {
return cmdutil.UsageErrorf(cmd, "--secret-key is required")
}
// Endpoint defaults when omitted (e.g. flag mode without --endpoint).
if strings.TrimSpace(opts.Endpoint) == "" {
return cmdutil.UsageErrorf(cmd, "--endpoint is required")
opts.Endpoint = DefaultEndpoint
}

path, err := resolveCredentialsFile(opts.CredentialsFile)
Expand Down Expand Up @@ -147,3 +156,11 @@ func NewCmdConfigure(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Comm

return cmd
}

// printConfigureIntro reminds the user where to obtain the access key before the
// wizard prompts for it.
func printConfigureIntro(ioStreams cmdutil.IOStreams) {
_, _ = fmt.Fprintln(ioStreams.ErrOut, "\n Create an Object Storage Access Key in the Verda dashboard first:")
_, _ = fmt.Fprintln(ioStreams.ErrOut, " log in → select your project → Project management → Credentials → Object Storage Access Keys")
_, _ = fmt.Fprintln(ioStreams.ErrOut)
}
69 changes: 69 additions & 0 deletions internal/verda-cli/cmd/s3/configure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,44 @@
package s3

import (
"bytes"
"os"
"path/filepath"
"testing"

"gopkg.in/ini.v1"

cmdutil "github.com/verda-cloud/verda-cli/internal/verda-cli/cmd/util"
)

// TestConfigureFlagMode_DefaultsEndpointAndRegion verifies that supplying only
// the keys runs non-interactively and fills in the default endpoint + region.
func TestConfigureFlagMode_DefaultsEndpointAndRegion(t *testing.T) {
// No t.Parallel: t.Setenv.
withTempVerdaHome(t)
path := filepath.Join(t.TempDir(), "credentials")
t.Setenv("VERDA_SHARED_CREDENTIALS_FILE", path)

f := cmdutil.NewTestFactory(nil)
cmd := NewCmdConfigure(f, cmdutil.IOStreams{Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}})
cmd.SetArgs([]string{"--access-key", "AKIA", "--secret-key", "secret"})
if err := cmd.Execute(); err != nil {
t.Fatalf("configure: %v", err)
}

cfg, err := ini.Load(path)
if err != nil {
t.Fatalf("load credentials: %v", err)
}
sec := cfg.Section("default")
if got := sec.Key("verda_s3_endpoint").String(); got != DefaultEndpoint {
t.Errorf("endpoint = %q, want default %q", got, DefaultEndpoint)
}
if got := sec.Key("verda_s3_region").String(); got != defaultRegion {
t.Errorf("region = %q, want default %q", got, defaultRegion)
}
}

func TestResolveCredentialsFileUsesEnv(t *testing.T) {
path := filepath.Join(t.TempDir(), "credentials")
t.Setenv("VERDA_SHARED_CREDENTIALS_FILE", path)
Expand All @@ -47,6 +78,44 @@ func TestResolveCredentialsFileExplicit(t *testing.T) {
}
}

func TestProfileChoices(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "credentials")
content := `[default]
verda_s3_access_key = AKIA
verda_s3_secret_key = secret
verda_s3_endpoint = https://objects.example.storage

[production]
verda_client_id = api-only
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}

choices := profileChoices(path)
if len(choices) != 3 {
t.Fatalf("choices = %d, want 3 (default, production, create-new): %+v", len(choices), choices)
}
if choices[0].Value != "default" || choices[0].Description != "S3 configured" {
t.Errorf("choice[0] = %+v, want default / S3 configured", choices[0])
}
if choices[1].Value != "production" || choices[1].Description != "no S3 credentials yet" {
t.Errorf("choice[1] = %+v, want production / no S3 credentials yet", choices[1])
}
if choices[2].Value != newProfileSentinel {
t.Errorf("last choice = %+v, want the create-new sentinel", choices[2])
}
}

func TestProfileChoices_NoFile(t *testing.T) {
t.Parallel()
choices := profileChoices(filepath.Join(t.TempDir(), "does-not-exist"))
if len(choices) != 1 || choices[0].Value != newProfileSentinel {
t.Errorf("with no credentials file, want only the create-new choice, got %+v", choices)
}
}

func TestConfigureWritesINI(t *testing.T) {
t.Parallel()

Expand Down
Loading