diff --git a/internal/verda-cli/cmd/cmd.go b/internal/verda-cli/cmd/cmd.go index b4c5602..3d9e15d 100644 --- a/internal/verda-cli/cmd/cmd.go +++ b/internal/verda-cli/cmd/cmd.go @@ -24,6 +24,7 @@ import ( "github/verda-cloud/verda-cli/internal/verda-cli/cmd/sshkey" "github/verda-cloud/verda-cli/internal/verda-cli/cmd/startupscript" "github/verda-cloud/verda-cli/internal/verda-cli/cmd/status" + "github/verda-cloud/verda-cli/internal/verda-cli/cmd/template" "github/verda-cloud/verda-cli/internal/verda-cli/cmd/update" cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" "github/verda-cloud/verda-cli/internal/verda-cli/cmd/vm" @@ -114,6 +115,7 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op locations.NewCmdLocations(f, ioStreams), sshkey.NewCmdSSHKey(f, ioStreams), startupscript.NewCmdStartupScript(f, ioStreams), + template.NewCmdTemplate(f, ioStreams), volume.NewCmdVolume(f, ioStreams), }, }, diff --git a/internal/verda-cli/cmd/template/CLAUDE.md b/internal/verda-cli/cmd/template/CLAUDE.md new file mode 100644 index 0000000..38474f1 --- /dev/null +++ b/internal/verda-cli/cmd/template/CLAUDE.md @@ -0,0 +1,84 @@ +# Template Command Knowledge + +## Quick Reference +- Parent: `verda template` (alias: `tmpl`) +- Subcommands: `create`, `list` (alias `ls`), `show`, `delete` (alias `rm`) +- Files: + - `template.go` -- Parent command, registers subcommands + - `create.go` -- Create command, name validation, runs VM wizard in template mode + - `list.go` -- List command with `--type` filter and structured output + - `show.go` -- Show command with field display and structured output + - `delete.go` -- Delete command with confirmation prompt + - `types.go` -- Re-exports types/functions from shared `internal/verda-cli/template/` + +## Domain-Specific Logic + +### Template YAML Format +All fields except `resource` are optional. Stored at `~/.verda/templates//.yaml`. + +| Field | Type | Description | +|-------|------|-------------| +| `resource` | string | Resource type, currently only `"vm"` | +| `billing_type` | string | `"on-demand"` or `"spot"` | +| `contract` | string | `"PAY_AS_YOU_GO"`, `"SPOT"`, `"LONG_TERM"` | +| `kind` | string | `"GPU"` or `"CPU"` | +| `instance_type` | string | e.g. `"1V100.6V"` | +| `location` | string | e.g. `"FIN-01"` | +| `image` | string | OS image slug | +| `os_volume_size` | int | GiB | +| `storage` | []StorageSpec | Each has `type` and `size` | +| `storage_skip` | bool | Skip storage step in wizard | +| `ssh_keys` | []string | Key **names** (not IDs) | +| `startup_script` | string | Script **name** (not ID) | +| `startup_script_skip` | bool | Skip startup script step in wizard | +| `hostname_pattern` | string | Pattern with `{random}` and `{location}` placeholders | + +### Name Validation and Auto-Reformatting +- Valid names match `^[a-z0-9][a-z0-9-]*$` +- `normalizeName()` auto-formats: lowercase, replace spaces/underscores with hyphens, strip invalid chars, collapse consecutive hyphens, trim leading/trailing hyphens +- Create command re-prompts on invalid names and on name collisions with existing templates + +### Template Resolution (--from flag) +- If ref contains `"/"` or ends with `".yaml"` -> treated as a file path +- Otherwise -> resolved as `~/.verda/templates/vm/.yaml` +- Empty ref (bare `--from`) -> interactive picker via `pickTemplate()` +- Resolution logic lives in `internal/verda-cli/template/template.go` `Resolve()` + +### Skip Flags +- `storage_skip: true` -> wizard skips additional storage step entirely +- `startup_script_skip: true` -> wizard skips startup script step entirely +- Captured when user selects "None (skip)" during template creation wizard +- Maps to `opts.storageSkip` and `opts.startupScriptSkip` in `createOptions` + +### Hostname Pattern Expansion +- `{random}` -> 3 petname words joined by hyphens (via `github.com/dustinkirkland/golang-petname`) +- `{location}` -> lowercased location code (e.g. `"FIN-01"` -> `"fin-01"`) +- Only expanded when `hostname_pattern` is set AND `opts.Hostname` is empty (no `--hostname` flag) + +### SSH Keys and Startup Scripts +- Stored in template by **name**, not ID +- Resolved to IDs at `vm create --from` time via `resolveSSHKeyNames()` and `resolveStartupScriptName()` +- On API error or name not found, produces a warning and the wizard prompts later +- Names are stored in `opts.sshKeyNames` / `opts.startupScriptName` for template-saving round-trip + +## Gotchas & Edge Cases + +- **Import cycle**: `cmd/template/` cannot import `cmd/vm/` for the Template type (circular dependency). Shared types live in `internal/verda-cli/template/`, re-exported by `cmd/template/types.go` via type aliases and `var` bindings. +- **`billingTypeSet` / `locationSet` flags**: Needed because `IsSet` in the wizard can't distinguish `"on-demand"` (falsy `IsSpot=false`) from "unset". When a template sets billing type or location, these booleans are set to `true` so the wizard skips those steps. +- **`NoOptDefVal` on `--from` flag**: Set to `" "` (space) so `--from` without a value is recognized as "flag changed but empty". When the user writes `verda vm create --from gpu-training`, cobra parses `gpu-training` as a positional arg; `RunE` recombines it into `opts.From`. +- **Startup script "None (skip)" label**: The wizard presents "None (skip)" as a selectable option. Previously, this label text was captured as the script name. Fixed by checking `Value != ""` before storing the name. +- **`ensurePricingCache`**: The confirm-deploy step calls this to fetch instance type and volume type pricing when the cache is empty. This happens when a template pre-filled earlier steps (skipping the steps that normally populate the cache). +- **Only first storage entry applied**: `applyTemplate()` only reads `tmpl.Storage[0]` because the wizard's convenience fields (`StorageSize`/`StorageType`) support a single additional volume. +- **AutoDescription**: `Template.AutoDescription()` joins non-empty `InstanceType`, `Image`, and `Location` with `", "` for the list view. +- **Directory permissions**: Template directories created with `0700`, files with `0644`. +- **Non-existent directory**: `List()` and `ListAll()` return `nil, nil` (not an error) when the templates directory doesn't exist yet. + +## Relationships + +- **`internal/verda-cli/template/`** -- Shared types (`Template`, `StorageSpec`, `Entry`) and I/O functions (`Save`, `Load`, `LoadFromPath`, `Resolve`, `List`, `ListAll`, `Delete`, `ValidateName`, `ExpandHostnamePattern`). Breaks the import cycle between `cmd/template/` and `cmd/vm/`. +- **`cmd/vm/wizard.go`** -- `WizardMode` (Deploy vs Template), `RunTemplateWizard()` (runs wizard without hostname/description/confirm steps), `TemplateResult` struct, `ensurePricingCache()` +- **`cmd/vm/template_apply.go`** -- `loadTemplateRef()`, `applyTemplate()`, `resolveTemplateNames()`, `resolveSSHKeyNames()`, `resolveStartupScriptName()`, `printTemplateSummary()`, `pickTemplate()` +- **`cmd/vm/create.go`** -- `--from` flag definition, `resolveCreateInputs()` orchestrates template loading + wizard invocation, `createOptions` struct with template-related internal fields (`billingTypeSet`, `locationSet`, `storageSkip`, `startupScriptSkip`, `sshKeyNames`, `startupScriptName`) +- **`cmdutil`** -- `Factory`, `IOStreams`, `LongDesc`, `Examples`, `DefaultSubCommandRun`, `WriteStructured` +- **`clioptions`** -- `VerdaDir()` for resolving `~/.verda/` base path +- **`petname`** -- `github.com/dustinkirkland/golang-petname` for `{random}` hostname expansion diff --git a/internal/verda-cli/cmd/template/README.md b/internal/verda-cli/cmd/template/README.md new file mode 100644 index 0000000..dd2dcfa --- /dev/null +++ b/internal/verda-cli/cmd/template/README.md @@ -0,0 +1,167 @@ +# verda template -- Manage reusable resource templates + +Save, list, show, and delete reusable resource configuration templates. Templates pre-fill the `vm create` wizard so you don't repeat the same settings. + +## Commands + +| Command | Description | Key Flags | +|---------|-------------|-----------| +| `verda template create [name]` | Interactive wizard to save a template | _(none)_ | +| `verda template list` | List all saved templates | `--type` | +| `verda template show ` | Display template details | `-o json` | +| `verda template delete ` | Delete a template (with confirmation) | _(none)_ | + +Aliases: `verda tmpl`, `verda tmpl ls` (list), `verda tmpl rm` (delete) + +## Usage Examples + +### Create + +```bash +# Interactive (prompts for name and runs VM wizard) +verda template create + +# Create a template with a specific name +verda template create gpu-training + +# Short alias +verda tmpl create my-template +``` + +The create command runs the VM wizard in **template mode** -- the same 10 configuration steps (billing type through startup script) but without hostname, description, or confirm-deploy. The resulting settings are saved to disk. + +### List + +```bash +# List all templates +verda template list + +# List only VM templates +verda template list --type vm + +# Short alias +verda tmpl ls +``` + +Output shows `NAME` (as `resource/name`) and an auto-generated `DESCRIPTION` built from instance type, image, and location. + +### Show + +```bash +# Show a VM template +verda template show vm/gpu-training + +# Output as JSON +verda template show vm/gpu-training -o json +``` + +### Delete + +```bash +# Delete a VM template (prompts for confirmation) +verda template delete vm/gpu-training + +# Short alias +verda tmpl rm vm/gpu-training +``` + +## Template Storage + +- Files stored at `~/.verda/templates//.yaml` +- Organized by resource type subdirectory (currently only `vm/`) +- Names must be lowercase alphanumeric with hyphens (regex: `^[a-z0-9][a-z0-9-]*$`) +- Auto-reformats invalid names: spaces and underscores become hyphens, uppercase becomes lowercase, other invalid characters are stripped, consecutive hyphens are collapsed + +## Template YAML Format + +A complete example showing all supported fields: + +```yaml +resource: vm +billing_type: on-demand # on-demand or spot +contract: PAY_AS_YOU_GO +kind: GPU # GPU or CPU +instance_type: 1V100.6V +location: FIN-01 +image: ubuntu-24.04-cuda-12.8 +os_volume_size: 200 # GiB +storage: + - type: NVMe + size: 500 +storage_skip: true # explicitly skip additional storage +ssh_keys: + - milek # by name, resolved to ID at create time +startup_script: setup-training # by name, resolved to ID at create time +startup_script_skip: true # explicitly skip startup script +hostname_pattern: "gpu-{random}-{location}" # auto-generate hostnames +``` + +All fields except `resource` are optional; omitted fields are left for the wizard to prompt. + +## Using Templates with `vm create` + +```bash +verda vm create --from gpu-training # load by name +verda vm create --from ./my-template.yaml # load from file path +verda vm create --from # pick from list (interactive) +verda vm create --from gpu-training --hostname my-vm --description "test" +``` + +### Flow + +1. Template values pre-fill the wizard's `createOptions` +2. A summary of template values is printed to stderr +3. SSH keys and startup scripts are resolved by name to ID via the API; unresolved names produce warnings +4. Only unfilled steps are prompted (hostname, description, confirm-deploy are always prompted; other steps only if the template didn't fill them) +5. The confirm-deploy step fetches pricing for the deployment summary (via `ensurePricingCache` if earlier pricing steps were skipped) + +### Template Resolution + +The `--from` flag uses `NoOptDefVal` so it can be used in three ways: + +- `--from gpu-training` -- resolves as a template name in `~/.verda/templates/vm/` +- `--from ./path/to/template.yaml` -- treated as a file path (contains `/` or ends with `.yaml`) +- `--from` (no value) -- shows an interactive picker of saved VM templates + +When `--from` consumes no value, the template name may appear as a positional arg (e.g., `verda vm create --from gpu-training`). The `RunE` handler recombines it. + +## Hostname Pattern + +The `hostname_pattern` field supports two placeholders: + +- `{random}` -- replaced with 3 random petname words joined by hyphens (e.g., `cold-cable-smiles`) +- `{location}` -- replaced with the lowercased location code (e.g., `fin-01`) + +Example: `"gpu-{random}-{location}"` expands to something like `"gpu-cold-cable-smiles-fin-01"`. + +The pattern is expanded only when `hostname_pattern` is set and no explicit `--hostname` flag is provided. + +## Skip Flags + +Templates can explicitly mark steps as skipped so the wizard does not re-ask: + +- **`storage_skip: true`** -- skip the additional storage step entirely (do not prompt for NVMe/HDD volumes) +- **`startup_script_skip: true`** -- skip the startup script step entirely (do not prompt for a script) + +These are captured when the user selects "None (skip)" during template creation and prevent the wizard from treating the empty value as "not yet filled." + +## Architecture Notes + +### Files + +- **template.go** -- Parent command definition (`verda template`), registers subcommands +- **create.go** -- `template create` command; prompts for resource type and name, runs VM wizard in template mode, saves result +- **list.go** -- `template list` command; lists entries with auto-description, supports `--type` filter and structured output +- **show.go** -- `template show` command; displays template fields, supports `-o json` structured output +- **delete.go** -- `template delete` command; loads template to verify existence, confirms, then deletes +- **types.go** -- Re-exports types and functions from `internal/verda-cli/template/` to avoid import cycles + +### Shared Package + +- **`internal/verda-cli/template/template.go`** -- Core types (`Template`, `StorageSpec`, `Entry`), I/O functions (`Save`, `Load`, `LoadFromPath`, `Resolve`, `List`, `ListAll`, `Delete`), name validation, hostname pattern expansion + +### Integration with `vm create` + +- **`cmd/vm/create.go`** -- Defines `--from` flag, calls `resolveCreateInputs` +- **`cmd/vm/template_apply.go`** -- `loadTemplateRef`, `applyTemplate`, `resolveTemplateNames`, `printTemplateSummary` +- **`cmd/vm/wizard.go`** -- `WizardMode`, `RunTemplateWizard`, `TemplateResult`, `ensurePricingCache` diff --git a/internal/verda-cli/cmd/template/create.go b/internal/verda-cli/cmd/template/create.go new file mode 100644 index 0000000..6b7ddd9 --- /dev/null +++ b/internal/verda-cli/cmd/template/create.go @@ -0,0 +1,181 @@ +package template + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" + + "github.com/spf13/cobra" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + "github/verda-cloud/verda-cli/internal/verda-cli/cmd/vm" + clioptions "github/verda-cloud/verda-cli/internal/verda-cli/options" +) + +var resourceTypes = []string{"Instance (VM)"} +var resourceMap = map[int]string{0: "vm"} + +// NewCmdCreate creates the template create command. +func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "create [name]", + Short: "Create a new resource template interactively", + Long: cmdutil.LongDesc(` + Create a reusable resource configuration template by running + the interactive wizard. The wizard collects instance type, + image, location, SSH keys, storage, and other settings. + + Templates are saved as YAML files under ~/.verda/templates//. + Names are auto-reformatted: "My GPU Setup" becomes "my-gpu-setup". + + After saving, use "verda vm create --from " to create + instances with pre-filled settings. + + You can manually edit the template YAML to add features like: + hostname_pattern: "gpu-{random}-{location}" + storage_skip: true + startup_script_skip: true + `), + Example: cmdutil.Examples(` + # Create a template interactively + verda template create + + # Create with a name (skips name prompt) + verda template create gpu-training + + # Then use it to create VMs + verda vm create --from gpu-training + verda vm create --from gpu-training --hostname my-vm + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + var name string + if len(args) > 0 { + name = args[0] + } + return runCreate(cmd, f, ioStreams, name) + }, + } + + return cmd +} + +func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, name string) error { + ctx := cmd.Context() + prompter := f.Prompter() + + // 1. Select resource type. + idx, err := prompter.Select(ctx, "Resource type", resourceTypes) + if err != nil { + return nil //nolint:nilerr // user cancellation (Ctrl+C) is not an error + } + resource := resourceMap[idx] + + // 2. Resolve templates directory. + verdaDir, err := clioptions.VerdaDir() + if err != nil { + return err + } + baseDir := filepath.Join(verdaDir, "templates") + + // 3. Get and validate template name (re-prompt on invalid input). + for { + if name == "" { + name, err = prompter.TextInput(ctx, "Template name") + if err != nil { + return nil //nolint:nilerr // user cancellation (Ctrl+C) is not an error + } + } + + // Auto-format: lowercase, replace spaces/underscores with hyphens, + // strip invalid characters. + normalized := normalizeName(name) + if normalized != name { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " Reformatted: %s\n", normalized) + name = normalized + } + + if err := ValidateName(name); err != nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " %v (e.g. gpu-training, cheap-dev-01)\n", err) + name = "" // re-prompt + continue + } + + if _, loadErr := Load(baseDir, resource, name); loadErr == nil { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " template %s/%s already exists\n", resource, name) + name = "" // re-prompt + continue + } + + break + } + + // 5. Run resource wizard. + var tmpl *Template + switch resource { + case "vm": + result, err := vm.RunTemplateWizard(ctx, f, ioStreams) + if err != nil { + return err + } + if result == nil { + return nil // user canceled wizard + } + // 6. Convert result to Template. + tmpl = vmResultToTemplate(result) + default: + return fmt.Errorf("unsupported resource type: %s", resource) + } + + // 7. Save to disk. + if err := Save(baseDir, resource, name, tmpl); err != nil { + return err + } + + // 8. Print confirmation. + _, _ = fmt.Fprintf(ioStreams.Out, "Template %s/%s saved\n", resource, name) + return nil +} + +func vmResultToTemplate(r *vm.TemplateResult) *Template { + tmpl := &Template{ + Resource: "vm", + BillingType: r.BillingType, + Contract: r.Contract, + Kind: r.Kind, + InstanceType: r.InstanceType, + Location: r.Location, + Image: r.Image, + OSVolumeSize: r.OSVolumeSize, + SSHKeys: r.SSHKeyNames, + StartupScript: r.StartupScriptName, + StorageSkip: r.StorageSkip, + StartupScriptSkip: r.StartupScriptSkip, + } + if r.StorageSize > 0 { + tmpl.Storage = []StorageSpec{{ + Type: r.StorageType, + Size: r.StorageSize, + }} + } + return tmpl +} + +// invalidChars matches anything that is not lowercase alphanumeric or hyphen. +var invalidChars = regexp.MustCompile(`[^a-z0-9-]+`) + +// normalizeName auto-formats a template name: lowercases, replaces +// spaces and underscores with hyphens, strips other invalid characters, +// and collapses multiple hyphens. +func normalizeName(name string) string { + s := strings.ToLower(strings.TrimSpace(name)) + s = strings.NewReplacer(" ", "-", "_", "-").Replace(s) + s = invalidChars.ReplaceAllString(s, "") + // Collapse multiple hyphens. + for strings.Contains(s, "--") { + s = strings.ReplaceAll(s, "--", "-") + } + s = strings.Trim(s, "-") + return s +} diff --git a/internal/verda-cli/cmd/template/delete.go b/internal/verda-cli/cmd/template/delete.go new file mode 100644 index 0000000..effba71 --- /dev/null +++ b/internal/verda-cli/cmd/template/delete.go @@ -0,0 +1,74 @@ +package template + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + clioptions "github/verda-cloud/verda-cli/internal/verda-cli/options" +) + +// NewCmdDelete creates the template delete command. +func NewCmdDelete(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Aliases: []string{"rm"}, + Short: "Delete a saved template", + Long: cmdutil.LongDesc(` + Delete a saved resource configuration template. + The argument must be in resource/name format (e.g. vm/gpu-training). + `), + Example: cmdutil.Examples(` + # Delete a VM template + verda template delete vm/gpu-training + + # Short alias + verda tmpl rm vm/gpu-training + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runDelete(cmd, f, ioStreams, args[0]) + }, + } + + return cmd +} + +func runDelete(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams, ref string) error { + resource, name, err := parseRef(ref) + if err != nil { + return err + } + + verdaDir, err := clioptions.VerdaDir() + if err != nil { + return err + } + baseDir := filepath.Join(verdaDir, "templates") + + // Verify template exists by loading it first. + if _, err := Load(baseDir, resource, name); err != nil { + return err + } + + // Confirm deletion. + prompter := f.Prompter() + confirmed, err := prompter.Confirm(cmd.Context(), fmt.Sprintf("Delete template %s/%s?", resource, name)) + if err != nil { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil //nolint:nilerr // user cancellation (Ctrl+C) is not an error + } + if !confirmed { + _, _ = fmt.Fprintln(ioStreams.ErrOut, "Canceled.") + return nil + } + + if err := Delete(baseDir, resource, name); err != nil { + return err + } + + _, _ = fmt.Fprintf(ioStreams.Out, "Deleted template: %s/%s\n", resource, name) + return nil +} diff --git a/internal/verda-cli/cmd/template/list.go b/internal/verda-cli/cmd/template/list.go new file mode 100644 index 0000000..44237a9 --- /dev/null +++ b/internal/verda-cli/cmd/template/list.go @@ -0,0 +1,79 @@ +package template + +import ( + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + clioptions "github/verda-cloud/verda-cli/internal/verda-cli/options" +) + +// NewCmdList creates the template list command. +func NewCmdList(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + var resourceType string + + cmd := &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List saved templates", + Long: cmdutil.LongDesc(` + List all saved resource configuration templates. + Use --type to filter by resource type (e.g. "vm"). + `), + Example: cmdutil.Examples(` + # List all templates + verda template list + + # List only VM templates + verda template list --type vm + + # Short alias + verda tmpl ls + `), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runList(f, ioStreams, resourceType) + }, + } + + cmd.Flags().StringVar(&resourceType, "type", "", "Filter by resource type (e.g. vm)") + return cmd +} + +func runList(f cmdutil.Factory, ioStreams cmdutil.IOStreams, resourceType string) error { + verdaDir, err := clioptions.VerdaDir() + if err != nil { + return err + } + baseDir := filepath.Join(verdaDir, "templates") + + var entries []Entry + if resourceType != "" { + entries, err = List(baseDir, resourceType) + } else { + entries, err = ListAll(baseDir) + } + if err != nil { + return err + } + + // Structured output: emit JSON/YAML and return. + if wrote, wErr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), entries); wrote { + return wErr + } + + if len(entries) == 0 { + _, _ = fmt.Fprintln(ioStreams.Out, "No templates found.") + return nil + } + + _, _ = fmt.Fprintf(ioStreams.Out, " %-25s %s\n", "NAME", "DESCRIPTION") + _, _ = fmt.Fprintf(ioStreams.Out, " %-25s %s\n", "----", "-----------") + for _, e := range entries { + displayName := e.Resource + "/" + e.Name + _, _ = fmt.Fprintf(ioStreams.Out, " %-25s %s\n", displayName, e.Description) + } + return nil +} diff --git a/internal/verda-cli/cmd/template/show.go b/internal/verda-cli/cmd/template/show.go new file mode 100644 index 0000000..648ef3a --- /dev/null +++ b/internal/verda-cli/cmd/template/show.go @@ -0,0 +1,106 @@ +package template + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + clioptions "github/verda-cloud/verda-cli/internal/verda-cli/options" +) + +// parseRef splits a "resource/name" reference into its two parts. +func parseRef(ref string) (resource, name string, err error) { + parts := strings.SplitN(ref, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid template reference %q: expected resource/name (e.g. vm/gpu-training)", ref) + } + return parts[0], parts[1], nil +} + +// NewCmdShow creates the template show command. +func NewCmdShow(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "show ", + Short: "Show details of a saved template", + Long: cmdutil.LongDesc(` + Display the full configuration of a saved template. + The argument must be in resource/name format (e.g. vm/gpu-training). + `), + Example: cmdutil.Examples(` + # Show a VM template + verda template show vm/gpu-training + + # Output as JSON + verda template show vm/gpu-training -o json + `), + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runShow(f, ioStreams, args[0]) + }, + } + + return cmd +} + +func runShow(f cmdutil.Factory, ioStreams cmdutil.IOStreams, ref string) error { + resource, name, err := parseRef(ref) + if err != nil { + return err + } + + verdaDir, err := clioptions.VerdaDir() + if err != nil { + return err + } + baseDir := filepath.Join(verdaDir, "templates") + + tmpl, err := Load(baseDir, resource, name) + if err != nil { + return err + } + + // Structured output: emit JSON/YAML and return. + if wrote, wErr := cmdutil.WriteStructured(ioStreams.Out, f.OutputFormat(), tmpl); wrote { + return wErr + } + + templatePath := filepath.Join(baseDir, resource, name+".yaml") + _, _ = fmt.Fprintf(ioStreams.Out, " Template: %s/%s\n", resource, name) + _, _ = fmt.Fprintf(ioStreams.Out, " File: %s\n", templatePath) + _, _ = fmt.Fprintln(ioStreams.Out) + + printField := func(label, value string) { + if value != "" { + _, _ = fmt.Fprintf(ioStreams.Out, " %-14s %s\n", label, value) + } + } + + printField("Resource:", tmpl.Resource) + printField("Billing:", tmpl.BillingType) + printField("Contract:", tmpl.Contract) + printField("Kind:", tmpl.Kind) + printField("Type:", tmpl.InstanceType) + printField("Location:", tmpl.Location) + printField("Image:", tmpl.Image) + + if tmpl.OSVolumeSize > 0 { + _, _ = fmt.Fprintf(ioStreams.Out, " %-14s %d GiB\n", "OS Volume:", tmpl.OSVolumeSize) + } + + if len(tmpl.Storage) > 0 { + for _, s := range tmpl.Storage { + _, _ = fmt.Fprintf(ioStreams.Out, " %-14s %s %d GiB\n", "Storage:", s.Type, s.Size) + } + } + + if len(tmpl.SSHKeys) > 0 { + printField("SSH Keys:", strings.Join(tmpl.SSHKeys, ", ")) + } + + printField("Startup:", tmpl.StartupScript) + + return nil +} diff --git a/internal/verda-cli/cmd/template/template.go b/internal/verda-cli/cmd/template/template.go new file mode 100644 index 0000000..171e1b7 --- /dev/null +++ b/internal/verda-cli/cmd/template/template.go @@ -0,0 +1,29 @@ +package template + +import ( + "github.com/spf13/cobra" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" +) + +// NewCmdTemplate creates the parent template command. +func NewCmdTemplate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command { + cmd := &cobra.Command{ + Use: "template", + Aliases: []string{"tmpl"}, + Short: "Manage reusable resource templates", + Long: cmdutil.LongDesc(` + Save, list, show, and delete reusable resource configuration templates. + Templates pre-fill the create wizard so you don't repeat the same settings. + `), + Run: cmdutil.DefaultSubCommandRun(ioStreams.Out), + } + + cmd.AddCommand( + NewCmdCreate(f, ioStreams), + NewCmdList(f, ioStreams), + NewCmdShow(f, ioStreams), + NewCmdDelete(f, ioStreams), + ) + return cmd +} diff --git a/internal/verda-cli/cmd/template/types.go b/internal/verda-cli/cmd/template/types.go new file mode 100644 index 0000000..4cb694b --- /dev/null +++ b/internal/verda-cli/cmd/template/types.go @@ -0,0 +1,26 @@ +package template + +import ( + tmpl "github/verda-cloud/verda-cli/internal/verda-cli/template" +) + +// Re-export types from the shared template package so that existing +// code in cmd/template continues to compile without changes. +type ( + Template = tmpl.Template + StorageSpec = tmpl.StorageSpec + Entry = tmpl.Entry +) + +// Re-export functions from the shared template package. +var ( + ValidateName = tmpl.ValidateName + Save = tmpl.Save + Load = tmpl.Load + LoadFromPath = tmpl.LoadFromPath + Resolve = tmpl.Resolve + List = tmpl.List + ListAll = tmpl.ListAll + Delete = tmpl.Delete + ExpandHostnamePattern = tmpl.ExpandHostnamePattern +) diff --git a/internal/verda-cli/cmd/template/types_test.go b/internal/verda-cli/cmd/template/types_test.go new file mode 100644 index 0000000..213f077 --- /dev/null +++ b/internal/verda-cli/cmd/template/types_test.go @@ -0,0 +1,732 @@ +package template + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTemplateSaveAndLoad(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + tmpl := &Template{ + Resource: "vm", + BillingType: "on_demand", + Contract: "monthly", + Kind: "gpu", + InstanceType: "A100x4", + Location: "FIN-01", + Image: "ubuntu-22.04", + OSVolumeSize: 100, + Storage: []StorageSpec{{Type: "ssd", Size: 500}, {Type: "hdd", Size: 1000}}, + SSHKeys: []string{"my-key", "work-key"}, + StartupScript: "setup.sh", + } + + if err := Save(dir, "vm", "my-template", tmpl); err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Verify file exists at expected path. + path := filepath.Join(dir, "vm", "my-template.yaml") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected file at %s, got error: %v", path, err) + } + + loaded, err := Load(dir, "vm", "my-template") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + // Verify scalar fields round-trip correctly. + assertStr(t, "Resource", loaded.Resource, tmpl.Resource) + assertStr(t, "BillingType", loaded.BillingType, tmpl.BillingType) + assertStr(t, "Contract", loaded.Contract, tmpl.Contract) + assertStr(t, "Kind", loaded.Kind, tmpl.Kind) + assertStr(t, "InstanceType", loaded.InstanceType, tmpl.InstanceType) + assertStr(t, "Location", loaded.Location, tmpl.Location) + assertStr(t, "Image", loaded.Image, tmpl.Image) + assertStr(t, "StartupScript", loaded.StartupScript, tmpl.StartupScript) + assertInt(t, "OSVolumeSize", loaded.OSVolumeSize, tmpl.OSVolumeSize) + + // Verify Storage round-trip. + if len(loaded.Storage) != 2 { + t.Fatalf("Storage length = %d, want 2", len(loaded.Storage)) + } + if loaded.Storage[0].Type != "ssd" || loaded.Storage[0].Size != 500 { + t.Errorf("Storage[0] = %+v, want {ssd 500}", loaded.Storage[0]) + } + if loaded.Storage[1].Type != "hdd" || loaded.Storage[1].Size != 1000 { + t.Errorf("Storage[1] = %+v, want {hdd 1000}", loaded.Storage[1]) + } + + // Verify SSHKeys round-trip. + if len(loaded.SSHKeys) != 2 || loaded.SSHKeys[0] != "my-key" || loaded.SSHKeys[1] != "work-key" { + t.Errorf("SSHKeys = %v, want [my-key work-key]", loaded.SSHKeys) + } +} + +func assertStr(t *testing.T, field, got, want string) { + t.Helper() + if got != want { + t.Errorf("%s = %q, want %q", field, got, want) + } +} + +func assertInt(t *testing.T, field string, got, want int) { + t.Helper() + if got != want { + t.Errorf("%s = %d, want %d", field, got, want) + } +} + +func TestLoadFromPath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + tmpl := &Template{ + Resource: "vm", + InstanceType: "H100x8", + Location: "US-EAST-1", + } + + if err := Save(dir, "vm", "pathtest", tmpl); err != nil { + t.Fatalf("Save() error = %v", err) + } + + path := filepath.Join(dir, "vm", "pathtest.yaml") + loaded, err := LoadFromPath(path) + if err != nil { + t.Fatalf("LoadFromPath() error = %v", err) + } + + if loaded.InstanceType != "H100x8" { + t.Errorf("InstanceType = %q, want %q", loaded.InstanceType, "H100x8") + } + if loaded.Location != "US-EAST-1" { + t.Errorf("Location = %q, want %q", loaded.Location, "US-EAST-1") + } +} + +func TestLoadFromPath_NotExist(t *testing.T) { + t.Parallel() + + _, err := LoadFromPath("/nonexistent/path/template.yaml") + if err == nil { + t.Fatal("LoadFromPath() expected error for nonexistent file, got nil") + } +} + +func TestList(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Save a few templates. + for _, name := range []string{"alpha", "beta", "gamma"} { + tmpl := &Template{ + Resource: "vm", + InstanceType: "A100x4", + Location: "FIN-01", + Image: "ubuntu-22.04", + } + if err := Save(dir, "vm", name, tmpl); err != nil { + t.Fatalf("Save(%q) error = %v", name, err) + } + } + + entries, err := List(dir, "vm") + if err != nil { + t.Fatalf("List() error = %v", err) + } + + if len(entries) != 3 { + t.Fatalf("List() returned %d entries, want 3", len(entries)) + } + + // Verify entries have expected fields. + names := map[string]bool{} + for _, e := range entries { + names[e.Name] = true + if e.Resource != "vm" { + t.Errorf("entry %q: Resource = %q, want %q", e.Name, e.Resource, "vm") + } + if e.Path == "" { + t.Errorf("entry %q: Path is empty", e.Name) + } + if e.Description == "" { + t.Errorf("entry %q: Description is empty", e.Name) + } + } + for _, name := range []string{"alpha", "beta", "gamma"} { + if !names[name] { + t.Errorf("List() missing entry %q", name) + } + } +} + +func TestList_NonExistentDir(t *testing.T) { + t.Parallel() + + entries, err := List("/nonexistent/base", "vm") + if err != nil { + t.Fatalf("List() error = %v, want nil for nonexistent dir", err) + } + if entries != nil { + t.Errorf("List() = %v, want nil for nonexistent dir", entries) + } +} + +func TestListAll(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Create templates in two resource types. + save := func(resource, name string) { + tmpl := &Template{Resource: resource, InstanceType: "type-" + name} + if err := Save(dir, resource, name, tmpl); err != nil { + t.Fatalf("Save(%q/%q) error = %v", resource, name, err) + } + } + save("vm", "dev") + save("vm", "prod") + save("volume", "big-disk") + + entries, err := ListAll(dir) + if err != nil { + t.Fatalf("ListAll() error = %v", err) + } + + if len(entries) != 3 { + t.Fatalf("ListAll() returned %d entries, want 3", len(entries)) + } + + // Verify we got entries from both resource types. + resources := map[string]int{} + for _, e := range entries { + resources[e.Resource]++ + } + if resources["vm"] != 2 { + t.Errorf("ListAll() vm count = %d, want 2", resources["vm"]) + } + if resources["volume"] != 1 { + t.Errorf("ListAll() volume count = %d, want 1", resources["volume"]) + } +} + +func TestListAll_NonExistentDir(t *testing.T) { + t.Parallel() + + entries, err := ListAll("/nonexistent/base") + if err != nil { + t.Fatalf("ListAll() error = %v, want nil for nonexistent dir", err) + } + if entries != nil { + t.Errorf("ListAll() = %v, want nil for nonexistent dir", entries) + } +} + +func TestDelete(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + tmpl := &Template{Resource: "vm", InstanceType: "A100x4"} + + if err := Save(dir, "vm", "doomed", tmpl); err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Verify it exists. + path := filepath.Join(dir, "vm", "doomed.yaml") + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected file at %s before delete", path) + } + + if err := Delete(dir, "vm", "doomed"); err != nil { + t.Fatalf("Delete() error = %v", err) + } + + // Verify it's gone. + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("expected file to be deleted at %s", path) + } +} + +func TestDelete_NotExist(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + err := Delete(dir, "vm", "nonexistent") + if err == nil { + t.Fatal("Delete() expected error for nonexistent file, got nil") + } +} + +func TestAutoDescription(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + tmpl Template + want string + }{ + { + name: "all fields", + tmpl: Template{InstanceType: "A100x4", Image: "ubuntu-22.04", Location: "FIN-01"}, + want: "A100x4, ubuntu-22.04, FIN-01", + }, + { + name: "instance type only", + tmpl: Template{InstanceType: "H100x8"}, + want: "H100x8", + }, + { + name: "image and location", + tmpl: Template{Image: "debian-12", Location: "US-EAST-1"}, + want: "debian-12, US-EAST-1", + }, + { + name: "empty template", + tmpl: Template{}, + want: "", + }, + { + name: "location only", + tmpl: Template{Location: "EU-WEST-1"}, + want: "EU-WEST-1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.tmpl.AutoDescription() + if got != tt.want { + t.Errorf("AutoDescription() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestValidateName(t *testing.T) { + t.Parallel() + + valid := []string{ + "my-template", + "a", + "template123", + "123template", + "a-b-c", + "dev", + } + for _, name := range valid { + if err := ValidateName(name); err != nil { + t.Errorf("ValidateName(%q) = %v, want nil", name, err) + } + } + + invalid := []struct { + input, wantContains string + }{ + {"", "empty"}, + {"My-Template", "lowercase"}, + {"my template", "lowercase"}, + {"my/template", "lowercase"}, + {"my_template", "lowercase"}, + {"my.template", "lowercase"}, + } + for _, tc := range invalid { + err := ValidateName(tc.input) + if err == nil { + t.Errorf("ValidateName(%q) = nil, want error containing %q", tc.input, tc.wantContains) + } else if !strings.Contains(err.Error(), tc.wantContains) { + t.Errorf("ValidateName(%q) = %v, want error containing %q", tc.input, err, tc.wantContains) + } + } +} + +func TestResolveTemplatePath(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + tmpl := &Template{Resource: "vm", InstanceType: "A100x4"} + + if err := Save(dir, "vm", "resolvable", tmpl); err != nil { + t.Fatalf("Save() error = %v", err) + } + + // Resolve by name. + path, err := Resolve(dir, "vm", "resolvable") + if err != nil { + t.Fatalf("Resolve() by name error = %v", err) + } + expected := filepath.Join(dir, "vm", "resolvable.yaml") + if path != expected { + t.Errorf("Resolve() by name = %q, want %q", path, expected) + } + + // Resolve by file path (absolute). + path, err = Resolve(dir, "vm", expected) + if err != nil { + t.Fatalf("Resolve() by path error = %v", err) + } + if path != expected { + t.Errorf("Resolve() by path = %q, want %q", path, expected) + } + + // Resolve with .yaml suffix (treated as file path, not a name). + // A bare "resolvable.yaml" without a directory should error since it + // is treated as a file path and the file doesn't exist at cwd. + _, err = Resolve(dir, "vm", "resolvable.yaml") + if err == nil { + t.Fatal("Resolve() with bare .yaml suffix expected error, got nil") + } + + // Resolve with absolute .yaml path should work. + absYamlRef := filepath.Join(dir, "vm", "resolvable.yaml") + path, err = Resolve(dir, "vm", absYamlRef) + if err != nil { + t.Fatalf("Resolve() with absolute .yaml path error = %v", err) + } + if path != absYamlRef { + t.Errorf("Resolve() = %q, want %q", path, absYamlRef) + } + + // Resolve with slash (absolute path containing /). + slashRef := dir + "/vm/resolvable.yaml" + path, err = Resolve(dir, "vm", slashRef) + if err != nil { + t.Fatalf("Resolve() with slash error = %v", err) + } + if path != slashRef { + t.Errorf("Resolve() with slash = %q, want %q", path, slashRef) + } + + // Resolve nonexistent name. + _, err = Resolve(dir, "vm", "nonexistent") + if err == nil { + t.Fatal("Resolve() expected error for nonexistent name, got nil") + } +} + +func TestTemplateSaveAndLoad_AllFields(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + tmpl := &Template{ + Resource: "vm", + BillingType: "spot", + Contract: "PAY_AS_YOU_GO", + Kind: "gpu", + InstanceType: "H100x8", + Location: "FIN-03", + Image: "ubuntu-24.04-cuda-12.8", + OSVolumeSize: 200, + Storage: []StorageSpec{{Type: "NVMe", Size: 500}, {Type: "HDD", Size: 2000}}, + StorageSkip: true, + SSHKeys: []string{"key-a", "key-b", "key-c"}, + StartupScript: "my-startup.sh", + StartupScriptSkip: true, + HostnamePattern: "gpu-{random}-{location}", + } + + if err := Save(dir, "vm", "all-fields", tmpl); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := Load(dir, "vm", "all-fields") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + assertStr(t, "Resource", loaded.Resource, tmpl.Resource) + assertStr(t, "BillingType", loaded.BillingType, tmpl.BillingType) + assertStr(t, "Contract", loaded.Contract, tmpl.Contract) + assertStr(t, "Kind", loaded.Kind, tmpl.Kind) + assertStr(t, "InstanceType", loaded.InstanceType, tmpl.InstanceType) + assertStr(t, "Location", loaded.Location, tmpl.Location) + assertStr(t, "Image", loaded.Image, tmpl.Image) + assertInt(t, "OSVolumeSize", loaded.OSVolumeSize, tmpl.OSVolumeSize) + assertStr(t, "StartupScript", loaded.StartupScript, tmpl.StartupScript) + assertStr(t, "HostnamePattern", loaded.HostnamePattern, tmpl.HostnamePattern) + + if !loaded.StorageSkip { + t.Error("StorageSkip = false, want true") + } + if !loaded.StartupScriptSkip { + t.Error("StartupScriptSkip = false, want true") + } + + if len(loaded.Storage) != 2 { + t.Fatalf("Storage length = %d, want 2", len(loaded.Storage)) + } + if loaded.Storage[0].Type != "NVMe" || loaded.Storage[0].Size != 500 { + t.Errorf("Storage[0] = %+v, want {NVMe 500}", loaded.Storage[0]) + } + if loaded.Storage[1].Type != "HDD" || loaded.Storage[1].Size != 2000 { + t.Errorf("Storage[1] = %+v, want {HDD 2000}", loaded.Storage[1]) + } + + if len(loaded.SSHKeys) != 3 { + t.Fatalf("SSHKeys length = %d, want 3", len(loaded.SSHKeys)) + } + for i, want := range []string{"key-a", "key-b", "key-c"} { + if loaded.SSHKeys[i] != want { + t.Errorf("SSHKeys[%d] = %q, want %q", i, loaded.SSHKeys[i], want) + } + } +} + +func TestTemplateSaveAndLoad_SkipFlags(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + tmpl := &Template{ + Resource: "vm", + InstanceType: "A100x4", + Location: "FIN-01", + Image: "ubuntu-22.04", + StorageSkip: true, + StartupScriptSkip: true, + } + + if err := Save(dir, "vm", "skip-flags", tmpl); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := Load(dir, "vm", "skip-flags") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + if !loaded.StorageSkip { + t.Error("StorageSkip = false, want true") + } + if !loaded.StartupScriptSkip { + t.Error("StartupScriptSkip = false, want true") + } + // Verify other fields are still correct. + assertStr(t, "InstanceType", loaded.InstanceType, "A100x4") + assertStr(t, "Location", loaded.Location, "FIN-01") +} + +func TestTemplateSaveAndLoad_HostnamePattern(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + tmpl := &Template{ + Resource: "vm", + InstanceType: "H100x8", + HostnamePattern: "gpu-{random}-{location}", + } + + if err := Save(dir, "vm", "hostname-pat", tmpl); err != nil { + t.Fatalf("Save() error = %v", err) + } + + loaded, err := Load(dir, "vm", "hostname-pat") + if err != nil { + t.Fatalf("Load() error = %v", err) + } + + assertStr(t, "HostnamePattern", loaded.HostnamePattern, "gpu-{random}-{location}") +} + +func TestList_MultipleResourceTypes(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + // Save templates in vm/ and cluster/ subdirs. + save := func(resource, name string) { + tmpl := &Template{Resource: resource, InstanceType: "type-" + name} + if err := Save(dir, resource, name, tmpl); err != nil { + t.Fatalf("Save(%q/%q) error = %v", resource, name, err) + } + } + save("vm", "dev-vm") + save("vm", "prod-vm") + save("cluster", "small-cluster") + save("cluster", "large-cluster") + + // ListAll should return entries from both resource types. + entries, err := ListAll(dir) + if err != nil { + t.Fatalf("ListAll() error = %v", err) + } + + if len(entries) != 4 { + t.Fatalf("ListAll() returned %d entries, want 4", len(entries)) + } + + resources := map[string]int{} + for _, e := range entries { + resources[e.Resource]++ + } + if resources["vm"] != 2 { + t.Errorf("ListAll() vm count = %d, want 2", resources["vm"]) + } + if resources["cluster"] != 2 { + t.Errorf("ListAll() cluster count = %d, want 2", resources["cluster"]) + } +} + +func TestValidateName_EdgeCases(t *testing.T) { + t.Parallel() + + tests := []struct { + input string + wantErr bool + desc string + }{ + {"a", false, "single char"}, + {"123", false, "numbers only"}, + {"a-b-c-d-e-f-g-h-i-j-k-l-m-n-o-p", false, "long name with hyphens"}, + {"abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnopqrstuvwxyz", false, "very long name"}, + {"bad-", false, "trailing hyphen is valid per regex"}, // nameRe = ^[a-z0-9][a-z0-9-]*$ + {"-bad", true, "leading hyphen is invalid"}, + {"", true, "empty name"}, + {"UPPER", true, "uppercase letters"}, + {"has space", true, "contains space"}, + {"has.dot", true, "contains dot"}, + {"has_underscore", true, "contains underscore"}, + {"has/slash", true, "contains slash"}, + } + + for _, tt := range tests { + t.Run(tt.desc, func(t *testing.T) { + t.Parallel() + err := ValidateName(tt.input) + if tt.wantErr && err == nil { + t.Errorf("ValidateName(%q) = nil, want error", tt.input) + } + if !tt.wantErr && err != nil { + t.Errorf("ValidateName(%q) = %v, want nil", tt.input, err) + } + }) + } +} + +func TestAutoDescription_WithSkips(t *testing.T) { + t.Parallel() + + // A template with StorageSkip=true should still generate description + // from InstanceType, Image, and Location. + tmpl := Template{ + InstanceType: "A100x4", + Image: "ubuntu-22.04", + Location: "FIN-01", + StorageSkip: true, + StartupScriptSkip: true, + } + got := tmpl.AutoDescription() + want := "A100x4, ubuntu-22.04, FIN-01" + if got != want { + t.Errorf("AutoDescription() = %q, want %q", got, want) + } +} + +func TestResolve_FilePathWithYamlSuffix(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + tmpl := &Template{Resource: "vm", InstanceType: "A100x4"} + + if err := Save(dir, "vm", "my-template", tmpl); err != nil { + t.Fatalf("Save() error = %v", err) + } + + // "my-template.yaml" without a directory prefix is treated as a file path + // (because it ends with .yaml), so it should fail since the file doesn't + // exist at that relative path. + _, err := Resolve(dir, "vm", "my-template.yaml") + if err == nil { + t.Fatal("Resolve() with bare .yaml suffix expected error, got nil") + } + + // An absolute path with .yaml suffix should succeed. + absPath := filepath.Join(dir, "vm", "my-template.yaml") + path, err := Resolve(dir, "vm", absPath) + if err != nil { + t.Fatalf("Resolve() with absolute .yaml path error = %v", err) + } + if path != absPath { + t.Errorf("Resolve() = %q, want %q", path, absPath) + } +} + +func TestResolve_RelativePathWithSlash(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + tmpl := &Template{Resource: "vm", InstanceType: "H100x8"} + + if err := Save(dir, "vm", "test", tmpl); err != nil { + t.Fatalf("Save() error = %v", err) + } + + // A reference containing "/" is treated as a file path. + // Use the actual absolute path to simulate "path with slash". + refWithSlash := dir + "/vm/test.yaml" + path, err := Resolve(dir, "vm", refWithSlash) + if err != nil { + t.Fatalf("Resolve() with slash error = %v", err) + } + if path != refWithSlash { + t.Errorf("Resolve() = %q, want %q", path, refWithSlash) + } + + // A nonexistent path with slash should fail. + _, err = Resolve(dir, "vm", "./templates/nonexistent.yaml") + if err == nil { + t.Fatal("Resolve() expected error for nonexistent path with slash, got nil") + } +} + +func TestExpandHostnamePattern(t *testing.T) { + t.Parallel() + + tests := []struct { + pattern string + location string + check func(string) bool + desc string + }{ + { + pattern: "gpu-{random}", + check: func(s string) bool { + return strings.HasPrefix(s, "gpu-") && len(s) > 4 + }, + desc: "prefix with random", + }, + { + pattern: "train-{location}", + location: "FIN-03", + check: func(s string) bool { return s == "train-fin-03" }, + desc: "location placeholder", + }, + { + pattern: "{random}-{location}", + location: "FIN-01", + check: func(s string) bool { + return strings.HasSuffix(s, "-fin-01") && len(s) > 7 + }, + desc: "random + location", + }, + { + pattern: "static-name", + check: func(s string) bool { return s == "static-name" }, + desc: "no placeholders", + }, + } + + for _, tt := range tests { + result := ExpandHostnamePattern(tt.pattern, tt.location) + if !tt.check(result) { + t.Errorf("%s: ExpandHostnamePattern(%q, %q) = %q, unexpected", + tt.desc, tt.pattern, tt.location, result) + } + } +} diff --git a/internal/verda-cli/cmd/vm/create.go b/internal/verda-cli/cmd/vm/create.go index 6b019f4..c9f9b15 100644 --- a/internal/verda-cli/cmd/vm/create.go +++ b/internal/verda-cli/cmd/vm/create.go @@ -28,6 +28,7 @@ type createOptions struct { Hostname string Description string Kind string + From string // --from template name or path SSHKeyIDs []string LocationCode string @@ -48,6 +49,14 @@ type createOptions struct { StorageOnSpotDiscontinue string Wait cmdutil.WaitOptions + + // Internal flags for template/wizard coordination. + sshKeyNames []string // names corresponding to SSHKeyIDs (for template saving) + startupScriptName string // name corresponding to StartupScriptID (for template saving) + billingTypeSet bool // true when billing type was pre-filled + locationSet bool // true when location was pre-filled + storageSkip bool // true when storage was explicitly skipped + startupScriptSkip bool // true when startup script was explicitly skipped } // NewCmdCreate creates the vm create cobra command. @@ -61,9 +70,27 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command Use: "create", Short: "Create a VM instance", Long: cmdutil.LongDesc(` - Create a Verda VM instance using the official Verda Cloud Go SDK. + Create a Verda VM instance. Without flags, launches an interactive + wizard. Use --from to pre-fill settings from a saved template. + + Templates are created with "verda template create" and stored + as YAML files under ~/.verda/templates/vm/. `), Example: cmdutil.Examples(` + # Interactive wizard + verda vm create + + # From a saved template (only prompts for hostname + confirm) + verda vm create --from gpu-training + verda vm create --from gpu-training --hostname my-vm + + # Pick template from list + verda vm create --from + + # From a template file + verda vm create --from ./my-template.yaml + + # Non-interactive with all flags verda vm create \ --kind gpu \ --instance-type 1V100.6V \ @@ -71,21 +98,17 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command --os ubuntu-24.04-cuda-13.0-open-docker \ --os-volume-size 100 \ --hostname gpu-runner \ - --description "GPU runner for batch jobs" \ --ssh-key ssh_key_123 - - verda vm create \ - --kind cpu \ - --instance-type CPU.4V.16G \ - --location FIN-03 \ - --os ubuntu-24.04 \ - --os-volume-size 55 \ - --hostname training-node \ - --is-spot \ - --storage-size 500 `), - Args: cobra.NoArgs, + Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + // --from with NoOptDefVal: "verda vm create --from gpu-training" + // leaves "gpu-training" as a positional arg. Recombine it. + if cmd.Flags().Changed("from") && strings.TrimSpace(opts.From) == "" && len(args) == 1 { + opts.From = args[0] + } else if len(args) > 0 { + return cmdutil.UsageErrorf(cmd, "unexpected argument %q", args[0]) + } return runCreate(cmd, f, ioStreams, opts) }, } @@ -117,6 +140,8 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command flags.IntVar(&opts.StorageSize, "storage-size", 0, "Size of the optional additional storage volume in GiB") flags.StringVar(&opts.StorageType, "storage-type", opts.StorageType, "Type of the optional additional storage volume") flags.StringVar(&opts.StorageOnSpotDiscontinue, "storage-on-spot-discontinue", "", "Spot discontinue policy for the optional additional storage volume") + flags.StringVar(&opts.From, "from", "", "Create from a saved template; use alone to pick from list") + flags.Lookup("from").NoOptDefVal = " " // allow --from without value (shows picker) _ = flags.MarkHidden("type") _ = flags.MarkHidden("image") _ = flags.MarkHidden("ssh-key-id") @@ -139,11 +164,9 @@ func runCreate(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream return cmdutil.NewMissingFlagsError(missing) } - // If any required field is missing, run the interactive wizard. - if opts.InstanceType == "" || opts.Image == "" || opts.Hostname == "" { - if err := runWizard(cmd.Context(), f, ioStreams, opts); err != nil { - return err - } + // Template + wizard: apply template if --from is used, then fill remaining fields. + if done, err := resolveCreateInputs(cmd, f, ioStreams, client, opts); done || err != nil { + return err } req, err := opts.request() @@ -214,7 +237,7 @@ func missingCreateFlags(opts *createOptions) []string { } func runWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) error { - flow := buildCreateFlow(f.VerdaClient, opts) + flow := buildCreateFlow(f.VerdaClient, opts, WizardModeDeploy) engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) return engine.Run(ctx, flow) } @@ -304,11 +327,11 @@ func normalizeContract(value string) (string, error) { case "": return "", nil case "pay_as_go", "pay-as-go", "pay_as_you_go", "pay-as-you-go", "payg", "pay as go", "pay as you go": - return "PAY_AS_YOU_GO", nil + return contractPayAsYouGo, nil case "spot": - return "SPOT", nil + return contractSpot, nil case "long_term", "long-term", "long term": - return "LONG_TERM", nil + return contractLongTerm, nil case "1 month", "3 months", "6 months", "1 year", "2 years", "1_month", "3_months", "6_months", "1_year", "2_years": return "", errors.New("the current POST /v1/instances public API does not accept long-term duration values on instance creation; use --contract long_term only if your backend supports it") default: diff --git a/internal/verda-cli/cmd/vm/template_apply.go b/internal/verda-cli/cmd/vm/template_apply.go new file mode 100644 index 0000000..4c67cfb --- /dev/null +++ b/internal/verda-cli/cmd/vm/template_apply.go @@ -0,0 +1,265 @@ +package vm + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "github.com/verda-cloud/verdacloud-sdk-go/pkg/verda" + + cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util" + clioptions "github/verda-cloud/verda-cli/internal/verda-cli/options" + "github/verda-cloud/verda-cli/internal/verda-cli/template" +) + +// resolveCreateInputs handles template loading and wizard invocation. +// Returns (true, nil) if the caller should stop (user canceled), or (false, nil) +// to continue with the create flow. +func resolveCreateInputs( + cmd *cobra.Command, + f cmdutil.Factory, + ioStreams cmdutil.IOStreams, + client *verda.Client, + opts *createOptions, +) (done bool, err error) { + // Load template when --from is used. + if cmd.Flags().Changed("from") { + ref := strings.TrimSpace(opts.From) + if err := applyTemplateFrom(cmd.Context(), f, ioStreams, client, opts, ref); err != nil { + return true, err + } + } + + // Run wizard for any remaining missing fields. + if opts.InstanceType == "" || opts.Image == "" || opts.Hostname == "" { + if err := runWizard(cmd.Context(), f, ioStreams, opts); err != nil { + return true, err + } + } + + return false, nil +} + +// applyTemplateFrom loads a template, applies its values to opts, resolves +// SSH key / startup script names to IDs, and prints a summary. +// If ref is empty, shows an interactive picker; otherwise loads by name or path. +func applyTemplateFrom( + ctx context.Context, + f cmdutil.Factory, + ioStreams cmdutil.IOStreams, + client *verda.Client, + opts *createOptions, + ref string, +) error { + verdaDir, err := clioptions.VerdaDir() + if err != nil { + return err + } + baseDir := filepath.Join(verdaDir, "templates") + + tmpl, err := loadTemplateRef(ctx, f, baseDir, ref) + if err != nil { + return err + } + if tmpl == nil { + return nil // user canceled picker + } + + applyTemplate(tmpl, opts) + + resolveCtx, resolveCancel := context.WithTimeout(ctx, f.Options().Timeout) + defer resolveCancel() + warnings := resolveTemplateNames(resolveCtx, client, tmpl, opts) + + printTemplateSummary(ioStreams, tmpl) + for _, w := range warnings { + _, _ = fmt.Fprintf(ioStreams.ErrOut, " Warning: %s -- will prompt during wizard\n", w) + } + + return nil +} + +// loadTemplateRef loads a template by name/path, or shows a picker when ref is empty. +func loadTemplateRef(ctx context.Context, f cmdutil.Factory, baseDir, ref string) (*template.Template, error) { + if ref == "" { + return pickTemplate(ctx, f, baseDir) + } + path, err := template.Resolve(baseDir, "vm", ref) + if err != nil { + return nil, err + } + return template.LoadFromPath(path) +} + +// pickTemplate shows an interactive picker of saved VM templates. +// Returns nil if no templates exist or the user cancels. +func pickTemplate(ctx context.Context, f cmdutil.Factory, baseDir string) (*template.Template, error) { + entries, err := template.List(baseDir, "vm") + if err != nil { + return nil, err + } + if len(entries) == 0 { + return nil, fmt.Errorf("no VM templates found in %s", filepath.Join(baseDir, "vm")) + } + + labels := make([]string, len(entries)) + for i, e := range entries { + labels[i] = fmt.Sprintf("%-20s %s", e.Name, e.Description) + } + + idx, err := f.Prompter().Select(ctx, "Select a template", labels) + if err != nil { + return nil, nil //nolint:nilerr // user canceled + } + + return template.LoadFromPath(entries[idx].Path) +} + +// applyTemplate pre-fills createOptions from a template. +func applyTemplate(tmpl *template.Template, opts *createOptions) { + if tmpl.BillingType != "" { + opts.IsSpot = tmpl.BillingType == billingTypeSpot + opts.billingTypeSet = true + } + if tmpl.Contract != "" { + opts.Contract = tmpl.Contract + } + if tmpl.Kind != "" { + opts.Kind = tmpl.Kind + } + if tmpl.InstanceType != "" { + opts.InstanceType = tmpl.InstanceType + } + if tmpl.Location != "" { + opts.LocationCode = tmpl.Location + opts.locationSet = true + } + if tmpl.Image != "" { + opts.Image = tmpl.Image + } + if tmpl.OSVolumeSize != 0 { + opts.OSVolumeSize = tmpl.OSVolumeSize + } + if len(tmpl.Storage) > 0 { + // Only the first storage entry is applied — the wizard's convenience + // flags (StorageSize/StorageType) support a single additional volume. + opts.StorageSize = tmpl.Storage[0].Size + opts.StorageType = tmpl.Storage[0].Type + } + if tmpl.StorageSkip { + opts.storageSkip = true + } + if tmpl.StartupScriptSkip { + opts.startupScriptSkip = true + } + // Hostname pattern: expand {random} and {location} placeholders. + if tmpl.HostnamePattern != "" && opts.Hostname == "" { + opts.Hostname = template.ExpandHostnamePattern(tmpl.HostnamePattern, opts.LocationCode) + } + // SSH keys and startup script are handled by resolveTemplateNames, not here. +} + +// resolveTemplateNames resolves SSH key names and startup script name to IDs. +// Returns warnings for any names that couldn't be resolved. +func resolveTemplateNames(ctx context.Context, client *verda.Client, tmpl *template.Template, opts *createOptions) []string { + sshWarnings := resolveSSHKeyNames(ctx, client, tmpl.SSHKeys, opts) + scriptWarnings := resolveStartupScriptName(ctx, client, tmpl.StartupScript, opts) + warnings := make([]string, 0, len(sshWarnings)+len(scriptWarnings)) + warnings = append(warnings, sshWarnings...) + warnings = append(warnings, scriptWarnings...) + return warnings +} + +// resolveSSHKeyNames resolves SSH key names to IDs via the API. +// On API errors, returns no warnings (wizard will prompt later). +func resolveSSHKeyNames(ctx context.Context, client *verda.Client, names []string, opts *createOptions) []string { + if len(names) == 0 { + return nil + } + keys, err := client.SSHKeys.GetAllSSHKeys(ctx) + if err != nil { + return nil // silently skip; wizard will prompt later + } + + nameToID := make(map[string]string, len(keys)) + for _, k := range keys { + nameToID[k.Name] = k.ID + } + + var warnings []string + for _, name := range names { + id, ok := nameToID[name] + if !ok { + warnings = append(warnings, fmt.Sprintf("SSH key %q not found", name)) + continue + } + opts.SSHKeyIDs = append(opts.SSHKeyIDs, id) + opts.sshKeyNames = append(opts.sshKeyNames, name) + } + return warnings +} + +// resolveStartupScriptName resolves a startup script name to its ID via the API. +// On API errors, returns no warnings (wizard will prompt later). +func resolveStartupScriptName(ctx context.Context, client *verda.Client, name string, opts *createOptions) []string { + if name == "" { + return nil + } + scripts, err := client.StartupScripts.GetAllStartupScripts(ctx) + if err != nil { + return nil // silently skip; wizard will prompt later + } + + for _, s := range scripts { + if s.Name == name { + opts.StartupScriptID = s.ID + opts.startupScriptName = s.Name + return nil + } + } + return []string{fmt.Sprintf("startup script %q not found", name)} +} + +// printTemplateSummary displays the template values being used. +func printTemplateSummary(ioStreams cmdutil.IOStreams, tmpl *template.Template) { + w := ioStreams.ErrOut + + _, _ = fmt.Fprintln(w, " Using template:") + _, _ = fmt.Fprintln(w) + + if tmpl.BillingType != "" { + _, _ = fmt.Fprintf(w, " %-14s %s\n", "Billing:", tmpl.BillingType) + } + if tmpl.Kind != "" { + _, _ = fmt.Fprintf(w, " %-14s %s\n", "Kind:", strings.ToUpper(tmpl.Kind)) + } + if tmpl.InstanceType != "" { + _, _ = fmt.Fprintf(w, " %-14s %s\n", "Type:", tmpl.InstanceType) + } + if tmpl.Location != "" { + _, _ = fmt.Fprintf(w, " %-14s %s\n", "Location:", tmpl.Location) + } + if tmpl.Image != "" { + _, _ = fmt.Fprintf(w, " %-14s %s\n", "Image:", tmpl.Image) + } + if tmpl.OSVolumeSize != 0 { + _, _ = fmt.Fprintf(w, " %-14s %d GiB\n", "OS Volume:", tmpl.OSVolumeSize) + } + if len(tmpl.Storage) > 0 { + s := tmpl.Storage[0] + _, _ = fmt.Fprintf(w, " %-14s %s %d GiB\n", "Storage:", s.Type, s.Size) + } + if len(tmpl.SSHKeys) > 0 { + _, _ = fmt.Fprintf(w, " %-14s %s\n", "SSH Keys:", strings.Join(tmpl.SSHKeys, ", ")) + } + if tmpl.StartupScript != "" { + _, _ = fmt.Fprintf(w, " %-14s %s\n", "Startup:", tmpl.StartupScript) + } + if tmpl.HostnamePattern != "" { + _, _ = fmt.Fprintf(w, " %-14s %s\n", "Hostname:", tmpl.HostnamePattern) + } + + _, _ = fmt.Fprintln(w) +} diff --git a/internal/verda-cli/cmd/vm/template_apply_test.go b/internal/verda-cli/cmd/vm/template_apply_test.go new file mode 100644 index 0000000..6e782b3 --- /dev/null +++ b/internal/verda-cli/cmd/vm/template_apply_test.go @@ -0,0 +1,305 @@ +package vm + +import ( + "strings" + "testing" + + "github/verda-cloud/verda-cli/internal/verda-cli/template" +) + +func TestApplyTemplate(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + BillingType: "spot", + Contract: "PAY_AS_YOU_GO", + Kind: "GPU", + InstanceType: "1V100.6V", + Location: "FIN-01", + Image: "ubuntu-24.04-cuda-12.8", + OSVolumeSize: 200, + Storage: []template.StorageSpec{{Type: "NVMe", Size: 500}}, + } + + opts := &createOptions{} + applyTemplate(tmpl, opts) + + if !opts.IsSpot { + t.Error("expected IsSpot=true for billing_type=spot") + } + if opts.Contract != "PAY_AS_YOU_GO" { + t.Errorf("Contract = %q, want PAY_AS_YOU_GO", opts.Contract) + } + if opts.Kind != "GPU" { + t.Errorf("Kind = %q, want GPU", opts.Kind) + } + if opts.InstanceType != "1V100.6V" { + t.Errorf("InstanceType = %q, want 1V100.6V", opts.InstanceType) + } + if opts.LocationCode != "FIN-01" { + t.Errorf("LocationCode = %q, want FIN-01", opts.LocationCode) + } + if opts.Image != "ubuntu-24.04-cuda-12.8" { + t.Errorf("Image = %q, want ubuntu-24.04-cuda-12.8", opts.Image) + } + if opts.OSVolumeSize != 200 { + t.Errorf("OSVolumeSize = %d, want 200", opts.OSVolumeSize) + } + if opts.StorageSize != 500 { + t.Errorf("StorageSize = %d, want 500", opts.StorageSize) + } + if opts.StorageType != "NVMe" { + t.Errorf("StorageType = %q, want NVMe", opts.StorageType) + } +} + +func TestApplyTemplate_OnDemand(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + BillingType: "on-demand", + } + + opts := &createOptions{} + applyTemplate(tmpl, opts) + + if opts.IsSpot { + t.Error("expected IsSpot=false for billing_type=on-demand") + } +} + +func TestApplyTemplate_Partial(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + InstanceType: "CPU.4V.16G", + Image: "ubuntu-24.04", + } + + opts := &createOptions{ + LocationCode: "FIN-01", // pre-existing default + StorageType: "NVMe", // pre-existing default + } + applyTemplate(tmpl, opts) + + if opts.InstanceType != "CPU.4V.16G" { + t.Errorf("InstanceType = %q, want CPU.4V.16G", opts.InstanceType) + } + // Unset template fields should not overwrite existing defaults + if opts.LocationCode != "FIN-01" { + t.Errorf("LocationCode = %q, want FIN-01 (should keep default)", opts.LocationCode) + } + if opts.StorageType != "NVMe" { + t.Errorf("StorageType = %q, want NVMe (should keep default)", opts.StorageType) + } +} + +func TestApplyTemplate_SkipFlags(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + BillingType: "on-demand", + Kind: "GPU", + InstanceType: "1V100.6V", + Location: "FIN-01", + Image: "ubuntu-24.04-cuda-12.8", + OSVolumeSize: 50, + StorageSkip: true, + StartupScriptSkip: true, + } + + opts := &createOptions{} + applyTemplate(tmpl, opts) + + if !opts.billingTypeSet { + t.Error("expected billingTypeSet=true") + } + if !opts.locationSet { + t.Error("expected locationSet=true") + } + if !opts.storageSkip { + t.Error("expected storageSkip=true") + } + if !opts.startupScriptSkip { + t.Error("expected startupScriptSkip=true") + } +} + +func TestApplyTemplate_HostnamePattern(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + InstanceType: "1V100.6V", + Location: "FIN-03", + HostnamePattern: "gpu-{random}-{location}", + } + + opts := &createOptions{} + applyTemplate(tmpl, opts) + + // Location should be applied first, then hostname pattern expanded. + if opts.LocationCode != "FIN-03" { + t.Errorf("LocationCode = %q, want FIN-03", opts.LocationCode) + } + + // Hostname should start with "gpu-", end with "-fin-03", and have random words in between. + if !strings.HasPrefix(opts.Hostname, "gpu-") { + t.Errorf("Hostname = %q, expected prefix %q", opts.Hostname, "gpu-") + } + if !strings.HasSuffix(opts.Hostname, "-fin-03") { + t.Errorf("Hostname = %q, expected suffix %q", opts.Hostname, "-fin-03") + } + // Should be longer than just "gpu-" + "-fin-03" = 11 chars, since {random} produces words. + if len(opts.Hostname) <= 11 { + t.Errorf("Hostname = %q, expected longer string with random words", opts.Hostname) + } +} + +func TestApplyTemplate_HostnamePatternNoOverwrite(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + InstanceType: "1V100.6V", + Location: "FIN-01", + HostnamePattern: "gpu-{random}-{location}", + } + + opts := &createOptions{ + Hostname: "my-existing-hostname", + } + applyTemplate(tmpl, opts) + + // The pre-existing hostname should NOT be overwritten by the pattern. + if opts.Hostname != "my-existing-hostname" { + t.Errorf("Hostname = %q, want %q (should not overwrite)", opts.Hostname, "my-existing-hostname") + } +} + +func TestApplyTemplate_HostnamePatternStaticName(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + InstanceType: "CPU.4V.16G", + HostnamePattern: "my-worker", + } + + opts := &createOptions{} + applyTemplate(tmpl, opts) + + // A pattern without placeholders should set the hostname exactly. + if opts.Hostname != "my-worker" { + t.Errorf("Hostname = %q, want %q", opts.Hostname, "my-worker") + } +} + +func TestApplyTemplate_WithStorage(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + InstanceType: "H100x8", + Storage: []template.StorageSpec{{Type: "NVMe", Size: 500}}, + } + + opts := &createOptions{ + StorageType: "NVMe", // default + } + applyTemplate(tmpl, opts) + + if opts.StorageSize != 500 { + t.Errorf("StorageSize = %d, want 500", opts.StorageSize) + } + if opts.StorageType != "NVMe" { + t.Errorf("StorageType = %q, want NVMe", opts.StorageType) + } + if opts.storageSkip { + t.Error("storageSkip should be false when storage is provided") + } +} + +func TestApplyTemplate_WithStorageHDD(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + Storage: []template.StorageSpec{{Type: "HDD", Size: 2000}}, + } + + opts := &createOptions{ + StorageType: "NVMe", // default should be overwritten + } + applyTemplate(tmpl, opts) + + if opts.StorageSize != 2000 { + t.Errorf("StorageSize = %d, want 2000", opts.StorageSize) + } + if opts.StorageType != "HDD" { + t.Errorf("StorageType = %q, want HDD", opts.StorageType) + } +} + +func TestApplyTemplate_StorageSkipAndStartupSkip(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + InstanceType: "A100x4", + StorageSkip: true, + StartupScriptSkip: true, + } + + opts := &createOptions{} + applyTemplate(tmpl, opts) + + if !opts.storageSkip { + t.Error("expected storageSkip=true") + } + if !opts.startupScriptSkip { + t.Error("expected startupScriptSkip=true") + } +} + +func TestApplyTemplate_BillingTypeSetFlag(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + BillingType: "on-demand", + } + + opts := &createOptions{} + applyTemplate(tmpl, opts) + + if !opts.billingTypeSet { + t.Error("expected billingTypeSet=true when template has billing_type") + } + if opts.IsSpot { + t.Error("expected IsSpot=false for billing_type=on-demand") + } +} + +func TestApplyTemplate_LocationSetFlag(t *testing.T) { + t.Parallel() + + tmpl := &template.Template{ + Resource: "vm", + Location: "US-EAST-1", + } + + opts := &createOptions{} + applyTemplate(tmpl, opts) + + if !opts.locationSet { + t.Error("expected locationSet=true when template has location") + } + if opts.LocationCode != "US-EAST-1" { + t.Errorf("LocationCode = %q, want US-EAST-1", opts.LocationCode) + } +} diff --git a/internal/verda-cli/cmd/vm/wizard.go b/internal/verda-cli/cmd/vm/wizard.go index 28b4e27..4086a6d 100644 --- a/internal/verda-cli/cmd/vm/wizard.go +++ b/internal/verda-cli/cmd/vm/wizard.go @@ -23,6 +23,13 @@ import ( const ( billingTypeSpot = "spot" kindGPU = "gpu" + + contractPayAsYouGo = "PAY_AS_YOU_GO" + contractSpot = "SPOT" + contractLongTerm = "LONG_TERM" + + unitLabelGPU = "GPU" + unitLabelVCPU = "vCPU" ) // clientFunc lazily resolves a Verda API client. This allows the wizard @@ -70,35 +77,118 @@ func (c *apiCache) fetchAvailability(ctx context.Context, getClient clientFunc, return c.avail, c.locations, nil } +// WizardMode controls which steps are included in the wizard flow. +type WizardMode int + +const ( + // WizardModeDeploy includes all steps: config + hostname + description + confirm deploy. + WizardModeDeploy WizardMode = iota + // WizardModeTemplate includes only config steps (no hostname, description, confirm). + WizardModeTemplate +) + +// TemplateResult holds the wizard output in a form suitable for template saving. +// It maps internal IDs to human-readable names for SSH keys and startup scripts. +type TemplateResult struct { + BillingType string + Contract string + Kind string + InstanceType string + Location string + Image string + OSVolumeSize int + SSHKeyNames []string + StartupScriptName string + StorageSize int + StorageType string + StorageSkip bool // user explicitly chose "None (skip)" + StartupScriptSkip bool // user explicitly chose "None (skip)" +} + +// RunTemplateWizard runs the VM create wizard in template mode (no hostname, +// description, or confirm-deploy steps). Returns the wizard results for +// saving as a template. +func RunTemplateWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams) (*TemplateResult, error) { + opts := &createOptions{ + LocationCode: verda.LocationFIN01, + StorageType: verda.VolumeTypeNVMe, + } + + flow := buildCreateFlow(f.VerdaClient, opts, WizardModeTemplate) + engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut)) + if err := engine.Run(ctx, flow); err != nil { + return nil, err + } + + return optsToTemplateResult(opts), nil +} + +func optsToTemplateResult(opts *createOptions) *TemplateResult { + result := &TemplateResult{ + Contract: opts.Contract, + Kind: opts.Kind, + InstanceType: opts.InstanceType, + Location: opts.LocationCode, + Image: opts.Image, + OSVolumeSize: opts.OSVolumeSize, + SSHKeyNames: opts.sshKeyNames, + StartupScriptName: opts.startupScriptName, + StorageSize: opts.StorageSize, + StorageType: opts.StorageType, + StorageSkip: opts.StorageSize == 0 && len(opts.VolumeSpecs) == 0, + StartupScriptSkip: opts.StartupScriptID == "" && opts.startupScriptName == "", + } + if opts.IsSpot { + result.BillingType = "spot" + } else { + result.BillingType = "on-demand" + } + return result +} + // buildCreateFlow builds the interactive wizard flow for vm create. // It mirrors the web UI step order: // // billing-type → contract → kind → instance-type → location → // image → os-volume-size → storage-size → ssh-keys → // startup-script → hostname → description -func buildCreateFlow(getClient clientFunc, opts *createOptions) *wizard.Flow { +func buildCreateFlow(getClient clientFunc, opts *createOptions, mode WizardMode) *wizard.Flow { cache := &apiCache{} - return &wizard.Flow{ - Name: "vm-create", - Layout: []wizard.ViewDef{ - {ID: "progress", View: wizard.NewProgressView(wizard.WithProgressPercent())}, - {ID: "hints", View: wizard.NewHintBarView(wizard.WithHintStyle(bubbletea.HintStyle()))}, - }, - Steps: []wizard.Step{ - stepBillingType(opts), - stepContract(getClient, opts), - stepKind(opts), - stepInstanceType(getClient, cache, opts), - stepLocation(getClient, cache, opts), - stepImage(getClient, opts), - stepOSVolumeSize(opts), - stepStorage(getClient, cache, opts), - stepSSHKeys(getClient, opts), - stepStartupScript(getClient, opts), + + steps := []wizard.Step{ + stepBillingType(opts), + stepContract(getClient, opts), + stepKind(opts), + stepInstanceType(getClient, cache, opts), + stepLocation(getClient, cache, opts), + stepImage(getClient, opts), + stepOSVolumeSize(opts), + stepStorage(getClient, cache, opts), + stepSSHKeys(getClient, opts), + stepStartupScript(getClient, opts), + } + if mode == WizardModeDeploy { + steps = append(steps, stepHostname(opts), stepDescription(opts), stepConfirmDeploy(getClient, cache, opts), - }, + ) + } + + layout := []wizard.ViewDef{ + {ID: "hints", View: wizard.NewHintBarView(wizard.WithHintStyle(bubbletea.HintStyle()))}, + } + if mode == WizardModeDeploy { + // Prepend progress bar for deploy mode only + layout = append([]wizard.ViewDef{ + {ID: "progress", View: wizard.NewProgressView(wizard.WithProgressPercent())}, + }, layout...) + } + + return &wizard.Flow{ + Name: "vm-create", + Layout: layout, + Steps: steps, } } @@ -120,7 +210,7 @@ func stepBillingType(opts *createOptions) wizard.Step { Resetter: func() { opts.IsSpot = false }, - IsSet: func() bool { return opts.IsSpot }, + IsSet: func() bool { return opts.billingTypeSet || opts.IsSpot }, Value: func() any { if opts.IsSpot { return billingTypeSpot @@ -144,7 +234,7 @@ func stepContract(getClient clientFunc, opts *createOptions) wizard.Step { }, Loader: func(ctx context.Context, _ tui.Prompter, status tui.Status, store *wizard.Store) ([]wizard.Choice, error) { choices := []wizard.Choice{ - {Label: "Pay as you go", Value: "PAY_AS_YOU_GO"}, + {Label: "Pay as you go", Value: contractPayAsYouGo}, } client, err := getClient() if err != nil { @@ -270,9 +360,9 @@ func stepInstanceType(getClient clientFunc, cache *apiCache, opts *createOptions units := instanceUnits(t) var priceStr string if units > 1 { - unitLabel := "GPU" + unitLabel := unitLabelGPU if t.GPU.NumberOfGPUs == 0 { - unitLabel = "vCPU" + unitLabel = unitLabelVCPU } perUnit := totalPrice / float64(units) priceStr = fmt.Sprintf("$%.3f/%s/hr $%.3f/hr", perUnit, unitLabel, totalPrice) @@ -332,9 +422,11 @@ func stepLocation(getClient clientFunc, cache *apiCache, opts *createOptions) wi }, Setter: func(v any) { opts.LocationCode = v.(string) }, Resetter: func() { opts.LocationCode = verda.LocationFIN01 }, - IsSet: func() bool { return opts.LocationCode != "" && opts.LocationCode != verda.LocationFIN01 }, - Value: func() any { return opts.LocationCode }, - Default: func(_ map[string]any) any { return verda.LocationFIN01 }, + IsSet: func() bool { + return opts.locationSet || (opts.LocationCode != "" && opts.LocationCode != verda.LocationFIN01) + }, + Value: func() any { return opts.LocationCode }, + Default: func(_ map[string]any) any { return verda.LocationFIN01 }, } } @@ -500,8 +592,10 @@ func stepStorage(getClient clientFunc, cache *apiCache, opts *createOptions) wiz }, Setter: func(v any) {}, // Set directly in Loader. Resetter: func() {}, // Don't clear — Loader manages the value. - IsSet: func() bool { return opts.StorageSize > 0 || len(opts.VolumeSpecs) > 0 || len(opts.ExistingVolumes) > 0 }, - Value: func() any { return "" }, + IsSet: func() bool { + return opts.storageSkip || opts.StorageSize > 0 || len(opts.VolumeSpecs) > 0 || len(opts.ExistingVolumes) > 0 + }, + Value: func() any { return "" }, } } @@ -687,8 +781,10 @@ func stepSSHKeys(getClient clientFunc, opts *createOptions) wizard.Step { // with empty choices and calls Resetter, so we must // bypass by returning a sentinel. opts.SSHKeyIDs = make([]string, len(selected)) + opts.sshKeyNames = make([]string, len(selected)) for i, c := range selected { opts.SSHKeyIDs[i] = c.Value + opts.sshKeyNames[i] = c.Label } return nil, nil } @@ -900,6 +996,9 @@ func stepStartupScript(getClient clientFunc, opts *createOptions) wizard.Step { if choices[idx].Value != addNewScriptValue { // Set the value directly and return empty so the engine auto-skips. opts.StartupScriptID = choices[idx].Value + if choices[idx].Value != "" { + opts.startupScriptName = choices[idx].Label + } return nil, nil } @@ -915,7 +1014,7 @@ func stepStartupScript(getClient clientFunc, opts *createOptions) wizard.Step { }, Setter: func(v any) {}, // Set directly in Loader. Resetter: func() {}, // Don't clear — Loader manages the value. - IsSet: func() bool { return opts.StartupScriptID != "" }, + IsSet: func() bool { return opts.startupScriptSkip || opts.StartupScriptID != "" }, Value: func() any { return opts.StartupScriptID }, } } @@ -1063,17 +1162,9 @@ func stepConfirmDeploy(getClient clientFunc, cache *apiCache, opts *createOption Prompt: wizard.ConfirmPrompt, Required: true, Default: func(_ map[string]any) any { - // Fetch volume type pricing (best effort). - if cache.volumeTypes == nil && len(opts.VolumeSpecs) > 0 { - if client, err := getClient(); err == nil { - if vTypes, err := client.VolumeTypes.GetAllVolumeTypes(context.Background()); err == nil { - cache.volumeTypes = make(map[string]verda.VolumeType, len(vTypes)) - for _, vt := range vTypes { - cache.volumeTypes[vt.Type] = vt - } - } - } - } + // Ensure pricing data is available (may be missing when + // steps were skipped via --from template pre-fill). + ensurePricingCache(getClient, cache, opts) renderDeploymentSummary(opts, cache) return true }, @@ -1088,6 +1179,37 @@ func stepConfirmDeploy(getClient clientFunc, cache *apiCache, opts *createOption } } +// ensurePricingCache populates the apiCache with instance type and volume +// type pricing data if it's missing. This happens when wizard steps were +// skipped because a template pre-filled the values. +func ensurePricingCache(getClient clientFunc, cache *apiCache, _ *createOptions) { + client, err := getClient() + if err != nil { + return + } + ctx := context.Background() + + // Fetch instance types if missing. + if cache.instanceTypes == nil { + if types, err := client.InstanceTypes.Get(ctx, "usd"); err == nil { + cache.instanceTypes = make(map[string]verda.InstanceTypeInfo, len(types)) + for i := range types { + cache.instanceTypes[types[i].InstanceType] = types[i] + } + } + } + + // Fetch volume types if missing. + if cache.volumeTypes == nil { + if vTypes, err := client.VolumeTypes.GetAllVolumeTypes(ctx); err == nil { + cache.volumeTypes = make(map[string]verda.VolumeType, len(vTypes)) + for _, vt := range vTypes { + cache.volumeTypes[vt.Type] = vt + } + } + } +} + // hoursInMonth is 365*24/12 = 730, matching the web frontend. const hoursInMonth = 730 @@ -1117,10 +1239,10 @@ func renderDeploymentSummary(opts *createOptions, cache *apiCache) { instUnits = instanceUnits(&info) if info.GPU.NumberOfGPUs > 0 { instLabel = fmt.Sprintf("%s — %s", info.InstanceType, info.GPU.Description) - instUnitLabel = "GPU" + instUnitLabel = unitLabelGPU } else { instLabel = fmt.Sprintf("%s — %d CPU, %dGB RAM", info.InstanceType, info.CPU.NumberOfCores, info.Memory.SizeInGigabytes) - instUnitLabel = "vCPU" + instUnitLabel = unitLabelVCPU } } diff --git a/internal/verda-cli/cmd/vm/wizard_test.go b/internal/verda-cli/cmd/vm/wizard_test.go index 42cddb6..c8d5a4b 100644 --- a/internal/verda-cli/cmd/vm/wizard_test.go +++ b/internal/verda-cli/cmd/vm/wizard_test.go @@ -37,7 +37,7 @@ func TestBuildCreateFlowHappyPath(t *testing.T) { // errClient returns error — API steps skipped via IsSet, confirm step handles error gracefully. errClient := func() (*verda.Client, error) { return nil, errors.New("no client in test") } - flow := buildCreateFlow(errClient, opts) + flow := buildCreateFlow(errClient, opts, WizardModeDeploy) engine := wizard.NewEngine(mock, nil) if err := engine.Run(context.Background(), flow); err != nil { @@ -82,7 +82,7 @@ func TestBuildCreateFlowSpotSkipsContract(t *testing.T) { mock.AddConfirm(true) // confirm deploy errClient := func() (*verda.Client, error) { return nil, errors.New("no client in test") } - flow := buildCreateFlow(errClient, opts) + flow := buildCreateFlow(errClient, opts, WizardModeDeploy) engine := wizard.NewEngine(mock, nil) if err := engine.Run(context.Background(), flow); err != nil { diff --git a/internal/verda-cli/template/template.go b/internal/verda-cli/template/template.go new file mode 100644 index 0000000..bdca997 --- /dev/null +++ b/internal/verda-cli/template/template.go @@ -0,0 +1,220 @@ +package template + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + petname "github.com/dustinkirkland/golang-petname" + "go.yaml.in/yaml/v3" +) + +func generateRandomWords() string { + return petname.Generate(3, "-") +} + +// Template represents a saved configuration template for creating resources. +type Template struct { + Resource string `yaml:"resource"` + BillingType string `yaml:"billing_type,omitempty"` + Contract string `yaml:"contract,omitempty"` + Kind string `yaml:"kind,omitempty"` + InstanceType string `yaml:"instance_type,omitempty"` + Location string `yaml:"location,omitempty"` + Image string `yaml:"image,omitempty"` + OSVolumeSize int `yaml:"os_volume_size,omitempty"` + Storage []StorageSpec `yaml:"storage,omitempty"` + StorageSkip bool `yaml:"storage_skip,omitempty"` + SSHKeys []string `yaml:"ssh_keys,omitempty"` + StartupScript string `yaml:"startup_script,omitempty"` + StartupScriptSkip bool `yaml:"startup_script_skip,omitempty"` + HostnamePattern string `yaml:"hostname_pattern,omitempty"` +} + +// StorageSpec describes an additional storage volume attached to a template. +type StorageSpec struct { + Type string `yaml:"type"` + Size int `yaml:"size"` +} + +// Entry represents a template listing entry with metadata. +type Entry struct { + Resource string + Name string + Description string + Path string +} + +// ExpandHostnamePattern expands placeholders in a hostname pattern: +// - {random} → 3 random words joined by hyphens (e.g. "cold-cable-smiles") +// - {location} → lowercased location code (e.g. "fin-03") +// +// Example: "gpu-{random}-{location}" → "gpu-cold-cable-smiles-fin-03". +func ExpandHostnamePattern(pattern, locationCode string) string { + s := pattern + if strings.Contains(s, "{random}") { + words := generateRandomWords() + s = strings.ReplaceAll(s, "{random}", words) + } + if strings.Contains(s, "{location}") { + s = strings.ReplaceAll(s, "{location}", strings.ToLower(locationCode)) + } + return s +} + +// nameRe matches valid template names: lowercase alphanumeric and hyphens. +var nameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// ValidateName checks that name is non-empty and contains only lowercase +// alphanumeric characters and hyphens. +func ValidateName(name string) error { + if name == "" { + return errors.New("template name must not be empty") + } + if !nameRe.MatchString(name) { + return errors.New("template name must contain only lowercase letters, digits, and hyphens") + } + return nil +} + +// Save writes a template to baseDir//.yaml. +// Directories are created with 0700 permissions; files with 0644. +func Save(baseDir, resource, name string, tmpl *Template) error { + dir := filepath.Join(baseDir, resource) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating template directory: %w", err) + } + + data, err := yaml.Marshal(tmpl) + if err != nil { + return fmt.Errorf("marshaling template: %w", err) + } + + path := filepath.Join(dir, name+".yaml") + if err := os.WriteFile(path, data, 0o644); err != nil { //nolint:gosec // templates are not secrets + return fmt.Errorf("writing template file: %w", err) + } + return nil +} + +// Load reads a template from baseDir//.yaml. +func Load(baseDir, resource, name string) (*Template, error) { + path := filepath.Join(baseDir, resource, name+".yaml") + return LoadFromPath(path) +} + +// LoadFromPath reads a template from an absolute file path. +func LoadFromPath(path string) (*Template, error) { + data, err := os.ReadFile(path) //nolint:gosec // user-provided template path + if err != nil { + return nil, fmt.Errorf("reading template file: %w", err) + } + + var tmpl Template + if err := yaml.Unmarshal(data, &tmpl); err != nil { + return nil, fmt.Errorf("parsing template file: %w", err) + } + return &tmpl, nil +} + +// Resolve converts a template reference to an absolute file path. +// If ref contains "/" or ends with ".yaml", it is treated as a file path; +// otherwise it is resolved as baseDir//.yaml. +// The resolved path must exist. +func Resolve(baseDir, resource, ref string) (string, error) { + var path string + if strings.Contains(ref, "/") || strings.HasSuffix(ref, ".yaml") { + path = ref + } else { + path = filepath.Join(baseDir, resource, ref+".yaml") + } + + if _, err := os.Stat(path); err != nil { + return "", fmt.Errorf("template not found: %w", err) + } + return path, nil +} + +// List returns all template entries in baseDir//. +// Returns nil (not an error) if the directory does not exist. +func List(baseDir, resource string) ([]Entry, error) { + dir := filepath.Join(baseDir, resource) + dirEntries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading template directory: %w", err) + } + + entries := make([]Entry, 0, len(dirEntries)) + for _, de := range dirEntries { + if de.IsDir() || !strings.HasSuffix(de.Name(), ".yaml") { + continue + } + name := strings.TrimSuffix(de.Name(), ".yaml") + path := filepath.Join(dir, de.Name()) + + tmpl, err := LoadFromPath(path) + if err != nil { + continue // skip unparseable files + } + + entries = append(entries, Entry{ + Resource: resource, + Name: name, + Description: tmpl.AutoDescription(), + Path: path, + }) + } + return entries, nil +} + +// ListAll returns template entries across all resource subdirectories. +// Returns nil (not an error) if baseDir does not exist. +func ListAll(baseDir string) ([]Entry, error) { + dirEntries, err := os.ReadDir(baseDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("reading templates base directory: %w", err) + } + + var entries []Entry + for _, de := range dirEntries { + if !de.IsDir() { + continue + } + sub, err := List(baseDir, de.Name()) + if err != nil { + return nil, err + } + entries = append(entries, sub...) + } + return entries, nil +} + +// Delete removes a template file at baseDir//.yaml. +func Delete(baseDir, resource, name string) error { + path := filepath.Join(baseDir, resource, name+".yaml") + if err := os.Remove(path); err != nil { + return fmt.Errorf("deleting template: %w", err) + } + return nil +} + +// AutoDescription returns a human-readable summary by joining non-empty +// InstanceType, Image, and Location fields with ", ". +func (t *Template) AutoDescription() string { + var parts []string + for _, s := range []string{t.InstanceType, t.Image, t.Location} { + if s != "" { + parts = append(parts, s) + } + } + return strings.Join(parts, ", ") +}