Skip to content

Commit fef8653

Browse files
hi-leiclaude
andcommitted
feat: template improvements — image names, description field, wizard steps
- Store image name (not ID) in templates, matching SSH key/startup script pattern. resolveImageName() converts name→ID at apply time with backward compat for existing templates storing IDs. - Add Description field to Template struct; AutoDescription() prefers custom description, falls back to auto-generated. - Add hostname-pattern and description wizard steps to template create. - Template edit: image selector shows names, description field editable, "Save & exit" pre-selected with full page size. - Template show: displays description field. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 08eae30 commit fef8653

8 files changed

Lines changed: 134 additions & 29 deletions

File tree

internal/verda-cli/cmd/template/create.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ func vmResultToTemplate(r *vm.TemplateResult) *Template {
149149
StartupScript: r.StartupScriptName,
150150
StorageSkip: r.StorageSkip,
151151
StartupScriptSkip: r.StartupScriptSkip,
152+
HostnamePattern: r.HostnamePattern,
153+
Description: r.Description,
152154
}
153155
if r.StorageSize > 0 {
154156
tmpl.Storage = []StorageSpec{{

internal/verda-cli/cmd/template/edit.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ func runEdit(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStreams,
8686
}
8787
labels[len(fields)] = "Save & exit"
8888

89-
idx, selErr := prompter.Select(ctx, "Edit field", labels)
89+
idx, selErr := prompter.Select(ctx, "Edit field", labels, tui.WithSelectDefault(len(fields)), tui.WithPageSize(len(labels)))
9090
if selErr != nil {
9191
// Ctrl+C — save what we have
9292
break
@@ -217,6 +217,18 @@ func buildFieldMenu(tmpl *Template) []editableField {
217217
return nil
218218
},
219219
},
220+
{
221+
label: "Description",
222+
display: func(t *Template) string { return valueOrDash(t.Description) },
223+
edit: func(ctx context.Context, f cmdutil.Factory, t *Template) error {
224+
val, err := f.Prompter().TextInput(ctx, "Description", tui.WithDefault(t.Description))
225+
if err != nil {
226+
return nil //nolint:nilerr // user canceled
227+
}
228+
t.Description = val
229+
return nil
230+
},
231+
},
220232
}
221233
return fields
222234
}
@@ -315,20 +327,18 @@ func editImage(ctx context.Context, f cmdutil.Factory, t *Template) error {
315327
}
316328

317329
choices := make([]string, 0, len(images))
318-
values := make([]string, 0, len(images))
319330
for _, img := range images {
320331
if img.IsCluster {
321332
continue
322333
}
323-
choices = append(choices, img.ID)
324-
values = append(values, img.ID)
334+
choices = append(choices, img.Name)
325335
}
326336

327337
idx, selErr := f.Prompter().Select(ctx, "Image", choices)
328338
if selErr != nil {
329339
return nil //nolint:nilerr // user canceled
330340
}
331-
t.Image = values[idx]
341+
t.Image = choices[idx]
332342
return nil
333343
}
334344

internal/verda-cli/cmd/template/show.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ func runShow(f cmdutil.Factory, ioStreams cmdutil.IOStreams, ref string) error {
123123
}
124124

125125
pf("Hostname Pattern:", tmpl.HostnamePattern)
126+
pf("Description:", tmpl.Description)
126127

127128
return nil
128129
}

internal/verda-cli/cmd/vm/create.go

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,12 +79,15 @@ type createOptions struct {
7979
Wait cmdutil.WaitOptions
8080

8181
// Internal flags for template/wizard coordination.
82-
sshKeyNames []string // names corresponding to SSHKeyIDs (for template saving)
83-
startupScriptName string // name corresponding to StartupScriptID (for template saving)
84-
billingTypeSet bool // true when billing type was pre-filled
85-
locationSet bool // true when location was pre-filled
86-
storageSkip bool // true when storage was explicitly skipped
87-
startupScriptSkip bool // true when startup script was explicitly skipped
82+
sshKeyNames []string // names corresponding to SSHKeyIDs (for template saving)
83+
startupScriptName string // name corresponding to StartupScriptID (for template saving)
84+
billingTypeSet bool // true when billing type was pre-filled
85+
locationSet bool // true when location was pre-filled
86+
storageSkip bool // true when storage was explicitly skipped
87+
startupScriptSkip bool // true when startup script was explicitly skipped
88+
imageName string // human-readable image name (for template saving)
89+
hostnamePattern string // template-mode only: hostname pattern
90+
templateDescription string // template-mode only: user description
8891
}
8992

9093
// NewCmdCreate creates the vm create cobra command.

internal/verda-cli/cmd/vm/template_apply.go

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -131,9 +131,7 @@ func applyTemplate(tmpl *template.Template, opts *createOptions) {
131131
opts.LocationCode = tmpl.Location
132132
opts.locationSet = true
133133
}
134-
if tmpl.Image != "" {
135-
opts.Image = tmpl.Image
136-
}
134+
// Image name is resolved to ID by resolveTemplateNames, not here.
137135
if tmpl.OSVolumeSize != 0 {
138136
opts.OSVolumeSize = tmpl.OSVolumeSize
139137
}
@@ -156,12 +154,15 @@ func applyTemplate(tmpl *template.Template, opts *createOptions) {
156154
// SSH keys and startup script are handled by resolveTemplateNames, not here.
157155
}
158156

159-
// resolveTemplateNames resolves SSH key names and startup script name to IDs.
160-
// Prints each warning to ioStreams.ErrOut and returns the collected warnings.
157+
// resolveTemplateNames resolves image name, SSH key names, and startup script
158+
// name to IDs. Prints each warning to ioStreams.ErrOut and returns the
159+
// collected warnings.
161160
func resolveTemplateNames(ctx context.Context, ioStreams cmdutil.IOStreams, client *verda.Client, tmpl *template.Template, opts *createOptions) []string {
161+
imageWarnings := resolveImageName(ctx, client, tmpl.Image, opts)
162162
_, sshWarnings := resolveSSHKeyNames(ctx, client, tmpl.SSHKeys, opts)
163163
scriptWarnings := resolveStartupScriptName(ctx, client, tmpl.StartupScript, opts)
164-
warnings := make([]string, 0, len(sshWarnings)+len(scriptWarnings))
164+
warnings := make([]string, 0, len(imageWarnings)+len(sshWarnings)+len(scriptWarnings))
165+
warnings = append(warnings, imageWarnings...)
165166
warnings = append(warnings, sshWarnings...)
166167
warnings = append(warnings, scriptWarnings...)
167168
for _, w := range warnings {
@@ -170,6 +171,36 @@ func resolveTemplateNames(ctx context.Context, ioStreams cmdutil.IOStreams, clie
170171
return warnings
171172
}
172173

174+
// resolveImageName resolves an image name to its ID via the API.
175+
// For backward compatibility, if the name matches an existing image ID directly,
176+
// it is used as-is (handles templates created before name-based storage).
177+
func resolveImageName(ctx context.Context, client *verda.Client, name string, opts *createOptions) (warnings []string) {
178+
if name == "" {
179+
return nil
180+
}
181+
images, err := client.Images.Get(ctx)
182+
if err != nil {
183+
return []string{fmt.Sprintf("failed to resolve image name: %v", err)}
184+
}
185+
// Try matching by name first.
186+
for _, img := range images {
187+
if img.Name == name {
188+
opts.Image = img.ID
189+
opts.imageName = img.Name
190+
return nil
191+
}
192+
}
193+
// Backward compat: if the value matches an image ID, use it directly.
194+
for _, img := range images {
195+
if img.ID == name {
196+
opts.Image = img.ID
197+
opts.imageName = img.Name
198+
return nil
199+
}
200+
}
201+
return []string{fmt.Sprintf("image %q not found", name)}
202+
}
203+
173204
// resolveSSHKeyNames resolves SSH key names to IDs via the API.
174205
// Returns (resolved IDs, warnings). On API error or name-not-found, a warning
175206
// is returned so the caller can inform the user.

internal/verda-cli/cmd/vm/template_apply_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,9 @@ func TestApplyTemplate(t *testing.T) {
4040
if opts.LocationCode != "FIN-01" {
4141
t.Errorf("LocationCode = %q, want FIN-01", opts.LocationCode)
4242
}
43-
if opts.Image != "ubuntu-24.04-cuda-12.8" {
44-
t.Errorf("Image = %q, want ubuntu-24.04-cuda-12.8", opts.Image)
43+
// Image is resolved by resolveTemplateNames, not applyTemplate.
44+
if opts.Image != "" {
45+
t.Errorf("Image = %q, want empty (resolved later by resolveTemplateNames)", opts.Image)
4546
}
4647
if opts.OSVolumeSize != 200 {
4748
t.Errorf("OSVolumeSize = %d, want 200", opts.OSVolumeSize)

internal/verda-cli/cmd/vm/wizard.go

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ type WizardMode int
4343
const (
4444
// WizardModeDeploy includes all steps: config + hostname + description + confirm deploy.
4545
WizardModeDeploy WizardMode = iota
46-
// WizardModeTemplate includes only config steps (no hostname, description, confirm).
46+
// WizardModeTemplate includes config steps + hostname pattern + template description (no deploy hostname, deploy description, or confirm).
4747
WizardModeTemplate
4848
)
4949

@@ -63,11 +63,13 @@ type TemplateResult struct {
6363
StorageType string
6464
StorageSkip bool // user explicitly chose "None (skip)"
6565
StartupScriptSkip bool // user explicitly chose "None (skip)"
66+
HostnamePattern string
67+
Description string
6668
}
6769

68-
// RunTemplateWizard runs the VM create wizard in template mode (no hostname,
69-
// description, or confirm-deploy steps). Returns the wizard results for
70-
// saving as a template.
70+
// RunTemplateWizard runs the VM create wizard in template mode. Includes config
71+
// steps plus hostname-pattern and template description, but no deploy hostname,
72+
// deploy description, or confirm-deploy steps.
7173
func RunTemplateWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams) (*TemplateResult, error) {
7274
return runTemplateWizardWithOpts(ctx, f, ioStreams, &createOptions{
7375
LocationCode: verda.LocationFIN01,
@@ -91,14 +93,16 @@ func optsToTemplateResult(opts *createOptions) *TemplateResult {
9193
Kind: opts.Kind,
9294
InstanceType: opts.InstanceType,
9395
Location: opts.LocationCode,
94-
Image: opts.Image,
96+
Image: opts.imageName,
9597
OSVolumeSize: opts.OSVolumeSize,
9698
SSHKeyNames: opts.sshKeyNames,
9799
StartupScriptName: opts.startupScriptName,
98100
StorageSize: opts.StorageSize,
99101
StorageType: opts.StorageType,
100102
StorageSkip: opts.StorageSize == 0 && len(opts.VolumeSpecs) == 0,
101103
StartupScriptSkip: opts.StartupScriptID == "" && opts.startupScriptName == "",
104+
HostnamePattern: opts.hostnamePattern,
105+
Description: opts.templateDescription,
102106
}
103107
if opts.IsSpot {
104108
result.BillingType = "spot"
@@ -136,6 +140,12 @@ func buildCreateFlow(ctx context.Context, getClient clientFunc, opts *createOpti
136140
stepConfirmDeploy(ctx, errOut, getClient, cache, opts),
137141
)
138142
}
143+
if mode == WizardModeTemplate {
144+
steps = append(steps,
145+
stepHostnamePattern(opts),
146+
stepTemplateDescription(opts),
147+
)
148+
}
139149

140150
layout := []wizard.ViewDef{
141151
{ID: "hints", View: wizard.NewHintBarView(wizard.WithHintStyle(bubbletea.HintStyle()))},
@@ -404,6 +414,7 @@ func stepLocation(getClient clientFunc, cache *apiCache, opts *createOptions) wi
404414
// --- Step 6: OS Image ---
405415

406416
func stepImage(getClient clientFunc, opts *createOptions) wizard.Step {
417+
var imagesByID map[string]string // ID → Name lookup, built by Loader
407418
return wizard.Step{
408419
Name: "image",
409420
Description: "Operating system image",
@@ -420,11 +431,13 @@ func stepImage(getClient clientFunc, opts *createOptions) wizard.Step {
420431
if err != nil {
421432
return nil, fmt.Errorf("fetching images: %w", err)
422433
}
434+
imagesByID = make(map[string]string, len(images))
423435
var choices []wizard.Choice
424436
for _, img := range images {
425437
if img.IsCluster {
426438
continue
427439
}
440+
imagesByID[img.ID] = img.Name
428441
desc := ""
429442
if len(img.Details) > 0 {
430443
desc = strings.Join(img.Details, ", ")
@@ -437,9 +450,15 @@ func stepImage(getClient clientFunc, opts *createOptions) wizard.Step {
437450
}
438451
return choices, nil
439452
},
440-
Default: func(_ map[string]any) any { return opts.Image },
441-
Setter: func(v any) { opts.Image = v.(string) },
442-
Resetter: func() { opts.Image = "" },
453+
Default: func(_ map[string]any) any { return opts.Image },
454+
Setter: func(v any) {
455+
id := v.(string)
456+
opts.Image = id
457+
if imagesByID != nil {
458+
opts.imageName = imagesByID[id]
459+
}
460+
},
461+
Resetter: func() { opts.Image = ""; opts.imageName = "" },
443462
IsSet: func() bool { return opts.Image != "" },
444463
Value: func() any { return opts.Image },
445464
}
@@ -719,6 +738,39 @@ func stepStartupScript(getClient clientFunc, opts *createOptions) wizard.Step {
719738
}
720739
}
721740

741+
// --- Template Step: Hostname Pattern ---
742+
743+
func stepHostnamePattern(opts *createOptions) wizard.Step {
744+
return wizard.Step{
745+
Name: "hostname-pattern",
746+
Description: "Hostname pattern (optional)",
747+
Prompt: wizard.TextInputPrompt,
748+
Required: false,
749+
Default: func(_ map[string]any) any {
750+
return "{random}-{location}"
751+
},
752+
Setter: func(v any) { opts.hostnamePattern = v.(string) },
753+
Resetter: func() { opts.hostnamePattern = "" },
754+
IsSet: func() bool { return opts.hostnamePattern != "" },
755+
Value: func() any { return opts.hostnamePattern },
756+
}
757+
}
758+
759+
// --- Template Step: Description ---
760+
761+
func stepTemplateDescription(opts *createOptions) wizard.Step {
762+
return wizard.Step{
763+
Name: "template-description",
764+
Description: "Description (optional)",
765+
Prompt: wizard.TextInputPrompt,
766+
Required: false,
767+
Setter: func(v any) { opts.templateDescription = v.(string) },
768+
Resetter: func() { opts.templateDescription = "" },
769+
IsSet: func() bool { return opts.templateDescription != "" },
770+
Value: func() any { return opts.templateDescription },
771+
}
772+
}
773+
722774
// --- Step 11: Hostname ---
723775

724776
func stepHostname(opts *createOptions) wizard.Step {

internal/verda-cli/template/template.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type Template struct {
3232
StartupScript string `yaml:"startup_script,omitempty"`
3333
StartupScriptSkip bool `yaml:"startup_script_skip,omitempty"`
3434
HostnamePattern string `yaml:"hostname_pattern,omitempty"`
35+
Description string `yaml:"description,omitempty"`
3536
}
3637

3738
// StorageSpec describes an additional storage volume attached to a template.
@@ -210,9 +211,13 @@ func Delete(baseDir, resource, name string) error {
210211
return nil
211212
}
212213

213-
// AutoDescription returns a human-readable summary by joining non-empty
214-
// InstanceType, Image, and Location fields with ", ".
214+
// AutoDescription returns a human-readable summary. If the user provided a
215+
// custom Description it is returned as-is; otherwise the method falls back to
216+
// joining non-empty InstanceType, Image, and Location with ", ".
215217
func (t *Template) AutoDescription() string {
218+
if t.Description != "" {
219+
return t.Description
220+
}
216221
var parts []string
217222
for _, s := range []string{t.InstanceType, t.Image, t.Location} {
218223
if s != "" {

0 commit comments

Comments
 (0)