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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (#927)

### Bug fixes

Expand Down
126 changes: 126 additions & 0 deletions cmd/compute/instance/instance_create.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package instance

import (
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"net"
"os"
"path"
"slices"
Expand All @@ -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"
Expand Down Expand Up @@ -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"`
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -200,6 +210,13 @@ func (c *instanceCreateCmd) CmdRun(cmd *cobra.Command, _ []string) error { //nol
}
}

if c.VPC != "" {
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 {
Expand Down Expand Up @@ -365,6 +382,115 @@ 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 subnet specs.
func resolveCreateInstanceVPC(
ctx context.Context,
client *v3.Client,
vpcNameOrID string,
subnetSpecs []vpcSubnetSpec,
) (*v3.CreateInstanceRequestVpc, error) {
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(),
Expand Down
206 changes: 206 additions & 0 deletions cmd/compute/instance/instance_create_vpc_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// 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: "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)
}
})
}
}

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)
}
})
}
}
Loading
Loading