From f85bfad1ab82d583e83c618fb6c89f629899e8ab Mon Sep 17 00:00:00 2001 From: Coker Richard <82083946+cokerrd@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:11:53 +0100 Subject: [PATCH 1/4] test: add instance create test --- pkg/api/instance/instance_test.go | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/pkg/api/instance/instance_test.go b/pkg/api/instance/instance_test.go index 89b6886..ab37ae7 100644 --- a/pkg/api/instance/instance_test.go +++ b/pkg/api/instance/instance_test.go @@ -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" { From af96d2b8e58848c8a3f58d87bb2d5f25d50f4eae Mon Sep 17 00:00:00 2001 From: Coker Richard <82083946+cokerrd@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:13:37 +0100 Subject: [PATCH 2/4] feat: instance creation with VPC network type and attach existing networks --- internal/commands/instance.go | 65 +++++++++++++++++++++++++++++------ pkg/api/instance/instance.go | 2 ++ 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/internal/commands/instance.go b/internal/commands/instance.go index 5ef2d33..9ba5920 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -18,6 +18,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 { @@ -368,8 +374,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", @@ -404,9 +419,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") } @@ -417,9 +429,37 @@ 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 == "" && networks == nil { + 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 == "" && networks == nil { + 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 == "" && networks == nil { + 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") } h := hostname @@ -476,7 +516,7 @@ func newInstanceCreateCmd() *cobra.Command { Template: template, IsPublic: isPublic, NetworkType: networkType, - Networks: []string{}, + Networks: networks, BillingCycle: billingCycle, SSHKey: sshKeyPtr, AuthMethod: authMethod, @@ -491,6 +531,8 @@ func newInstanceCreateCmd() *cobra.Command { ComputeCategory: computeCategory, BlockstoragePlan: blockstoragePlan, NetworkPlan: networkPlan, + DefaultNetwork: defaultNetwork, + VrPlan: vrPlan, UserData: userDataPtr, } return runInstanceCreate(cmd, req, wait) @@ -501,15 +543,18 @@ func newInstanceCreateCmd() *cobra.Command { cmd.Flags().StringVar(&project, "project", "", "Project slug (required)") cmd.Flags().StringVar(®ion, "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)") + cmd.Flags().StringVar(&defaultNetwork, "default-network", "", "Default network slug (optional; required when attaching multiple networks)") + cmd.Flags().StringSliceVar(&networks, "networks", nil, "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)") diff --git a/pkg/api/instance/instance.go b/pkg/api/instance/instance.go index 90246a0..498f090 100644 --- a/pkg/api/instance/instance.go +++ b/pkg/api/instance/instance.go @@ -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"` From b576ffda6df01daa0474f488b7471d4bde702c4a Mon Sep 17 00:00:00 2001 From: Coker Richard <82083946+cokerrd@users.noreply.github.com> Date: Sun, 2 Aug 2026 23:29:36 +0100 Subject: [PATCH 3/4] fix: bad request on instance creation with new network --- internal/commands/instance.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/commands/instance.go b/internal/commands/instance.go index 9ba5920..6a975e0 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -434,7 +434,7 @@ func newInstanceCreateCmd() *cobra.Command { } switch networkType { case NetworkTypeL2: - if networkPlan == "" && networks == nil { + if networkPlan == "" && len(networks) == 0 { return fmt.Errorf("--network-plan or --networks is required when --network-type is '%s'", networkType) } if vrPlan != "" { @@ -444,14 +444,14 @@ func newInstanceCreateCmd() *cobra.Command { return fmt.Errorf("--is-public cannot be true for '%s' networks; pass --is-public=false", networkType) } case NetworkTypeIsolated: - if networkPlan == "" && networks == nil { + 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 == "" && networks == nil { + if vrPlan == "" && len(networks) == 0 { return fmt.Errorf("--vr-plan or --networks is required when --network-type is '%s'", networkType) } if networkPlan != "" { @@ -554,7 +554,7 @@ func newInstanceCreateCmd() *cobra.Command { 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)") cmd.Flags().StringVar(&defaultNetwork, "default-network", "", "Default network slug (optional; required when attaching multiple networks)") - cmd.Flags().StringSliceVar(&networks, "networks", nil, "List of network slugs to attach to the instance (optional; see: zcp network list)") + 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)") From b19f625147dbe8c0ae63629a71c9ff434e6a3772 Mon Sep 17 00:00:00 2001 From: Coker Richard <82083946+cokerrd@users.noreply.github.com> Date: Mon, 3 Aug 2026 00:04:32 +0100 Subject: [PATCH 4/4] feat: validate --default-network against --networks specified --- internal/commands/instance.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/commands/instance.go b/internal/commands/instance.go index 6a975e0..52acd12 100644 --- a/internal/commands/instance.go +++ b/internal/commands/instance.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "slices" "strconv" "strings" "time" @@ -461,6 +462,9 @@ func newInstanceCreateCmd() *cobra.Command { if len(networks) > 1 && defaultNetwork == "" { return fmt.Errorf("--default-network is required when attaching multiple networks") } + if defaultNetwork != "" && !slices.Contains(networks, defaultNetwork) { + return fmt.Errorf("--default-network must be one of --networks") + } h := hostname if h == "" {