Skip to content

Commit bd28c3e

Browse files
authored
Fix/vm create related bugs (#16)
* fix: improve VM wizard SSH key UX and add action shortcut commands - Require at least one SSH key selection (WithMinSelections) to prevent proceeding with empty selection that would fail at create time - Show newly added SSH keys at top of list instead of bottom - Add "Load from file" option for SSH key creation with file validation and auto-populated default from ~/.ssh/*.pub - Add shortcut commands: vm start, vm shutdown, vm hibernate, vm delete that accept positional instance ID and filter instance list by valid status * fix: show SSH pub key picker instead of hardcoded default path When loading SSH key from file, scan ~/.ssh/*.pub and present a select list with "Enter path manually..." option, instead of auto-populating a single hardcoded default. Well-known key types sorted first. * fix: correct OS image in vm create help example Use ubuntu-24.04-cuda-13.0-open-docker which is a valid image for the 1V100.6V instance type at FIN-01. * fix: allow extra SSH args after -- separator cobra.MaximumNArgs(1) was rejecting args after "--" since Cobra counts all positional args together. Use ArbitraryArgs instead; the existing ArgsLenAtDash logic already splits target from extra SSH args correctly. Fixes: verda ssh <id> -- -L 8080:localhost:8080 * fix: upgrade verdagostack to v1.1.3 and add package release configs Upgrade verdagostack to v1.1.3 which fixes wizard back-navigation: - Esc in Loader-managed steps (SSH keys, storage, startup script) now goes back instead of cancelling the wizard - Back navigates to the immediate prior step instead of skipping all completed steps Add goreleaser configs for Homebrew tap, Scoop bucket, and Linux packages (deb/rpm/apk). * docs: add package install methods to README and rename release test Add Homebrew, Scoop, and Linux package (deb/rpm/apk) install sections to README. Fix OS image in the non-interactive example. Rename install-test.sh to test-release-packages.sh for clarity. * fix: configure HOMEBREW_TAP_TOKEN for brew/scoop release Add token config to goreleaser brews/scoops sections and pass HOMEBREW_TAP_TOKEN secret in the release workflow so goreleaser can push formula/manifest to the homebrew-tap repo. * fix: suppress gosec G304 false positive on user-provided SSH key path
1 parent d0a3b8e commit bd28c3e

6 files changed

Lines changed: 239 additions & 10 deletions

File tree

internal/verda-cli/cmd/ssh/ssh.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ func NewCmdSSH(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command {
4848
# Pass extra ssh arguments
4949
verda ssh gpu-runner -- -L 8080:localhost:8080
5050
`),
51-
Args: cobra.MaximumNArgs(1),
51+
Args: cobra.ArbitraryArgs, // extra args after "--" are passed to ssh
5252
DisableFlagParsing: false,
5353
RunE: func(cmd *cobra.Command, args []string) error {
5454
var target string

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

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,13 @@ func runAction(cmd *cobra.Command, f cmdutil.Factory, ioStreams cmdutil.IOStream
169169

170170
// Select instance if not provided.
171171
if opts.InstanceID == "" {
172-
selected, err := selectInstance(ctx, f, ioStreams, client)
172+
// When action is pre-set (shortcut commands), only show instances
173+
// with statuses valid for that action.
174+
var statusFilter []string
175+
if opts.Action != "" {
176+
statusFilter = validFromForAction(opts.Action)
177+
}
178+
selected, err := selectInstance(ctx, f, ioStreams, client, statusFilter...)
173179
if err != nil {
174180
return err
175181
}
@@ -329,7 +335,7 @@ func runDeleteAgent(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IO
329335
return nil
330336
}
331337

332-
func selectInstance(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client *verda.Client) (string, error) {
338+
func selectInstance(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, client *verda.Client, statusFilter ...string) (string, error) {
333339
var sp interface{ Stop(string) }
334340
if status := f.Status(); status != nil {
335341
sp, _ = status.Spinner(ctx, "Loading instances...")
@@ -342,8 +348,26 @@ func selectInstance(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IO
342348
return "", err
343349
}
344350

351+
// Filter by status when the caller restricts to specific statuses.
352+
if len(statusFilter) > 0 {
353+
filtered := instances[:0]
354+
for i := range instances {
355+
for _, s := range statusFilter {
356+
if instances[i].Status == s {
357+
filtered = append(filtered, instances[i])
358+
break
359+
}
360+
}
361+
}
362+
instances = filtered
363+
}
364+
345365
if len(instances) == 0 {
346-
_, _ = fmt.Fprintln(ioStreams.Out, "No instances found.")
366+
if len(statusFilter) > 0 {
367+
_, _ = fmt.Fprintf(ioStreams.Out, "No instances with status %s found.\n", strings.Join(statusFilter, ", "))
368+
} else {
369+
_, _ = fmt.Fprintln(ioStreams.Out, "No instances found.")
370+
}
347371
return "", nil
348372
}
349373

@@ -446,6 +470,21 @@ func runDeleteFlow(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOS
446470
return nil
447471
}
448472

473+
// validFromForAction returns the ValidFrom statuses for a given action name.
474+
// Returns nil (no filter) if the action is unknown or has no status restriction (e.g. delete).
475+
func validFromForAction(actionName string) []string {
476+
label, ok := actionNameMap[strings.ToLower(actionName)]
477+
if !ok {
478+
return nil
479+
}
480+
for _, a := range allActions {
481+
if a.Label == label {
482+
return a.ValidFrom
483+
}
484+
}
485+
return nil
486+
}
487+
449488
// resolveAction maps a CLI --action flag value to an instanceAction from the valid set.
450489
func resolveAction(actionName string, validActions []instanceAction) (instanceAction, error) {
451490
label, ok := actionNameMap[strings.ToLower(actionName)]

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ func NewCmdCreate(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command
6868
--kind gpu \
6969
--instance-type 1V100.6V \
7070
--location FIN-01 \
71-
--os ubuntu-24.04-cuda-12.8-open-docker \
71+
--os ubuntu-24.04-cuda-13.0-open-docker \
7272
--os-volume-size 100 \
7373
--hostname gpu-runner \
7474
--description "GPU runner for batch jobs" \
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package vm
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
cmdutil "github/verda-cloud/verda-cli/internal/verda-cli/cmd/util"
7+
)
8+
9+
// shortcutDef describes a shortcut command that delegates to runAction.
10+
type shortcutDef struct {
11+
Use string
12+
Aliases []string
13+
Short string
14+
Action string // value passed as --action to runAction
15+
}
16+
17+
var shortcuts = []shortcutDef{
18+
{
19+
Use: "start <instance-id>",
20+
Short: "Start a VM instance",
21+
Action: "start",
22+
},
23+
{
24+
Use: "shutdown <instance-id>",
25+
Short: "Shutdown a VM instance",
26+
Aliases: []string{"stop"},
27+
Action: "shutdown",
28+
},
29+
{
30+
Use: "hibernate <instance-id>",
31+
Short: "Hibernate a VM instance",
32+
Action: "hibernate",
33+
},
34+
{
35+
Use: "delete <instance-id>",
36+
Aliases: []string{"rm"},
37+
Short: "Delete a VM instance",
38+
Action: "delete",
39+
},
40+
}
41+
42+
// newShortcutCmd creates a thin command that pre-sets the action and delegates to runAction.
43+
func newShortcutCmd(f cmdutil.Factory, ioStreams cmdutil.IOStreams, def shortcutDef) *cobra.Command {
44+
opts := &actionOptions{
45+
Action: def.Action,
46+
}
47+
48+
cmd := &cobra.Command{
49+
Use: def.Use,
50+
Aliases: def.Aliases,
51+
Short: def.Short,
52+
Args: cobra.MaximumNArgs(1),
53+
RunE: func(cmd *cobra.Command, args []string) error {
54+
if len(args) > 0 {
55+
opts.InstanceID = args[0]
56+
}
57+
return runAction(cmd, f, ioStreams, opts)
58+
},
59+
}
60+
61+
cmd.Flags().StringVar(&opts.InstanceID, "id", "", "Instance ID (alternative to positional argument)")
62+
cmd.Flags().BoolVar(&opts.Yes, "yes", false, "Skip confirmation for destructive actions")
63+
opts.Wait.AddFlags(cmd.Flags(), true)
64+
65+
return cmd
66+
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,10 @@ func NewCmdVM(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command {
2525
NewCmdAction(f, ioStreams),
2626
NewCmdAvailability(f, ioStreams),
2727
)
28+
29+
// Shortcut commands for common actions.
30+
for _, def := range shortcuts {
31+
cmd.AddCommand(newShortcutCmd(f, ioStreams, def))
32+
}
2833
return cmd
2934
}

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

Lines changed: 124 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"math"
88
"os"
9+
"path/filepath"
910
"slices"
1011
"strconv"
1112
"strings"
@@ -659,7 +660,7 @@ func stepSSHKeys(getClient clientFunc, opts *createOptions) wizard.Step {
659660
for i, c := range choices {
660661
labels[i] = c.Label
661662
}
662-
indices, err := prompter.MultiSelect(ctx, "SSH keys to inject", labels)
663+
indices, err := prompter.MultiSelect(ctx, "SSH keys to inject", labels, tui.WithMinSelections(1))
663664
if err != nil {
664665
return nil, err
665666
}
@@ -697,7 +698,7 @@ func stepSSHKeys(getClient clientFunc, opts *createOptions) wizard.Step {
697698
return nil, err
698699
}
699700
if newKey != nil {
700-
keys = append(keys, *newKey)
701+
keys = append([]verda.SSHKey{*newKey}, keys...)
701702
choices = buildSSHKeyChoices(keys)
702703
}
703704
}
@@ -727,10 +728,40 @@ func promptAddSSHKey(ctx context.Context, prompter tui.Prompter, client *verda.C
727728
if err != nil || strings.TrimSpace(name) == "" {
728729
return nil, nil //nolint:nilerr // User canceled or left input blank.
729730
}
730-
pubKey, err := prompter.TextInput(ctx, "Public key (paste)")
731-
if err != nil || strings.TrimSpace(pubKey) == "" {
732-
return nil, nil //nolint:nilerr // User canceled or left input blank.
731+
732+
// Ask for source: load from file or paste.
733+
sourceIdx, err := prompter.Select(ctx, "Public key source", []string{
734+
"Load from file",
735+
"Paste content",
736+
})
737+
if err != nil {
738+
return nil, nil //nolint:nilerr // User canceled.
739+
}
740+
741+
var pubKey string
742+
switch sourceIdx {
743+
case 0: // Load from file
744+
filePath, err := promptSSHKeyFilePath(ctx, prompter)
745+
if err != nil || filePath == "" {
746+
return nil, nil //nolint:nilerr // User canceled.
747+
}
748+
data, err := os.ReadFile(filePath) //nolint:gosec // User-provided path from interactive prompt, validated by validateFilePath.
749+
if err != nil {
750+
_, _ = prompter.Confirm(ctx, fmt.Sprintf("Error: %v. Press Enter to continue.", err), tui.WithConfirmDefault(true))
751+
return nil, nil
752+
}
753+
pubKey = string(data)
754+
case 1: // Paste content
755+
pubKey, err = prompter.TextInput(ctx, "Public key (paste)")
756+
if err != nil || strings.TrimSpace(pubKey) == "" {
757+
return nil, nil //nolint:nilerr // User canceled or left input blank.
758+
}
733759
}
760+
761+
if strings.TrimSpace(pubKey) == "" {
762+
return nil, nil
763+
}
764+
734765
created, err := client.SSHKeys.AddSSHKey(ctx, &verda.CreateSSHKeyRequest{
735766
Name: strings.TrimSpace(name),
736767
PublicKey: strings.TrimSpace(pubKey),
@@ -743,6 +774,94 @@ func promptAddSSHKey(ctx context.Context, prompter tui.Prompter, client *verda.C
743774
return created, nil
744775
}
745776

777+
// validateFilePath checks that the input is a non-empty path to an existing file.
778+
var validateFilePath = func(s string) error {
779+
s = strings.TrimSpace(s)
780+
if s == "" {
781+
return errors.New("file path is required")
782+
}
783+
if _, err := os.Stat(s); err != nil {
784+
return fmt.Errorf("file not found: %s", s)
785+
}
786+
return nil
787+
}
788+
789+
// promptSSHKeyFilePath discovers .pub files in ~/.ssh/ and lets the user pick
790+
// one, or enter a path manually. Returns "" if the user cancels.
791+
func promptSSHKeyFilePath(ctx context.Context, prompter tui.Prompter) (string, error) {
792+
pubFiles := discoverSSHPubKeys()
793+
794+
if len(pubFiles) == 0 {
795+
p, err := prompter.TextInput(ctx, "Public key file path",
796+
tui.WithPlaceholder("~/.ssh/id_ed25519.pub"),
797+
tui.WithValidation(validateFilePath),
798+
)
799+
if err != nil || strings.TrimSpace(p) == "" {
800+
return "", err
801+
}
802+
return strings.TrimSpace(p), nil
803+
}
804+
805+
labels := make([]string, len(pubFiles)+1)
806+
copy(labels, pubFiles)
807+
labels[len(pubFiles)] = "Enter path manually..."
808+
809+
idx, err := prompter.Select(ctx, "Select public key file", labels)
810+
if err != nil {
811+
return "", err
812+
}
813+
if idx < len(pubFiles) {
814+
return pubFiles[idx], nil
815+
}
816+
817+
// Manual path entry.
818+
p, err := prompter.TextInput(ctx, "Public key file path",
819+
tui.WithValidation(validateFilePath),
820+
)
821+
if err != nil || strings.TrimSpace(p) == "" {
822+
return "", err
823+
}
824+
return strings.TrimSpace(p), nil
825+
}
826+
827+
// discoverSSHPubKeys returns all .pub files found in ~/.ssh/, with well-known
828+
// key types (id_ed25519, id_rsa, id_ecdsa) sorted first.
829+
func discoverSSHPubKeys() []string {
830+
home, err := os.UserHomeDir()
831+
if err != nil {
832+
return nil
833+
}
834+
sshDir := filepath.Join(home, ".ssh")
835+
836+
matches, _ := filepath.Glob(filepath.Join(sshDir, "*.pub"))
837+
if len(matches) == 0 {
838+
return nil
839+
}
840+
841+
// Sort well-known key types to the front.
842+
preferred := map[string]int{
843+
"id_ed25519.pub": 0,
844+
"id_rsa.pub": 1,
845+
"id_ecdsa.pub": 2,
846+
}
847+
slices.SortFunc(matches, func(a, b string) int {
848+
pa, oka := preferred[filepath.Base(a)]
849+
pb, okb := preferred[filepath.Base(b)]
850+
if oka && okb {
851+
return pa - pb
852+
}
853+
if oka {
854+
return -1
855+
}
856+
if okb {
857+
return 1
858+
}
859+
return strings.Compare(a, b)
860+
})
861+
862+
return matches
863+
}
864+
746865
// --- Step 10: Startup Script ---
747866

748867
const addNewScriptValue = "__add_new_script__"

0 commit comments

Comments
 (0)