Skip to content

Commit be03cc3

Browse files
authored
Feat/verda template (#33)
* add template types and I/O layer for template system Foundation layer with Template, StorageSpec, Entry structs and all I/O functions: Save, Load, LoadFromPath, Resolve, List, ListAll, Delete, ValidateName, AutoDescription. Includes comprehensive tests.
1 parent 72ccba7 commit be03cc3

16 files changed

Lines changed: 2481 additions & 66 deletions

File tree

internal/verda-cli/cmd/cmd.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/sshkey"
2525
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/startupscript"
2626
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/status"
27+
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/template"
2728
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/update"
2829
cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util"
2930
"github/verda-cloud/verda-cli/internal/verda-cli/cmd/vm"
@@ -114,6 +115,7 @@ func NewRootCommand(ioStreams cmdutil.IOStreams) (*cobra.Command, *clioptions.Op
114115
locations.NewCmdLocations(f, ioStreams),
115116
sshkey.NewCmdSSHKey(f, ioStreams),
116117
startupscript.NewCmdStartupScript(f, ioStreams),
118+
template.NewCmdTemplate(f, ioStreams),
117119
volume.NewCmdVolume(f, ioStreams),
118120
},
119121
},
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Template Command Knowledge
2+
3+
## Quick Reference
4+
- Parent: `verda template` (alias: `tmpl`)
5+
- Subcommands: `create`, `list` (alias `ls`), `show`, `delete` (alias `rm`)
6+
- Files:
7+
- `template.go` -- Parent command, registers subcommands
8+
- `create.go` -- Create command, name validation, runs VM wizard in template mode
9+
- `list.go` -- List command with `--type` filter and structured output
10+
- `show.go` -- Show command with field display and structured output
11+
- `delete.go` -- Delete command with confirmation prompt
12+
- `types.go` -- Re-exports types/functions from shared `internal/verda-cli/template/`
13+
14+
## Domain-Specific Logic
15+
16+
### Template YAML Format
17+
All fields except `resource` are optional. Stored at `~/.verda/templates/<resource>/<name>.yaml`.
18+
19+
| Field | Type | Description |
20+
|-------|------|-------------|
21+
| `resource` | string | Resource type, currently only `"vm"` |
22+
| `billing_type` | string | `"on-demand"` or `"spot"` |
23+
| `contract` | string | `"PAY_AS_YOU_GO"`, `"SPOT"`, `"LONG_TERM"` |
24+
| `kind` | string | `"GPU"` or `"CPU"` |
25+
| `instance_type` | string | e.g. `"1V100.6V"` |
26+
| `location` | string | e.g. `"FIN-01"` |
27+
| `image` | string | OS image slug |
28+
| `os_volume_size` | int | GiB |
29+
| `storage` | []StorageSpec | Each has `type` and `size` |
30+
| `storage_skip` | bool | Skip storage step in wizard |
31+
| `ssh_keys` | []string | Key **names** (not IDs) |
32+
| `startup_script` | string | Script **name** (not ID) |
33+
| `startup_script_skip` | bool | Skip startup script step in wizard |
34+
| `hostname_pattern` | string | Pattern with `{random}` and `{location}` placeholders |
35+
36+
### Name Validation and Auto-Reformatting
37+
- Valid names match `^[a-z0-9][a-z0-9-]*$`
38+
- `normalizeName()` auto-formats: lowercase, replace spaces/underscores with hyphens, strip invalid chars, collapse consecutive hyphens, trim leading/trailing hyphens
39+
- Create command re-prompts on invalid names and on name collisions with existing templates
40+
41+
### Template Resolution (--from flag)
42+
- If ref contains `"/"` or ends with `".yaml"` -> treated as a file path
43+
- Otherwise -> resolved as `~/.verda/templates/vm/<ref>.yaml`
44+
- Empty ref (bare `--from`) -> interactive picker via `pickTemplate()`
45+
- Resolution logic lives in `internal/verda-cli/template/template.go` `Resolve()`
46+
47+
### Skip Flags
48+
- `storage_skip: true` -> wizard skips additional storage step entirely
49+
- `startup_script_skip: true` -> wizard skips startup script step entirely
50+
- Captured when user selects "None (skip)" during template creation wizard
51+
- Maps to `opts.storageSkip` and `opts.startupScriptSkip` in `createOptions`
52+
53+
### Hostname Pattern Expansion
54+
- `{random}` -> 3 petname words joined by hyphens (via `github.com/dustinkirkland/golang-petname`)
55+
- `{location}` -> lowercased location code (e.g. `"FIN-01"` -> `"fin-01"`)
56+
- Only expanded when `hostname_pattern` is set AND `opts.Hostname` is empty (no `--hostname` flag)
57+
58+
### SSH Keys and Startup Scripts
59+
- Stored in template by **name**, not ID
60+
- Resolved to IDs at `vm create --from` time via `resolveSSHKeyNames()` and `resolveStartupScriptName()`
61+
- On API error or name not found, produces a warning and the wizard prompts later
62+
- Names are stored in `opts.sshKeyNames` / `opts.startupScriptName` for template-saving round-trip
63+
64+
## Gotchas & Edge Cases
65+
66+
- **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.
67+
- **`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.
68+
- **`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`.
69+
- **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.
70+
- **`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).
71+
- **Only first storage entry applied**: `applyTemplate()` only reads `tmpl.Storage[0]` because the wizard's convenience fields (`StorageSize`/`StorageType`) support a single additional volume.
72+
- **AutoDescription**: `Template.AutoDescription()` joins non-empty `InstanceType`, `Image`, and `Location` with `", "` for the list view.
73+
- **Directory permissions**: Template directories created with `0700`, files with `0644`.
74+
- **Non-existent directory**: `List()` and `ListAll()` return `nil, nil` (not an error) when the templates directory doesn't exist yet.
75+
76+
## Relationships
77+
78+
- **`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/`.
79+
- **`cmd/vm/wizard.go`** -- `WizardMode` (Deploy vs Template), `RunTemplateWizard()` (runs wizard without hostname/description/confirm steps), `TemplateResult` struct, `ensurePricingCache()`
80+
- **`cmd/vm/template_apply.go`** -- `loadTemplateRef()`, `applyTemplate()`, `resolveTemplateNames()`, `resolveSSHKeyNames()`, `resolveStartupScriptName()`, `printTemplateSummary()`, `pickTemplate()`
81+
- **`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`)
82+
- **`cmdutil`** -- `Factory`, `IOStreams`, `LongDesc`, `Examples`, `DefaultSubCommandRun`, `WriteStructured`
83+
- **`clioptions`** -- `VerdaDir()` for resolving `~/.verda/` base path
84+
- **`petname`** -- `github.com/dustinkirkland/golang-petname` for `{random}` hostname expansion
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# verda template -- Manage reusable resource templates
2+
3+
Save, list, show, and delete reusable resource configuration templates. Templates pre-fill the `vm create` wizard so you don't repeat the same settings.
4+
5+
## Commands
6+
7+
| Command | Description | Key Flags |
8+
|---------|-------------|-----------|
9+
| `verda template create [name]` | Interactive wizard to save a template | _(none)_ |
10+
| `verda template list` | List all saved templates | `--type` |
11+
| `verda template show <resource/name>` | Display template details | `-o json` |
12+
| `verda template delete <resource/name>` | Delete a template (with confirmation) | _(none)_ |
13+
14+
Aliases: `verda tmpl`, `verda tmpl ls` (list), `verda tmpl rm` (delete)
15+
16+
## Usage Examples
17+
18+
### Create
19+
20+
```bash
21+
# Interactive (prompts for name and runs VM wizard)
22+
verda template create
23+
24+
# Create a template with a specific name
25+
verda template create gpu-training
26+
27+
# Short alias
28+
verda tmpl create my-template
29+
```
30+
31+
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.
32+
33+
### List
34+
35+
```bash
36+
# List all templates
37+
verda template list
38+
39+
# List only VM templates
40+
verda template list --type vm
41+
42+
# Short alias
43+
verda tmpl ls
44+
```
45+
46+
Output shows `NAME` (as `resource/name`) and an auto-generated `DESCRIPTION` built from instance type, image, and location.
47+
48+
### Show
49+
50+
```bash
51+
# Show a VM template
52+
verda template show vm/gpu-training
53+
54+
# Output as JSON
55+
verda template show vm/gpu-training -o json
56+
```
57+
58+
### Delete
59+
60+
```bash
61+
# Delete a VM template (prompts for confirmation)
62+
verda template delete vm/gpu-training
63+
64+
# Short alias
65+
verda tmpl rm vm/gpu-training
66+
```
67+
68+
## Template Storage
69+
70+
- Files stored at `~/.verda/templates/<resource>/<name>.yaml`
71+
- Organized by resource type subdirectory (currently only `vm/`)
72+
- Names must be lowercase alphanumeric with hyphens (regex: `^[a-z0-9][a-z0-9-]*$`)
73+
- Auto-reformats invalid names: spaces and underscores become hyphens, uppercase becomes lowercase, other invalid characters are stripped, consecutive hyphens are collapsed
74+
75+
## Template YAML Format
76+
77+
A complete example showing all supported fields:
78+
79+
```yaml
80+
resource: vm
81+
billing_type: on-demand # on-demand or spot
82+
contract: PAY_AS_YOU_GO
83+
kind: GPU # GPU or CPU
84+
instance_type: 1V100.6V
85+
location: FIN-01
86+
image: ubuntu-24.04-cuda-12.8
87+
os_volume_size: 200 # GiB
88+
storage:
89+
- type: NVMe
90+
size: 500
91+
storage_skip: true # explicitly skip additional storage
92+
ssh_keys:
93+
- milek # by name, resolved to ID at create time
94+
startup_script: setup-training # by name, resolved to ID at create time
95+
startup_script_skip: true # explicitly skip startup script
96+
hostname_pattern: "gpu-{random}-{location}" # auto-generate hostnames
97+
```
98+
99+
All fields except `resource` are optional; omitted fields are left for the wizard to prompt.
100+
101+
## Using Templates with `vm create`
102+
103+
```bash
104+
verda vm create --from gpu-training # load by name
105+
verda vm create --from ./my-template.yaml # load from file path
106+
verda vm create --from # pick from list (interactive)
107+
verda vm create --from gpu-training --hostname my-vm --description "test"
108+
```
109+
110+
### Flow
111+
112+
1. Template values pre-fill the wizard's `createOptions`
113+
2. A summary of template values is printed to stderr
114+
3. SSH keys and startup scripts are resolved by name to ID via the API; unresolved names produce warnings
115+
4. Only unfilled steps are prompted (hostname, description, confirm-deploy are always prompted; other steps only if the template didn't fill them)
116+
5. The confirm-deploy step fetches pricing for the deployment summary (via `ensurePricingCache` if earlier pricing steps were skipped)
117+
118+
### Template Resolution
119+
120+
The `--from` flag uses `NoOptDefVal` so it can be used in three ways:
121+
122+
- `--from gpu-training` -- resolves as a template name in `~/.verda/templates/vm/`
123+
- `--from ./path/to/template.yaml` -- treated as a file path (contains `/` or ends with `.yaml`)
124+
- `--from` (no value) -- shows an interactive picker of saved VM templates
125+
126+
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.
127+
128+
## Hostname Pattern
129+
130+
The `hostname_pattern` field supports two placeholders:
131+
132+
- `{random}` -- replaced with 3 random petname words joined by hyphens (e.g., `cold-cable-smiles`)
133+
- `{location}` -- replaced with the lowercased location code (e.g., `fin-01`)
134+
135+
Example: `"gpu-{random}-{location}"` expands to something like `"gpu-cold-cable-smiles-fin-01"`.
136+
137+
The pattern is expanded only when `hostname_pattern` is set and no explicit `--hostname` flag is provided.
138+
139+
## Skip Flags
140+
141+
Templates can explicitly mark steps as skipped so the wizard does not re-ask:
142+
143+
- **`storage_skip: true`** -- skip the additional storage step entirely (do not prompt for NVMe/HDD volumes)
144+
- **`startup_script_skip: true`** -- skip the startup script step entirely (do not prompt for a script)
145+
146+
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."
147+
148+
## Architecture Notes
149+
150+
### Files
151+
152+
- **template.go** -- Parent command definition (`verda template`), registers subcommands
153+
- **create.go** -- `template create` command; prompts for resource type and name, runs VM wizard in template mode, saves result
154+
- **list.go** -- `template list` command; lists entries with auto-description, supports `--type` filter and structured output
155+
- **show.go** -- `template show` command; displays template fields, supports `-o json` structured output
156+
- **delete.go** -- `template delete` command; loads template to verify existence, confirms, then deletes
157+
- **types.go** -- Re-exports types and functions from `internal/verda-cli/template/` to avoid import cycles
158+
159+
### Shared Package
160+
161+
- **`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
162+
163+
### Integration with `vm create`
164+
165+
- **`cmd/vm/create.go`** -- Defines `--from` flag, calls `resolveCreateInputs`
166+
- **`cmd/vm/template_apply.go`** -- `loadTemplateRef`, `applyTemplate`, `resolveTemplateNames`, `printTemplateSummary`
167+
- **`cmd/vm/wizard.go`** -- `WizardMode`, `RunTemplateWizard`, `TemplateResult`, `ensurePricingCache`

0 commit comments

Comments
 (0)