Skip to content
Open
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
69 changes: 59 additions & 10 deletions internal/commands/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"fmt"
"os"
"os/exec"
"slices"
"strconv"
"strings"
"time"
Expand All @@ -18,6 +19,12 @@ import (
"github.com/zsoftly/zcp-cli/pkg/api/instance"
)

const (
NetworkTypeL2 = "L2"
NetworkTypeIsolated = "Isolated"
NetworkTypeVpc = "Vpc"
)

// instanceGetRetryWait controls the backoff between transient-routing-error retries.
// Overridden in tests to avoid real sleeps.
var instanceGetRetryWait = func(attempt int) time.Duration {
Expand Down Expand Up @@ -368,8 +375,17 @@ func newInstanceCreateCmd() *cobra.Command {
disk int
wait bool
isPublic bool
networks []string
vrPlan string
defaultNetwork string
)

var validNetworkTypes = map[string]bool{
NetworkTypeL2: true,
NetworkTypeIsolated: true,
NetworkTypeVpc: true,
}

cmd := &cobra.Command{
Use: "create",
Short: "Create a new virtual machine",
Expand Down Expand Up @@ -404,9 +420,6 @@ func newInstanceCreateCmd() *cobra.Command {
if storageCategory == "" {
return fmt.Errorf("--storage-category is required")
}
if networkPlan == "" {
return fmt.Errorf("--network-plan is required")
}
if userData != "" && userDataFile != "" {
return fmt.Errorf("--user-data and --user-data-file are mutually exclusive")
}
Expand All @@ -417,9 +430,40 @@ func newInstanceCreateCmd() *cobra.Command {
}
userData = string(data)
}

if networkType == "L2" && isPublic {
return fmt.Errorf("--is-public cannot be true for L2 networks; pass --is-public=false")
if !validNetworkTypes[networkType] {
return fmt.Errorf("invalid value %q for --network-type: must be one of L2, Isolated, Vpc", networkType)
}
switch networkType {
case NetworkTypeL2:
if networkPlan == "" && len(networks) == 0 {
return fmt.Errorf("--network-plan or --networks is required when --network-type is '%s'", networkType)
}
if vrPlan != "" {
return fmt.Errorf("--vr-plan is not allowed when --network-type is '%s'", networkType)
}
if isPublic {
return fmt.Errorf("--is-public cannot be true for '%s' networks; pass --is-public=false", networkType)
}
case NetworkTypeIsolated:
if networkPlan == "" && len(networks) == 0 {
return fmt.Errorf("--network-plan or --networks is required when --network-type is '%s'", networkType)
}
if vrPlan != "" {
return fmt.Errorf("--vr-plan is not allowed when --network-type is '%s'", networkType)
}
case NetworkTypeVpc:
if vrPlan == "" && len(networks) == 0 {
return fmt.Errorf("--vr-plan or --networks is required when --network-type is '%s'", networkType)
}
if networkPlan != "" {
return fmt.Errorf("--network-plan is not allowed when --network-type is '%s'", networkType)
}
}
if len(networks) > 1 && defaultNetwork == "" {
return fmt.Errorf("--default-network is required when attaching multiple networks")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
if defaultNetwork != "" && !slices.Contains(networks, defaultNetwork) {
return fmt.Errorf("--default-network must be one of --networks")
}

h := hostname
Expand Down Expand Up @@ -476,7 +520,7 @@ func newInstanceCreateCmd() *cobra.Command {
Template: template,
IsPublic: isPublic,
NetworkType: networkType,
Networks: []string{},
Networks: networks,
BillingCycle: billingCycle,
SSHKey: sshKeyPtr,
AuthMethod: authMethod,
Expand All @@ -491,6 +535,8 @@ func newInstanceCreateCmd() *cobra.Command {
ComputeCategory: computeCategory,
BlockstoragePlan: blockstoragePlan,
NetworkPlan: networkPlan,
DefaultNetwork: defaultNetwork,
VrPlan: vrPlan,
UserData: userDataPtr,
}
return runInstanceCreate(cmd, req, wait)
Expand All @@ -501,15 +547,18 @@ func newInstanceCreateCmd() *cobra.Command {
cmd.Flags().StringVar(&project, "project", "", "Project slug (required)")
cmd.Flags().StringVar(&region, "region", "", "Region slug (required)")
cmd.Flags().StringVar(&template, "template", "", "Template slug (required)")
cmd.Flags().StringVar(&plan, "plan", "", "Plan slug (required)")
cmd.Flags().StringVar(&plan, "plan", "", "Plan slug (e.g. ca2sxs- see: zcp plan vm (required)")
cmd.Flags().StringVar(&billingCycle, "billing-cycle", "", "Billing cycle slug: hourly, monthly, etc. (required)")
cmd.Flags().StringVar(&networkType, "network-type", "Isolated", "Network type (default: Isolated)")
cmd.Flags().StringVar(&networkType, "network-type", "Isolated", "Network type: Isolated, L2 or Vpc (required)")
cmd.Flags().StringVar(&sshKey, "ssh-key", "", "Name of an existing SSH key to attach for login (optional; see 'zcp ssh-key list')")
cmd.Flags().StringVar(&hostname, "hostname", "", "Hostname (defaults to --name)")
cmd.Flags().StringVar(&storageCategory, "storage-category", "", "Storage category (required, e.g. premium-ssd - see: zcp plan storage)")
cmd.Flags().StringVar(&computeCategory, "compute-category", "", "Compute category slug (optional)")
cmd.Flags().StringVar(&blockstoragePlan, "blockstorage-plan", "", "Block storage plan slug (optional, e.g. b2g1 — see: zcp plan storage)")
cmd.Flags().StringVar(&networkPlan, "network-plan", "", "Network plan slug (required, e.g. pnet-yow, pnet-yul — see: zcp plan network)")
cmd.Flags().StringVar(&networkPlan, "network-plan", "", "Network plan slug (optional; required when creating an Isolated or L2 network type— see: zcp plan network)")
cmd.Flags().StringVar(&vrPlan, "vr-plan", "", "Virtual router plan slug (optional; required when creating a VPC — see: zcp plan router)")
Comment on lines +550 to +559

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Correct the conditional flag help text.

--network-type has a default value, so it is not required. --network-plan and --vr-plan are optional when --networks is set. Update these descriptions to match the validation rules.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/commands/instance.go` around lines 546 - 555, Update the flag
descriptions in the command setup around the networkType, networkPlan, and
vrPlan variables to reflect validation: remove “required” from --network-type,
and state that --network-plan and --vr-plan are only required when creating
their applicable network types unless --networks is provided.

cmd.Flags().StringVar(&defaultNetwork, "default-network", "", "Default network slug (optional; required when attaching multiple networks)")
cmd.Flags().StringSliceVar(&networks, "networks", []string{}, "List of network slugs to attach to the instance (optional; see: zcp network list)")
cmd.Flags().StringVar(&userData, "user-data", "", "Startup script content (cloud-init / bash)")
cmd.Flags().StringVar(&userDataFile, "user-data-file", "", "Path to a file containing the startup script")
cmd.Flags().IntVar(&cpu, "cpu", 0, "Number of vCPUs for a custom plan (e.g. 2)")
Expand Down
2 changes: 2 additions & 0 deletions pkg/api/instance/instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ type CreateRequest struct {
ComputeCategory string `json:"compute_category,omitempty"`
BlockstoragePlan string `json:"blockstorage_plan,omitempty"`
NetworkPlan string `json:"network_plan,omitempty"`
VrPlan string `json:"vr_plan,omitempty"`
DefaultNetwork string `json:"default_network,omitempty"`
IsVNF bool `json:"is_vnf"`
IsVMPasswordRequired bool `json:"is_vm_password_required"`
IsVMSSHRequired bool `json:"is_vm_ssh_required"`
Expand Down
44 changes: 44 additions & 0 deletions pkg/api/instance/instance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,50 @@ func newClient(baseURL string) *httpclient.Client {
})
}

func TestCreate(t *testing.T) {
var gotBody map[string]interface{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/virtual-machines" {
t.Errorf("method=%s path=%s", r.Method, r.URL.Path)
}
if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}

vm := instance.VirtualMachine{
ID: "vm-1",
Name: "test-vm",
Slug: "test-vm",
State: "Starting",
}
data, _ := json.Marshal(vm)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "Success", "data": json.RawMessage(data),
})
}))
defer srv.Close()

svc := instance.NewService(newClient(srv.URL))
req := instance.CreateRequest{
Name: "test-vm",
}
vm, err := svc.Create(context.Background(), req)
if err != nil {
t.Fatalf("Create() error = %v", err)
}
if vm.Slug != "test-vm" {
t.Errorf("slug = %q, want %q", vm.Slug, "test-vm")
}
if vm.State != "Starting" {
t.Errorf("state = %q, want %q", vm.State, "Starting")
}
if gotBody["name"] != "test-vm" {
t.Errorf("request body name = %v, want %q", gotBody["name"], "test-vm")
}
}

func TestList(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/virtual-machines" {
Expand Down
Loading