Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/verda-cloud/verdacloud-sdk-go v1.4.2
github.com/verda-cloud/verdagostack v1.2.0
github.com/verda-cloud/verdagostack v1.3.0
go.yaml.in/yaml/v3 v3.0.4
)

Expand Down Expand Up @@ -53,5 +53,5 @@ require (
go.uber.org/zap v1.27.1 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
golang.org/x/text v0.35.0 // indirect
)
8 changes: 4 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -104,8 +104,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/verda-cloud/verdacloud-sdk-go v1.4.2 h1:oVb8fHVQOY+YPuuMYMee9gYCkPTwAw01LmkqxM21T/Y=
github.com/verda-cloud/verdacloud-sdk-go v1.4.2/go.mod h1:pmlpiCL9fTSikZ3qWLJPpHOG0E8PKkQVUX5s4Z+SktY=
github.com/verda-cloud/verdagostack v1.2.0 h1:HdVFXWyfEMq17ldn3+FrePYYB5Jdquz9D/Y0V+4d2BE=
github.com/verda-cloud/verdagostack v1.2.0/go.mod h1:h9XR9uCYBYauRyGF4NLlScD5bC2UEMxMkqg6fUVjyDo=
github.com/verda-cloud/verdagostack v1.3.0 h1:NxW5OaE79tbc9pemy/Zasjqw08IuvNr0ivlpe0VP91Q=
github.com/verda-cloud/verdagostack v1.3.0/go.mod h1:eWTGv3kbBUGVCjNKZYLzzK9+UwpNWoPN3B2vebN2otY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
Expand All @@ -124,8 +124,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
Expand Down
2 changes: 1 addition & 1 deletion internal/verda-cli/cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func NewCmdLogin(f cmdutil.Factory, ioStreams cmdutil.IOStreams) *cobra.Command
RunE: func(cmd *cobra.Command, args []string) error {
if strings.TrimSpace(opts.ClientID) == "" || strings.TrimSpace(opts.ClientSecret) == "" {
flow := buildLoginFlow(opts)
engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut))
engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation())
if err := engine.Run(cmd.Context(), flow); err != nil {
return err
}
Expand Down
28 changes: 16 additions & 12 deletions internal/verda-cli/cmd/auth/wizard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ package auth

import (
"context"
"io"
"testing"

tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing"
"github.com/verda-cloud/verdagostack/pkg/tui/wizard"
)

Expand All @@ -16,14 +16,16 @@ func TestBuildLoginFlowHappyPath(t *testing.T) {
BaseURL: defaultBaseURL,
}

mock := tuitest.New()
mock.AddTextInput("staging") // profile
mock.AddTextInput("https://staging-api.verda.com") // base-url
mock.AddTextInput("my-id") // client-id
mock.AddPassword("my-secret") // client-secret

flow := buildLoginFlow(opts)
engine := wizard.NewEngine(mock, nil)
engine := wizard.NewEngine(nil, nil,
wizard.WithOutput(io.Discard),
wizard.WithTestResults(
wizard.TextResult("staging"), // profile
wizard.TextResult("https://staging-api.verda.com"), // base-url
wizard.TextResult("my-id"), // client-id
wizard.TextResult("my-secret"), // client-secret (password prompt returns text too)
),
)

if err := engine.Run(context.Background(), flow); err != nil {
t.Fatalf("wizard Run failed: %v", err)
Expand Down Expand Up @@ -53,11 +55,13 @@ func TestBuildLoginFlowWithPresetFlags(t *testing.T) {
}

// Only client-secret needs prompting (profile, base-url, client-id are preset via IsSet).
mock := tuitest.New()
mock.AddPassword("the-secret")

flow := buildLoginFlow(opts)
engine := wizard.NewEngine(mock, nil)
engine := wizard.NewEngine(nil, nil,
wizard.WithOutput(io.Discard),
wizard.WithTestResults(
wizard.TextResult("the-secret"), // client-secret
),
)

if err := engine.Run(context.Background(), flow); err != nil {
t.Fatalf("wizard Run failed: %v", err)
Expand Down
2 changes: 1 addition & 1 deletion internal/verda-cli/cmd/vm/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ func missingCreateFlags(opts *createOptions) []string {

func runWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) error {
flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeDeploy, ioStreams.ErrOut)
engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut))
engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation())
return engine.Run(ctx, flow)
}

Expand Down
57 changes: 12 additions & 45 deletions internal/verda-cli/cmd/vm/wizard.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
const (
billingTypeSpot = "spot"
kindGPU = "gpu"
kindCPU = "cpu"

contractPayAsYouGo = "PAY_AS_YOU_GO"
contractSpot = "SPOT"
Expand Down Expand Up @@ -78,7 +79,7 @@ func RunTemplateWizard(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil

func runTemplateWizardWithOpts(ctx context.Context, f cmdutil.Factory, ioStreams cmdutil.IOStreams, opts *createOptions) (*TemplateResult, error) {
flow := buildCreateFlow(ctx, f.VerdaClient, opts, WizardModeTemplate, ioStreams.ErrOut)
engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut))
engine := wizard.NewEngine(f.Prompter(), f.Status(), wizard.WithOutput(ioStreams.ErrOut), wizard.WithExitConfirmation())
if err := engine.Run(ctx, flow); err != nil {
return nil, err
}
Expand All @@ -104,7 +105,7 @@ func optsToTemplateResult(opts *createOptions) *TemplateResult {
Description: opts.templateDescription,
}
if opts.IsSpot {
result.BillingType = "spot"
result.BillingType = billingTypeSpot
} else {
result.BillingType = billingTypeOnDemand
}
Expand Down Expand Up @@ -258,7 +259,7 @@ func stepKind(opts *createOptions) wizard.Step {
Required: true,
Loader: wizard.StaticChoices(
wizard.Choice{Label: "GPU", Value: kindGPU, Description: "GPU-accelerated instances"},
wizard.Choice{Label: "CPU", Value: "cpu", Description: "CPU-only instances"},
wizard.Choice{Label: "CPU", Value: kindCPU, Description: "CPU-only instances"},
),
Default: func(_ map[string]any) any { return opts.Kind },
Setter: func(v any) { opts.Kind = v.(string) },
Expand Down Expand Up @@ -318,51 +319,17 @@ func stepInstanceType(getClient clientFunc, cache *apiCache, opts *createOptions
}
}

var choices []wizard.Choice
for i := range types {
t := &types[i]
if !matchesKind(t.InstanceType, kind) {
continue
choices := buildInstanceTypeChoices(types, kind, isSpot, availLocs, cache)
if len(choices) == 0 {
kindLabel := "GPU"
if kind == kindCPU {
kindLabel = "CPU"
}
if availLocs != nil && len(availLocs[t.InstanceType]) == 0 {
continue // skip unavailable instance types (deploy mode only)
}
totalPrice := float64(t.PricePerHour)
pricingLabel := "on-demand"
if isSpot {
totalPrice = float64(t.SpotPrice)
}
units := instanceUnits(t)
var priceStr string
if units > 1 {
unitLabel := unitLabelGPU
if t.GPU.NumberOfGPUs == 0 {
unitLabel = unitLabelVCPU
}
perUnit := totalPrice / float64(units)
priceStr = fmt.Sprintf("$%.3f/%s/hr $%.3f/hr", perUnit, unitLabel, totalPrice)
} else {
priceStr = fmt.Sprintf("$%.3f/hr", totalPrice)
pricingLabel = "spot"
}
label := fmt.Sprintf("%s — %s, %s %s",
t.InstanceType, formatGPU(t), formatMemory(t), priceStr)
var desc string
if availLocs != nil {
locs := availLocs[t.InstanceType]
locNames := make([]string, len(locs))
for j, code := range locs {
if loc, ok := cache.locations[code]; ok {
locNames[j] = loc.Code
} else {
locNames[j] = code
}
}
desc = fmt.Sprintf("[%s]", strings.Join(locNames, ", "))
}
choices = append(choices, wizard.Choice{
Label: label,
Value: t.InstanceType,
Description: desc,
})
return nil, fmt.Errorf("no %s %s instances are available right now — try a different compute type or billing type", kindLabel, pricingLabel)
}
return choices, nil
},
Expand Down
53 changes: 53 additions & 0 deletions internal/verda-cli/cmd/vm/wizard_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,59 @@ func loadAvailableLocations(ctx context.Context, cache *apiCache, getClient clie
return choices, nil
}

// --- Instance type choices ---

// buildInstanceTypeChoices filters and formats instance types into wizard choices.
func buildInstanceTypeChoices(types []verda.InstanceTypeInfo, kind string, isSpot bool, availLocs map[string][]string, cache *apiCache) []wizard.Choice {
choices := make([]wizard.Choice, 0, len(types))
for i := range types {
t := &types[i]
if !matchesKind(t.InstanceType, kind) {
continue
}
if availLocs != nil && len(availLocs[t.InstanceType]) == 0 {
continue
}
totalPrice := float64(t.PricePerHour)
if isSpot {
totalPrice = float64(t.SpotPrice)
}
units := instanceUnits(t)
var priceStr string
if units > 1 {
unitLabel := unitLabelGPU
if t.GPU.NumberOfGPUs == 0 {
unitLabel = unitLabelVCPU
}
perUnit := totalPrice / float64(units)
priceStr = fmt.Sprintf("$%.3f/%s/hr $%.3f/hr", perUnit, unitLabel, totalPrice)
} else {
priceStr = fmt.Sprintf("$%.3f/hr", totalPrice)
}
label := fmt.Sprintf("%s — %s, %s %s",
t.InstanceType, formatGPU(t), formatMemory(t), priceStr)
var desc string
if availLocs != nil {
locs := availLocs[t.InstanceType]
locNames := make([]string, len(locs))
for j, code := range locs {
if loc, ok := cache.locations[code]; ok {
locNames[j] = loc.Code
} else {
locNames[j] = code
}
}
desc = fmt.Sprintf("[%s]", strings.Join(locNames, ", "))
}
choices = append(choices, wizard.Choice{
Label: label,
Value: t.InstanceType,
Description: desc,
})
}
return choices
}

// --- Helpers ---

// instanceUnits returns the number of billable units (GPUs or vCPUs).
Expand Down
44 changes: 23 additions & 21 deletions internal/verda-cli/cmd/vm/wizard_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"testing"

"github.com/verda-cloud/verdacloud-sdk-go/pkg/verda"
tuitest "github.com/verda-cloud/verdagostack/pkg/tui/testing"
"github.com/verda-cloud/verdagostack/pkg/tui/wizard"
)

Expand All @@ -27,20 +26,21 @@ func TestBuildCreateFlowHappyPath(t *testing.T) {
}

// The wizard will prompt for: billing-type, kind, os-volume-size,
// hostname, description.
mock := tuitest.New()
mock.AddSelect(0) // billing-type: On-Demand
mock.AddSelect(0) // kind: GPU
mock.AddTextInput("100") // os-volume-size
mock.AddTextInput("my-gpu") // hostname
mock.AddTextInput("") // description (use default = hostname)
mock.AddConfirm(true) // confirm deploy

// errClient returns error — API steps skipped via IsSet, confirm step handles error gracefully.
// hostname, description, confirm-deploy.
ctx := context.Background()
errClient := func() (*verda.Client, error) { return nil, errors.New("no client in test") }
flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy, io.Discard)
engine := wizard.NewEngine(mock, nil)
engine := wizard.NewEngine(nil, nil,
wizard.WithOutput(io.Discard),
wizard.WithTestResults(
wizard.SelectResult(0), // billing-type: On-Demand
wizard.SelectResult(0), // kind: GPU
wizard.TextResult("100"), // os-volume-size
wizard.TextResult("my-gpu"), // hostname
wizard.TextResult(""), // description (use default = hostname)
wizard.ConfirmResult(true), // confirm deploy
),
)

if err := engine.Run(ctx, flow); err != nil {
t.Fatalf("wizard Run failed: %v", err)
Expand Down Expand Up @@ -75,18 +75,20 @@ func TestBuildCreateFlowSpotSkipsContract(t *testing.T) {
VolumeSpecs: []string{"data:100:NVMe"}, // pre-fill so storage step is skipped
}

mock := tuitest.New()
mock.AddSelect(1) // billing-type: Spot Instance
mock.AddSelect(0) // kind: GPU
mock.AddTextInput("50") // os-volume-size
mock.AddTextInput("spot-vm") // hostname
mock.AddTextInput("") // description
mock.AddConfirm(true) // confirm deploy

ctx := context.Background()
errClient := func() (*verda.Client, error) { return nil, errors.New("no client in test") }
flow := buildCreateFlow(ctx, errClient, opts, WizardModeDeploy, io.Discard)
engine := wizard.NewEngine(mock, nil)
engine := wizard.NewEngine(nil, nil,
wizard.WithOutput(io.Discard),
wizard.WithTestResults(
wizard.SelectResult(1), // billing-type: Spot Instance
wizard.SelectResult(0), // kind: GPU
wizard.TextResult("50"), // os-volume-size
wizard.TextResult("spot-vm"), // hostname
wizard.TextResult(""), // description
wizard.ConfirmResult(true), // confirm deploy
),
)

if err := engine.Run(ctx, flow); err != nil {
t.Fatalf("wizard Run failed: %v", err)
Expand Down
Loading