From 7b9f5b328cbe9621eb0c0dec7522873d44624882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Deroide?= Date: Mon, 2 Nov 2026 15:53:35 +0100 Subject: [PATCH 1/2] vpc support for exo compute instance create --- CHANGELOG.md | 1 + cmd/compute/instance/instance_create.go | 130 +++++++++++++ .../instance/instance_create_vpc_test.go | 179 ++++++++++++++++++ .../compute/instance_create_vpc.txtar | 50 +++++ .../compute_instance_create_flags.txtar | 4 +- 5 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 cmd/compute/instance/instance_create_vpc_test.go create mode 100644 tests/e2e/scenarios/with-api/compute/instance_create_vpc.txtar diff --git a/CHANGELOG.md b/CHANGELOG.md index e28dc0a1e..2886ac920 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - kms: add `--encryption-algorithm` to `exo kms crypto encrypt` and `decrypt`, and `--source-encryption-algorithm` and `--dest-encryption-algorithm` to `reencrypt`, to use RSA keys - kms: show the key spec in `exo kms key show` and `exo kms key list` - dbaas: add `exo dbaas acl list` for Kafka and OpenSearch, `exo dbaas acl create` and `delete` for Kafka, and Kafka and OpenSearch support in `exo dbaas acl show` (#923) +- compute: add `--vpc` and `--vpc-subnet` to `exo compute instance create` to attach an instance to VPC Subnets at creation ### Bug fixes diff --git a/cmd/compute/instance/instance_create.go b/cmd/compute/instance/instance_create.go index d04721b97..a4def71d0 100644 --- a/cmd/compute/instance/instance_create.go +++ b/cmd/compute/instance/instance_create.go @@ -1,11 +1,14 @@ package instance import ( + "context" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/pem" + "errors" "fmt" + "net" "os" "path" "slices" @@ -17,6 +20,7 @@ import ( exocmd "github.com/exoscale/cli/cmd" "github.com/exoscale/cli/cmd/compute" + "github.com/exoscale/cli/cmd/networking/vpc" "github.com/exoscale/cli/pkg/account" "github.com/exoscale/cli/pkg/globalstate" "github.com/exoscale/cli/pkg/output" @@ -52,6 +56,8 @@ type instanceCreateCmd struct { SecurityGroups []string `cli-flag:"security-group" cli-usage:"instance Security Group NAME|ID (can be specified multiple times)"` Template string `cli-usage:"instance template NAME|ID"` TemplateVisibility string `cli-usage:"instance template visibility (public|private)"` + VPC string `cli-flag:"vpc" cli-usage:"VPC NAME|ID to attach the instance to (requires --vpc-subnet)"` + VPCSubnets vpcSubnetSpecs `cli-flag:"vpc-subnet" cli-usage:"VPC Subnet to attach the instance to (format: NAME|ID[,ipv4=IP]; can be specified multiple times)"` Zone v3.ZoneName `cli-short:"z" cli-usage:"instance zone"` } @@ -84,6 +90,10 @@ func (c *instanceCreateCmd) CmdRun(cmd *cobra.Command, _ []string) error { //nol singleUseSSHPublicKey ssh.PublicKey ) + if err := validateVPCFlags(c.VPC, c.VPCSubnets); err != nil { + return err + } + ctx := exocmd.GContext client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) if err != nil { @@ -200,6 +210,13 @@ func (c *instanceCreateCmd) CmdRun(cmd *cobra.Command, _ []string) error { //nol } } + if c.VPC != "" || len(c.VPCSubnets) > 0 { + instanceReq.Vpc, err = resolveCreateInstanceVPC(ctx, client, c.VPC, c.VPCSubnets) + if err != nil { + return err + } + } + if l := len(c.SecurityGroups); l > 0 { sgs, err := client.ListSecurityGroups(ctx) if err != nil { @@ -365,6 +382,119 @@ func (c *instanceCreateCmd) CmdRun(cmd *cobra.Command, _ []string) error { //nol return nil } +// resolveCreateInstanceVPC builds the VPC attachment of an instance creation +// request from a VPC NAME|ID and a list of NAME|ID[:IPV4] subnet specs. +func resolveCreateInstanceVPC( + ctx context.Context, + client *v3.Client, + vpcNameOrID string, + subnetSpecs []vpcSubnetSpec, +) (*v3.CreateInstanceRequestVpc, error) { + if err := validateVPCFlags(vpcNameOrID, subnetSpecs); err != nil { + return nil, err + } + + vpcEntry, err := vpc.FindVPC(ctx, client, vpcNameOrID) + if err != nil { + return nil, err + } + + subnets, err := client.ListSubnets(ctx, vpcEntry.ID) + if err != nil { + return nil, fmt.Errorf("error listing VPC Subnets: %w", err) + } + + req := &v3.CreateInstanceRequestVpc{ID: vpcEntry.ID} + for _, spec := range subnetSpecs { + subnet, err := subnets.FindListSubnetEntry(spec.NameOrID) + if err != nil { + if errors.Is(err, v3.ErrNotFound) { + return nil, fmt.Errorf("subnet %q not found in VPC %q", spec.NameOrID, vpcNameOrID) + } + return nil, fmt.Errorf("error retrieving VPC Subnet: %w", err) + } + + req.Subnets = append(req.Subnets, v3.CreateInstanceRequestVpcSubnets{ID: subnet.ID, Ipv4: spec.IPv4}) + } + + return req, nil +} + +func validateVPCFlags(vpcNameOrID string, subnetSpecs []vpcSubnetSpec) error { + if vpcNameOrID == "" && len(subnetSpecs) > 0 { + return fmt.Errorf("--vpc is required when --vpc-subnet is specified") + } + if vpcNameOrID != "" && len(subnetSpecs) == 0 { + return fmt.Errorf("at least one --vpc-subnet is required when --vpc is specified") + } + return nil +} + +// vpcSubnetSpec is one --vpc-subnet value: NAME|ID[,ipv4=IP]. +type vpcSubnetSpec struct { + NameOrID string + IPv4 net.IP +} + +// vpcSubnetSpecs implements pflag.Value. Unlike a []string flag, it does not +// split on commas, so each --vpc-subnet occurrence holds one subnet with its +// key=value options. +type vpcSubnetSpecs []vpcSubnetSpec + +func (s *vpcSubnetSpecs) Set(v string) error { + spec, err := parseVPCSubnetSpec(v) + if err != nil { + return err + } + *s = append(*s, spec) + return nil +} + +func (s *vpcSubnetSpecs) String() string { + out := make([]string, len(*s)) + for i, spec := range *s { + out[i] = spec.NameOrID + if spec.IPv4 != nil { + out[i] += ",ipv4=" + spec.IPv4.String() + } + } + return strings.Join(out, " ") +} + +func (s *vpcSubnetSpecs) Type() string { return "stringArray" } + +// parseVPCSubnetSpec parses a NAME|ID[,ipv4=IP] subnet spec. +func parseVPCSubnetSpec(v string) (vpcSubnetSpec, error) { + parts := strings.Split(v, ",") + spec := vpcSubnetSpec{NameOrID: parts[0]} + if spec.NameOrID == "" || strings.Contains(spec.NameOrID, "=") { + return vpcSubnetSpec{}, fmt.Errorf("missing subnet NAME|ID (format: NAME|ID[,ipv4=IP])") + } + + for _, opt := range parts[1:] { + key, value, ok := strings.Cut(opt, "=") + if !ok { + return vpcSubnetSpec{}, fmt.Errorf("invalid option %q (expected key=value)", opt) + } + + switch key { + case "ipv4": + if spec.IPv4 != nil { + return vpcSubnetSpec{}, fmt.Errorf("ipv4 specified more than once") + } + ip := net.ParseIP(value) + if ip == nil || ip.To4() == nil { + return vpcSubnetSpec{}, fmt.Errorf("invalid IPv4 address %q", value) + } + spec.IPv4 = ip.To4() + default: + return vpcSubnetSpec{}, fmt.Errorf("unsupported option %q (supported: ipv4)", key) + } + } + + return spec, nil +} + func init() { cobra.CheckErr(exocmd.RegisterCLICommand(instanceCmd, &instanceCreateCmd{ CliCommandSettings: exocmd.DefaultCLICmdSettings(), diff --git a/cmd/compute/instance/instance_create_vpc_test.go b/cmd/compute/instance/instance_create_vpc_test.go new file mode 100644 index 000000000..d513554cd --- /dev/null +++ b/cmd/compute/instance/instance_create_vpc_test.go @@ -0,0 +1,179 @@ +// AI-generated by claude-opus-5-5 - not reviewed yet +package instance + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/testutils" + v3 "github.com/exoscale/egoscale/v3" +) + +const ( + testVPCID = "8f3a0000-0000-4000-8000-000000000001" + testSubnetAID = "8f3a0000-0000-4000-8000-0000000000a1" + testSubnetBID = "8f3a0000-0000-4000-8000-0000000000b1" +) + +func TestParseVPCSubnetSpec(t *testing.T) { + tests := []struct { + spec string + name string + ip string + wantErr string + }{ + {spec: "front", name: "front"}, + {spec: testSubnetAID, name: testSubnetAID}, + {spec: "front,ipv4=10.0.0.5", name: "front", ip: "10.0.0.5"}, + {spec: "a:b,ipv4=10.0.0.5", name: "a:b", ip: "10.0.0.5"}, + {spec: ",ipv4=10.0.0.5", wantErr: "missing subnet"}, + {spec: "", wantErr: "missing subnet"}, + {spec: "ipv4=10.0.0.5", wantErr: "missing subnet"}, + {spec: "front,", wantErr: "expected key=value"}, + {spec: "front,10.0.0.5", wantErr: "expected key=value"}, + {spec: "front,ipv4=", wantErr: "invalid IPv4"}, + {spec: "front,ipv4=nope", wantErr: "invalid IPv4"}, + {spec: "front,ipv4=fd00::1", wantErr: "invalid IPv4"}, + {spec: "front,ipv4=10.0.0.5,ipv4=10.0.0.6", wantErr: "more than once"}, + {spec: "front,ipv6=fd00::1", wantErr: `unsupported option "ipv6"`}, + } + + for _, tt := range tests { + t.Run(tt.spec, func(t *testing.T) { + spec, err := parseVPCSubnetSpec(tt.spec) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if spec.NameOrID != tt.name { + t.Errorf("name = %q, want %q", spec.NameOrID, tt.name) + } + if tt.ip == "" && spec.IPv4 != nil { + t.Errorf("ip = %v, want nil", spec.IPv4) + } + if tt.ip != "" && !spec.IPv4.Equal(net.ParseIP(tt.ip)) { + t.Errorf("ip = %v, want %s", spec.IPv4, tt.ip) + } + }) + } +} + +// TestVPCSubnetFlagKeepsCommas checks that each --vpc-subnet occurrence is +// kept whole (a []string flag would split "front,ipv4=..." in two). +func TestVPCSubnetFlagKeepsCommas(t *testing.T) { + parent := &cobra.Command{Use: "test"} + c := &instanceCreateCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings()} + if err := exocmd.RegisterCLICommand(parent, c); err != nil { + t.Fatalf("register command: %v", err) + } + cmd := parent.Commands()[0] + + if err := cmd.ParseFlags([]string{ + "--vpc", "prod", + "--vpc-subnet", "front,ipv4=10.0.0.5", + "--vpc-subnet", "back", + }); err != nil { + t.Fatalf("parse flags: %v", err) + } + if err := exocmd.CliCommandDefaultPreRun(c, cmd, []string{"web1"}); err != nil { + t.Fatalf("pre-run: %v", err) + } + + if len(c.VPCSubnets) != 2 { + t.Fatalf("got %d subnets, want 2: %+v", len(c.VPCSubnets), c.VPCSubnets) + } + if c.VPCSubnets[0].NameOrID != "front" || !c.VPCSubnets[0].IPv4.Equal(net.ParseIP("10.0.0.5")) { + t.Errorf("subnet[0] = %+v", c.VPCSubnets[0]) + } + if c.VPCSubnets[1].NameOrID != "back" || c.VPCSubnets[1].IPv4 != nil { + t.Errorf("subnet[1] = %+v", c.VPCSubnets[1]) + } +} + +func newVPCTestServer(t *testing.T) { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/vpc", func(w http.ResponseWriter, _ *http.Request) { + testutils.WriteJSON(t, w, http.StatusOK, v3.ListVpcsResponse{ + Vpcs: []v3.ListVpcEntry{{ID: v3.UUID(testVPCID), Name: "prod"}}, + }) + }) + mux.HandleFunc("/vpc/"+testVPCID+"/subnet", func(w http.ResponseWriter, _ *http.Request) { + testutils.WriteJSON(t, w, http.StatusOK, v3.ListSubnetsResponse{ + Subnets: []v3.ListSubnetEntry{ + {ID: v3.UUID(testSubnetAID), Name: "front"}, + {ID: v3.UUID(testSubnetBID), Name: "back"}, + }, + }) + }) + + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + testutils.SetupV3Client(t, srv.URL) +} + +func TestResolveCreateInstanceVPC(t *testing.T) { + newVPCTestServer(t) + + req, err := resolveCreateInstanceVPC( + context.Background(), + globalstate.EgoscaleV3Client, + "prod", + []vpcSubnetSpec{{NameOrID: "front", IPv4: net.ParseIP("10.0.0.5")}, {NameOrID: testSubnetBID}}, + ) + if err != nil { + t.Fatalf("resolve: %v", err) + } + + if req.ID != v3.UUID(testVPCID) { + t.Errorf("vpc id = %s, want %s", req.ID, testVPCID) + } + if len(req.Subnets) != 2 { + t.Fatalf("got %d subnets, want 2", len(req.Subnets)) + } + if req.Subnets[0].ID != v3.UUID(testSubnetAID) || !req.Subnets[0].Ipv4.Equal(net.ParseIP("10.0.0.5")) { + t.Errorf("subnet[0] = %+v", req.Subnets[0]) + } + if req.Subnets[1].ID != v3.UUID(testSubnetBID) || req.Subnets[1].Ipv4 != nil { + t.Errorf("subnet[1] = %+v", req.Subnets[1]) + } +} + +func TestResolveCreateInstanceVPCErrors(t *testing.T) { + newVPCTestServer(t) + + tests := []struct { + name string + vpc string + subnets []vpcSubnetSpec + wantErr string + }{ + {name: "subnet without vpc", subnets: []vpcSubnetSpec{{NameOrID: "front"}}, wantErr: "--vpc is required"}, + {name: "vpc without subnet", vpc: "prod", wantErr: "at least one --vpc-subnet"}, + {name: "unknown vpc", vpc: "nope", subnets: []vpcSubnetSpec{{NameOrID: "front"}}, wantErr: `vpc "nope" not found`}, + {name: "unknown subnet", vpc: "prod", subnets: []vpcSubnetSpec{{NameOrID: "nope"}}, wantErr: `subnet "nope" not found`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := resolveCreateInstanceVPC(context.Background(), globalstate.EgoscaleV3Client, tt.vpc, tt.subnets) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} diff --git a/tests/e2e/scenarios/with-api/compute/instance_create_vpc.txtar b/tests/e2e/scenarios/with-api/compute/instance_create_vpc.txtar new file mode 100644 index 000000000..a66deb3a0 --- /dev/null +++ b/tests/e2e/scenarios/with-api/compute/instance_create_vpc.txtar @@ -0,0 +1,50 @@ +# Test: exo compute instance create supports --vpc / --vpc-subnet +# Creates a VPC with a Subnet, then instances attached at creation time: +# one with a fixed IPv4 (NAME,ipv4=IP syntax), one with a random IPv4 (subnet ID). +# TEST_ZONE and TEST_RUN_ID are injected by the API test runner. + +# --vpc and --vpc-subnet must be used together +! exec exo --zone $TEST_ZONE compute instance create vpc-err-$TEST_RUN_ID --vpc-subnet front +stderr '--vpc is required when --vpc-subnet is specified' + +! exec exo --zone $TEST_ZONE compute instance create vpc-err-$TEST_RUN_ID --vpc whatever +stderr 'at least one --vpc-subnet is required' + +# Setup: VPC + Subnet +exec exo --zone $TEST_ZONE --output-format json networking vpc create vpc-$TEST_RUN_ID +json-setenv VPC_ID id stdout + +exec exo --zone $TEST_ZONE --output-format json networking vpc subnet create $VPC_ID front-$TEST_RUN_ID --ipv4-block 10.42.0.0/24 --address-family inet4 --address-space private +json-setenv SUBNET_ID id stdout + +# Invalid --vpc-subnet values are rejected +! exec exo --zone $TEST_ZONE compute instance create vpc-err-$TEST_RUN_ID --vpc vpc-$TEST_RUN_ID --vpc-subnet front-$TEST_RUN_ID,ipv4=not-an-ip +stderr 'invalid IPv4 address' + +! exec exo --zone $TEST_ZONE compute instance create vpc-err-$TEST_RUN_ID --vpc vpc-$TEST_RUN_ID --vpc-subnet front-$TEST_RUN_ID,ipv6=fd00::1 +stderr 'unsupported option "ipv6"' + +# Instance with a fixed IPv4, VPC and Subnet referenced by name +exec exo --zone $TEST_ZONE --output-format json compute instance create vpc-a-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 --vpc vpc-$TEST_RUN_ID --vpc-subnet front-$TEST_RUN_ID,ipv4=10.42.0.10 +json-setenv INSTANCE_A_ID id stdout + +wait-instance-state $TEST_ZONE $INSTANCE_A_ID running 300 + +exec exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_A_ID +stdout '"vpc":"vpc-[a-z0-9-]+"' +stdout '"vpc_subnets":\["front-[a-z0-9-]+ 10\.42\.0\.10"\]' + +# Instance with a random IPv4, VPC and Subnet referenced by ID +exec exo --zone $TEST_ZONE --output-format json compute instance create vpc-b-$TEST_RUN_ID --instance-type standard.tiny --template 'Linux Ubuntu 22.04 LTS 64-bit' --disk-size 10 --vpc $VPC_ID --vpc-subnet $SUBNET_ID +json-setenv INSTANCE_B_ID id stdout + +wait-instance-state $TEST_ZONE $INSTANCE_B_ID running 300 + +exec exo --zone $TEST_ZONE --output-format json compute instance show $INSTANCE_B_ID +stdout '"vpc_subnets":\["front-[a-z0-9-]+ 10\.42\.0\.[0-9]+"\]' + +# Cleanup +exec exo --zone $TEST_ZONE compute instance delete --force $INSTANCE_A_ID +exec exo --zone $TEST_ZONE compute instance delete --force $INSTANCE_B_ID +exec exo --zone $TEST_ZONE networking vpc subnet delete --force $VPC_ID $SUBNET_ID +exec exo --zone $TEST_ZONE networking vpc delete --force $VPC_ID diff --git a/tests/e2e/scenarios/without-api/compute_instance_create_flags.txtar b/tests/e2e/scenarios/without-api/compute_instance_create_flags.txtar index 12fde7341..ae3706ecb 100644 --- a/tests/e2e/scenarios/without-api/compute_instance_create_flags.txtar +++ b/tests/e2e/scenarios/without-api/compute_instance_create_flags.txtar @@ -1,6 +1,8 @@ -# Test: compute instance create --help shows the reverse DNS flag +# Test: compute instance create --help shows the reverse DNS and VPC flags exec exo compute instance create --help stdout '--reverse-dns' stdout '--instance-type' stdout '--template' +stdout '--vpc string' +stdout '--vpc-subnet' From 3f1d511c859b02c4d8cc869a985e62b1e45b1750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Deroide?= Date: Mon, 2 Nov 2026 16:50:27 +0100 Subject: [PATCH 2/2] apply code review suggestions --- CHANGELOG.md | 2 +- cmd/compute/instance/instance_create.go | 8 ++--- .../instance/instance_create_vpc_test.go | 31 +++++++++++++++++-- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2886ac920..ba7407786 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ - kms: add `--encryption-algorithm` to `exo kms crypto encrypt` and `decrypt`, and `--source-encryption-algorithm` and `--dest-encryption-algorithm` to `reencrypt`, to use RSA keys - kms: show the key spec in `exo kms key show` and `exo kms key list` - dbaas: add `exo dbaas acl list` for Kafka and OpenSearch, `exo dbaas acl create` and `delete` for Kafka, and Kafka and OpenSearch support in `exo dbaas acl show` (#923) -- compute: add `--vpc` and `--vpc-subnet` to `exo compute instance create` to attach an instance to VPC Subnets at creation +- compute: add `--vpc` and `--vpc-subnet` to `exo compute instance create` to attach an instance to VPC Subnets at creation (#927) ### Bug fixes diff --git a/cmd/compute/instance/instance_create.go b/cmd/compute/instance/instance_create.go index a4def71d0..a846635f1 100644 --- a/cmd/compute/instance/instance_create.go +++ b/cmd/compute/instance/instance_create.go @@ -210,7 +210,7 @@ func (c *instanceCreateCmd) CmdRun(cmd *cobra.Command, _ []string) error { //nol } } - if c.VPC != "" || len(c.VPCSubnets) > 0 { + if c.VPC != "" { instanceReq.Vpc, err = resolveCreateInstanceVPC(ctx, client, c.VPC, c.VPCSubnets) if err != nil { return err @@ -383,17 +383,13 @@ func (c *instanceCreateCmd) CmdRun(cmd *cobra.Command, _ []string) error { //nol } // resolveCreateInstanceVPC builds the VPC attachment of an instance creation -// request from a VPC NAME|ID and a list of NAME|ID[:IPV4] subnet specs. +// request from a VPC NAME|ID and a list of subnet specs. func resolveCreateInstanceVPC( ctx context.Context, client *v3.Client, vpcNameOrID string, subnetSpecs []vpcSubnetSpec, ) (*v3.CreateInstanceRequestVpc, error) { - if err := validateVPCFlags(vpcNameOrID, subnetSpecs); err != nil { - return nil, err - } - vpcEntry, err := vpc.FindVPC(ctx, client, vpcNameOrID) if err != nil { return nil, err diff --git a/cmd/compute/instance/instance_create_vpc_test.go b/cmd/compute/instance/instance_create_vpc_test.go index d513554cd..0c403eefb 100644 --- a/cmd/compute/instance/instance_create_vpc_test.go +++ b/cmd/compute/instance/instance_create_vpc_test.go @@ -162,8 +162,6 @@ func TestResolveCreateInstanceVPCErrors(t *testing.T) { subnets []vpcSubnetSpec wantErr string }{ - {name: "subnet without vpc", subnets: []vpcSubnetSpec{{NameOrID: "front"}}, wantErr: "--vpc is required"}, - {name: "vpc without subnet", vpc: "prod", wantErr: "at least one --vpc-subnet"}, {name: "unknown vpc", vpc: "nope", subnets: []vpcSubnetSpec{{NameOrID: "front"}}, wantErr: `vpc "nope" not found`}, {name: "unknown subnet", vpc: "prod", subnets: []vpcSubnetSpec{{NameOrID: "nope"}}, wantErr: `subnet "nope" not found`}, } @@ -177,3 +175,32 @@ func TestResolveCreateInstanceVPCErrors(t *testing.T) { }) } } + +func TestValidateVPCFlags(t *testing.T) { + tests := []struct { + name string + vpc string + subnets []vpcSubnetSpec + wantErr string + }{ + {name: "no vpc flags"}, + {name: "vpc and subnet", vpc: "prod", subnets: []vpcSubnetSpec{{NameOrID: "front"}}}, + {name: "subnet without vpc", subnets: []vpcSubnetSpec{{NameOrID: "front"}}, wantErr: "--vpc is required"}, + {name: "vpc without subnet", vpc: "prod", wantErr: "at least one --vpc-subnet"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateVPCFlags(tt.vpc, tt.subnets) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +}