Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7eb7e19
add template types and I/O layer for template system
hi-lei Apr 9, 2026
ef01354
add template parent command and template list subcommand
hi-lei Apr 9, 2026
a601f84
add template show and delete subcommands
hi-lei Apr 9, 2026
d6893e0
refactor wizard to support WizardMode for deploy vs template flows
hi-lei Apr 9, 2026
8750155
add exported RunTemplateWizard and capture SSH key/startup script names
hi-lei Apr 9, 2026
aa1f23a
add template create command with interactive wizard flow
hi-lei Apr 9, 2026
9bbde44
add --from flag to vm create with template loading and name resolution
hi-lei Apr 9, 2026
65cf706
fix: add timeout context to template name resolution and unit tests
hi-lei Apr 9, 2026
dea1647
fix(template): re-prompt on invalid template name instead of exiting
hi-lei Apr 9, 2026
f75409e
fix(template): show example names on validation error
hi-lei Apr 9, 2026
085ff08
feat(template): auto-reformat template names on input
hi-lei Apr 9, 2026
7cf9044
fix(vm): allow --from without value to show template picker
hi-lei Apr 9, 2026
d946983
fix(vm): only show template picker when --from is explicitly used
hi-lei Apr 9, 2026
33499df
fix(vm): use standard --from syntax with required value
hi-lei Apr 9, 2026
c9557cd
feat(vm): support both --from (picker) and --from name (direct load)
hi-lei Apr 9, 2026
fd9e937
fix(vm): template pre-fill now skips wizard steps correctly
hi-lei Apr 9, 2026
1e5e07d
feat(template): remember skipped storage and startup script choices
hi-lei Apr 9, 2026
8fa6ab8
fix(vm): fetch pricing data when wizard steps skipped by template
hi-lei Apr 9, 2026
bfe5dc0
feat(template): add hostname_pattern with {random} and {location}
hi-lei Apr 9, 2026
674aac0
docs(template): add comprehensive tests and command documentation
hi-lei Apr 9, 2026
8bb428c
docs: improve help text for template create and vm create --from
hi-lei Apr 9, 2026
98efbb1
test(vm): add storage pre-fill test cases for template apply
hi-lei Apr 9, 2026
b156262
fix lint errors
hi-lei Apr 9, 2026
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
2 changes: 2 additions & 0 deletions internal/verda-cli/cmd/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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),
},
},
Expand Down
84 changes: 84 additions & 0 deletions internal/verda-cli/cmd/template/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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/<resource>/<name>.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/<ref>.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
167 changes: 167 additions & 0 deletions internal/verda-cli/cmd/template/README.md
Original file line number Diff line number Diff line change
@@ -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 <resource/name>` | Display template details | `-o json` |
| `verda template delete <resource/name>` | 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/<resource>/<name>.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`
Loading
Loading