diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a21827e6..b4762af3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,11 +6,14 @@ ### Features + +- networking: add VPC support under `exo networking vpc` (#880) - dbaas: add clickhouse subcommands — create with type-specific flags, show/update/delete support (incl. `--uri` building a `clickhouse://` connection string from the revealed `avnadmin` credentials), user list/show/create/delete/reset/reveal (create/reset return the generated password), role list/delete, acl show (#894) - dbaas: e2e scenarios for clickhouse lifecycle, user ops, role ACL and pg config lifecycle; local runner forwards the account endpoint so preprod runs need no manual env setup (#894) - sks: add `--kubelet-max-pods` flag to `nodepool add`/`update` commands (#904) - sks: add a `generate-karpenter-manifests` (short: `km`) command to generate the Karpenter manifests relevant to a cluster + ### Bug fixes - dbaas: fix empty table output for user create, role list and acl show (output routed to a nil writer) (#894) diff --git a/cmd/compute/instance/instance_attach_to_subnet.go b/cmd/compute/instance/instance_attach_to_subnet.go new file mode 100644 index 000000000..51ec823e6 --- /dev/null +++ b/cmd/compute/instance/instance_attach_to_subnet.go @@ -0,0 +1,110 @@ +package instance + +import ( + "context" + "fmt" + "net" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/cmd/networking/vpc" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/utils" + v3 "github.com/exoscale/egoscale/v3" +) + +type instanceAttachToSubnetCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"attach-to-subnet"` + + Instance string `cli-arg:"#" cli-usage:"INSTANCE-NAME|ID"` + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + Subnet string `cli-arg:"#" cli-usage:"SUBNET-NAME|ID"` + + IPv4 string `cli-flag:"ipv4" cli-usage:"IPv4 address to assign to the Compute instance in the Subnet"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"instance zone"` +} + +func (c *instanceAttachToSubnetCmd) CmdAliases() []string { return nil } + +func (c *instanceAttachToSubnetCmd) CmdShort() string { + return "Attach a Compute instance to a VPC Subnet" +} + +func (c *instanceAttachToSubnetCmd) CmdLong() string { + return "This command attaches a Compute instance to a VPC Subnet." +} + +func (c *instanceAttachToSubnetCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *instanceAttachToSubnetCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + instances, err := client.ListInstances(ctx) + if err != nil { + return err + } + + instance, err := findInstance(instances, c.Instance, string(c.Zone)) + if err != nil { + return err + } + + vpcEntry, err := vpc.FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + subnetEntry, err := vpc.FindSubnet(ctx, client, vpcEntry.ID, c.Subnet) + if err != nil { + return err + } + + req := v3.AttachInstanceToSubnetRequest{ + Instance: &v3.InstanceRef{ID: instance.ID}, + } + + if c.IPv4 != "" { + ip := net.ParseIP(c.IPv4) + if ip == nil || ip.To4() == nil { + return fmt.Errorf("invalid IPv4 address: %q", c.IPv4) + } + req.Ipv4 = ip + } + + if err := utils.RunAsync( + ctx, + client, + fmt.Sprintf("Attaching instance %q to Subnet %q...", c.Instance, c.Subnet), + func(ctx context.Context, client *v3.Client) (*v3.Operation, error) { + return client.AttachInstanceToSubnet(ctx, vpcEntry.ID, subnetEntry.ID, req) + }, + ); err != nil { + return err + } + + if !globalstate.Quiet { + return (&instanceShowCmd{ + CliCommandSettings: c.CliCommandSettings, + Instance: instance.ID.String(), + Zone: c.Zone, + }).CmdRun(nil, nil) + } + + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(instanceCmd, &instanceAttachToSubnetCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/compute/instance/instance_detach_from_subnet.go b/cmd/compute/instance/instance_detach_from_subnet.go new file mode 100644 index 000000000..da1d65521 --- /dev/null +++ b/cmd/compute/instance/instance_detach_from_subnet.go @@ -0,0 +1,100 @@ +package instance + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/cmd/networking/vpc" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/utils" + v3 "github.com/exoscale/egoscale/v3" +) + +type instanceDetachFromSubnetCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"detach-from-subnet"` + + Instance string `cli-arg:"#" cli-usage:"INSTANCE-NAME|ID"` + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + Subnet string `cli-arg:"#" cli-usage:"SUBNET-NAME|ID"` + + Zone v3.ZoneName `cli-short:"z" cli-usage:"instance zone"` +} + +func (c *instanceDetachFromSubnetCmd) CmdAliases() []string { return nil } + +func (c *instanceDetachFromSubnetCmd) CmdShort() string { + return "Detach a Compute instance from a VPC Subnet" +} + +func (c *instanceDetachFromSubnetCmd) CmdLong() string { + return "This command detaches a Compute instance from a VPC Subnet." +} + +func (c *instanceDetachFromSubnetCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *instanceDetachFromSubnetCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + instances, err := client.ListInstances(ctx) + if err != nil { + return err + } + + instance, err := findInstance(instances, c.Instance, string(c.Zone)) + if err != nil { + return err + } + + vpcEntry, err := vpc.FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + subnetEntry, err := vpc.FindSubnet(ctx, client, vpcEntry.ID, c.Subnet) + if err != nil { + return err + } + + req := v3.DetachInstanceFromSubnetRequest{ + Instance: &v3.InstanceRef{ID: instance.ID}, + } + + if err := utils.RunAsync( + ctx, + client, + fmt.Sprintf("Detaching instance %q from Subnet %q...", c.Instance, c.Subnet), + func(ctx context.Context, client *v3.Client) (*v3.Operation, error) { + return client.DetachInstanceFromSubnet(ctx, vpcEntry.ID, subnetEntry.ID, req) + }, + ); err != nil { + return err + } + + if !globalstate.Quiet { + return (&instanceShowCmd{ + CliCommandSettings: c.CliCommandSettings, + Instance: instance.ID.String(), + Zone: c.Zone, + }).CmdRun(nil, nil) + } + + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(instanceCmd, &instanceDetachFromSubnetCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/compute/instance/instance_show.go b/cmd/compute/instance/instance_show.go index b5f0340e7..61bf99d51 100644 --- a/cmd/compute/instance/instance_show.go +++ b/cmd/compute/instance/instance_show.go @@ -18,29 +18,33 @@ import ( ) type InstanceShowOutput struct { - ID v3.UUID `json:"id"` - Name string `json:"name"` - CreationDate string `json:"creation_date"` - InstanceType string `json:"instance_type"` - Template string `json:"template"` - Zone v3.ZoneName `json:"zone"` - AntiAffinityGroups []string `json:"anti_affinity_groups" outputLabel:"Anti-Affinity Groups"` - DeployTarget string `json:"deploy_target"` - SecurityGroups []string `json:"security_groups"` - PrivateInstance string `json:"private-instance" outputLabel:"Private Instance"` - PrivateNetworks []string `json:"private_networks"` - ElasticIPs []string `json:"elastic_ips" outputLabel:"Elastic IPs"` - PublicIPAssignment v3.PublicIPAssignment `json:"public-ip" outputLabel:"Public IP"` - IPAddress string `json:"ip_address"` - IPv6Address string `json:"ipv6_address" outputLabel:"IPv6 Address"` - SSHKeys []string `json:"ssh_keys"` - DiskSize string `json:"disk_size"` - State v3.InstanceState `json:"state"` - Labels map[string]string `json:"labels"` - SecureBoot bool `json:"secureboot"` - Tpm bool `json:"tpm"` - ReverseDNS v3.DomainName `json:"reverse_dns" outputLabel:"Reverse DNS"` - AppConsistentSnapshot bool `json:"application_consistent_snapshot_enabled" outputLabel:"Application-Consistent Snapshot enabled"` + ID v3.UUID `json:"id"` + Name string `json:"name"` + CreationDate string `json:"creation_date"` + InstanceType string `json:"instance_type"` + Template string `json:"template"` + Zone v3.ZoneName `json:"zone"` + AntiAffinityGroups []string `json:"anti_affinity_groups" outputLabel:"Anti-Affinity Groups"` + DeployTarget string `json:"deploy_target"` + SecurityGroups []string `json:"security_groups"` + + PrivateInstance string `json:"private-instance" outputLabel:"Private Instance"` + ElasticIPs []string `json:"elastic_ips" outputLabel:"Elastic IPs"` + PublicIPAssignment v3.PublicIPAssignment `json:"public-ip" outputLabel:"Public IP"` + IPAddress string `json:"ip_address"` + IPv6Address string `json:"ipv6_address" outputLabel:"IPv6 Address"` + Vpc string `json:"vpc"` + VpcSubnets []string `json:"vpc_subnets"` + PrivateNetworks []string `json:"private_networks"` + + SSHKeys []string `json:"ssh_keys"` + DiskSize string `json:"disk_size"` + State v3.InstanceState `json:"state"` + Labels map[string]string `json:"labels"` + SecureBoot bool `json:"secureboot"` + Tpm bool `json:"tpm"` + ReverseDNS v3.DomainName `json:"reverse_dns" outputLabel:"Reverse DNS"` + AppConsistentSnapshot bool `json:"application_consistent_snapshot_enabled" outputLabel:"Application-Consistent Snapshot enabled"` } func (o *InstanceShowOutput) Type() string { return "Compute instance" } @@ -124,20 +128,31 @@ func (c *instanceShowCmd) CmdRun(cmd *cobra.Command, _ []string) error { PublicIPAssignment: instance.PublicIPAssignment, IPAddress: utils.DefaultIP(&instance.PublicIP, "-"), IPv6Address: utils.DefaultIP(ipV6, "-"), + //TODO: we need to fix the orchestrator to prevent NPEs here + Vpc: instance.Vpc.Name, + VpcSubnets: func() []string { + list := make([]string, 0) + for _, v := range instance.Vpc.Subnets { + list = append(list, fmt.Sprintf("%v %v", v.Name, v.Ipv4)) + } + return list + }(), + PrivateNetworks: make([]string, 0), Labels: func() (v map[string]string) { + if instance.Labels != nil { v = instance.Labels } return }(), - Name: instance.Name, - PrivateNetworks: make([]string, 0), - SSHKeys: make([]string, 0), - SecurityGroups: make([]string, 0), - SecureBoot: *instance.SecurebootEnabled, - Tpm: *instance.TpmEnabled, - State: instance.State, - Zone: c.Zone, + Name: instance.Name, + + SSHKeys: make([]string, 0), + SecurityGroups: make([]string, 0), + SecureBoot: *instance.SecurebootEnabled, + Tpm: *instance.TpmEnabled, + State: instance.State, + Zone: c.Zone, } if instance.ApplicationConsistentSnapshotEnabled != nil { diff --git a/cmd/networking/networking.go b/cmd/networking/networking.go new file mode 100644 index 000000000..b3fa7eedc --- /dev/null +++ b/cmd/networking/networking.go @@ -0,0 +1,18 @@ +package networking + +import ( + exocmd "github.com/exoscale/cli/cmd" + "github.com/spf13/cobra" +) + +// NetworkingCmd is the root command for networking services. +var NetworkingCmd = &cobra.Command{ + Use: "networking", + Short: "Networking services management", + Aliases: []string{"net"}, + SuggestFor: []string{"network", "vpc"}, +} + +func init() { + exocmd.RootCmd.AddCommand(NetworkingCmd) +} diff --git a/cmd/networking/vpc/vpc.go b/cmd/networking/vpc/vpc.go new file mode 100644 index 000000000..ade49c1d5 --- /dev/null +++ b/cmd/networking/vpc/vpc.go @@ -0,0 +1,60 @@ +package vpc + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/exoscale/cli/cmd/networking" + v3 "github.com/exoscale/egoscale/v3" +) + +// Cmd is the root command for VPC subcommands. +var Cmd = &cobra.Command{ + Use: "vpc", + Short: "Virtual Private Cloud management", +} + +func init() { + networking.NetworkingCmd.AddCommand(Cmd) +} + +// FindVPC resolves a VPC by name or ID in the client's current zone. +func FindVPC(ctx context.Context, client *v3.Client, nameOrID string) (v3.ListVpcEntry, error) { + resp, err := client.ListVpcs(ctx) + if err != nil { + return v3.ListVpcEntry{}, err + } + + vpc, err := resp.FindListVpcEntry(nameOrID) + if err != nil { + if errors.Is(err, v3.ErrNotFound) { + return v3.ListVpcEntry{}, fmt.Errorf( + "vpc %q not found\nHint: use -z to specify a different zone, or run 'exo networking vpc list' to see VPCs across all zones", + nameOrID) + } + return v3.ListVpcEntry{}, err + } + + return vpc, nil +} + +// FindSubnet resolves a Subnet by name or ID within the given VPC. +func FindSubnet(ctx context.Context, client *v3.Client, vpcID v3.UUID, nameOrID string) (v3.ListSubnetEntry, error) { + resp, err := client.ListSubnets(ctx, vpcID) + if err != nil { + return v3.ListSubnetEntry{}, err + } + + subnet, err := resp.FindListSubnetEntry(nameOrID) + if err != nil { + if errors.Is(err, v3.ErrNotFound) { + return v3.ListSubnetEntry{}, fmt.Errorf("subnet %q not found in VPC %s", nameOrID, vpcID) + } + return v3.ListSubnetEntry{}, err + } + + return subnet, nil +} diff --git a/cmd/networking/vpc/vpc_create.go b/cmd/networking/vpc/vpc_create.go new file mode 100644 index 000000000..2566b92b4 --- /dev/null +++ b/cmd/networking/vpc/vpc_create.go @@ -0,0 +1,87 @@ +package vpc + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + "github.com/exoscale/cli/utils" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcCreateCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"create"` + + Name string `cli-arg:"#" cli-usage:"NAME"` + + Description string `cli-usage:"VPC description"` + Labels map[string]string `cli-flag:"label" cli-usage:"VPC label (format: key=value)"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcCreateCmd) CmdAliases() []string { return exocmd.GCreateAlias } + +func (c *vpcCreateCmd) CmdShort() string { return "Create a VPC" } + +func (c *vpcCreateCmd) CmdLong() string { + return fmt.Sprintf(`This command creates a Virtual Private Cloud. + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&vpcShowOutput{}), ", ")) +} + +func (c *vpcCreateCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *vpcCreateCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + req := v3.CreateVpcRequest{ + Name: c.Name, + Description: c.Description, + } + + if len(c.Labels) > 0 { + req.Labels = c.Labels + } + + op, err := client.CreateVpc(ctx, req) + if err != nil { + return err + } + + utils.DecorateAsyncOperation(fmt.Sprintf("Creating VPC %q...", c.Name), func() { + op, err = client.Wait(ctx, op, v3.OperationStateSuccess) + }) + if err != nil { + return err + } + + if !globalstate.Quiet { + return (&vpcShowCmd{ + CliCommandSettings: c.CliCommandSettings, + VPC: op.Reference.ID.String(), + Zone: c.Zone, + }).CmdRun(nil, nil) + } + + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &vpcCreateCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_delete.go b/cmd/networking/vpc/vpc_delete.go new file mode 100644 index 000000000..17ceacbff --- /dev/null +++ b/cmd/networking/vpc/vpc_delete.go @@ -0,0 +1,65 @@ +package vpc + +import ( + "fmt" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/utils" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcDeleteCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"delete"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + + Force bool `cli-short:"f" cli-usage:"don't prompt for confirmation"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcDeleteCmd) CmdAliases() []string { return exocmd.GRemoveAlias } + +func (c *vpcDeleteCmd) CmdShort() string { return "Delete a VPC" } + +func (c *vpcDeleteCmd) CmdLong() string { return "" } + +func (c *vpcDeleteCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *vpcDeleteCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + entry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + if !c.Force { + if !utils.AskQuestion(ctx, fmt.Sprintf("Are you sure you want to delete VPC %s?", c.VPC)) { + return nil + } + } + + if err := client.DeleteVpc(ctx, entry.ID); err != nil { + return err + } + + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &vpcDeleteCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_list.go b/cmd/networking/vpc/vpc_list.go new file mode 100644 index 000000000..a8b893945 --- /dev/null +++ b/cmd/networking/vpc/vpc_list.go @@ -0,0 +1,104 @@ +package vpc + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + "github.com/exoscale/cli/utils" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcListItemOutput struct { + ID v3.UUID `json:"id" outputWidth:"36"` + Name string `json:"name" outputWidth:"30"` + Zone v3.ZoneName `json:"zone" outputWidth:"8"` + Default bool `json:"default"` + Description string `json:"description" outputWidth:"40"` +} + +type vpcListCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"list"` + + Zone v3.ZoneName `cli-short:"z" cli-usage:"zone to filter results to"` +} + +func (c *vpcListCmd) CmdAliases() []string { return exocmd.GListAlias } + +func (c *vpcListCmd) CmdShort() string { return "List VPCs" } + +func (c *vpcListCmd) CmdLong() string { + return fmt.Sprintf(`This command lists Virtual Private Clouds. + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&vpcListItemOutput{}), ", ")) +} + +func (c *vpcListCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *vpcListCmd) CmdRun(_ *cobra.Command, _ []string) error { + return runVPCList(c, os.Stdout, os.Stderr) +} + +func runVPCList(c *vpcListCmd, stdout, stderr io.Writer) error { + client := globalstate.EgoscaleV3Client + ctx := exocmd.GContext + + zones, err := utils.AllZonesV3(ctx, client, c.Zone) + if err != nil { + return err + } + + sink := utils.NewWarningSinkTo(stderr) + defer sink.Flush() + + streamer := output.NewStreamer(vpcListItemOutput{}, stdout) + defer func() { + if err := streamer.Close(); err != nil { + _, _ = fmt.Fprintf(stderr, "error: %s\n", err) + } + }() + + failed := utils.ForEveryZoneAsync(ctx, zones, globalstate.RequestTimeout, sink, true, + func(ctx context.Context, zone v3.Zone) error { + zc := client.WithEndpoint(zone.APIEndpoint) + resp, err := zc.ListVpcs(ctx) + if err != nil { + return fmt.Errorf("unable to list VPCs in zone %s: %w", zone.Name, err) + } + for _, v := range resp.Vpcs { + if err := streamer.Push(vpcListItemOutput{ + ID: v.ID, + Name: v.Name, + Zone: zone.Name, + Default: *v.Default, + Description: v.Description, + }); err != nil { + return err + } + } + return nil + }) + + if failed > 0 { + return fmt.Errorf("%d zone(s) failed", failed) + } + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &vpcListCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_route.go b/cmd/networking/vpc/vpc_route.go new file mode 100644 index 000000000..3f16f3662 --- /dev/null +++ b/cmd/networking/vpc/vpc_route.go @@ -0,0 +1,14 @@ +package vpc + +import ( + "github.com/spf13/cobra" +) + +var vpcRouteCmd = &cobra.Command{ + Use: "route", + Short: "Manage VPC routes", +} + +func init() { + Cmd.AddCommand(vpcRouteCmd) +} diff --git a/cmd/networking/vpc/vpc_route_create.go b/cmd/networking/vpc/vpc_route_create.go new file mode 100644 index 000000000..8877388f1 --- /dev/null +++ b/cmd/networking/vpc/vpc_route_create.go @@ -0,0 +1,88 @@ +package vpc + +import ( + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcRouteCreateCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"create"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + + Subnet string `cli-usage:"Subnet to create the route in (NAME|ID)"` + Destination string `cli-usage:"route destination CIDR (e.g. 10.9.0.0/24)"` + // TODO: Add a proper link to the doc here to explain what the target can be + Target string `cli-usage:"route target, as ip= (e.g. ip=10.0.0.5)"` + Description string `cli-usage:"route description"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcRouteCreateCmd) CmdAliases() []string { return exocmd.GCreateAlias } + +func (c *vpcRouteCreateCmd) CmdShort() string { return "Create a VPC route" } + +func (c *vpcRouteCreateCmd) CmdLong() string { + return `This command creates a route on a VPC Subnet. + +Routes are scoped to a Subnet, so --subnet is required.` +} + +func (c *vpcRouteCreateCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + if err := exocmd.CliCommandDefaultPreRun(c, cmd, args); err != nil { + return err + } + + return exocmd.CmdCheckRequiredFlags(cmd, []string{"subnet", "destination", "target"}) +} + +func (c *vpcRouteCreateCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + vpcEntry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + subnetEntry, err := FindSubnet(ctx, client, vpcEntry.ID, c.Subnet) + if err != nil { + return err + } + + if _, err := client.CreateRoute(ctx, vpcEntry.ID, subnetEntry.ID, v3.CreateRouteRequest{ + Destination: c.Destination, + Target: c.Target, + Description: c.Description, + }); err != nil { + return err + } + + // Routes have no show command of their own (they are unnamed and the API + // exposes no per-route GET), so list the Subnet's routes instead. + if !globalstate.Quiet { + return (&vpcRouteListCmd{ + CliCommandSettings: c.CliCommandSettings, + VPC: vpcEntry.ID.String(), + Subnet: subnetEntry.ID.String(), + Zone: c.Zone, + }).CmdRun(nil, nil) + } + + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(vpcRouteCmd, &vpcRouteCreateCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_route_delete.go b/cmd/networking/vpc/vpc_route_delete.go new file mode 100644 index 000000000..9f2d4b761 --- /dev/null +++ b/cmd/networking/vpc/vpc_route_delete.go @@ -0,0 +1,80 @@ +package vpc + +import ( + "fmt" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/utils" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcRouteDeleteCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"delete"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + Route string `cli-arg:"#" cli-usage:"ROUTE-ID"` + + Subnet string `cli-usage:"Subnet the route belongs to (NAME|ID)"` + Force bool `cli-short:"f" cli-usage:"don't prompt for confirmation"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcRouteDeleteCmd) CmdAliases() []string { return exocmd.GRemoveAlias } + +func (c *vpcRouteDeleteCmd) CmdShort() string { return "Delete a VPC route" } + +func (c *vpcRouteDeleteCmd) CmdLong() string { + return `This command deletes a route from a VPC Subnet. + +Routes associated to a VPC directly can't be deleted, so --subnet is required.` +} + +func (c *vpcRouteDeleteCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + if err := exocmd.CliCommandDefaultPreRun(c, cmd, args); err != nil { + return err + } + + return exocmd.CmdCheckRequiredFlags(cmd, []string{"subnet"}) +} + +func (c *vpcRouteDeleteCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + vpcEntry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + subnetEntry, err := FindSubnet(ctx, client, vpcEntry.ID, c.Subnet) + if err != nil { + return err + } + + if !c.Force { + if !utils.AskQuestion(ctx, fmt.Sprintf("Are you sure you want to delete route %s?", c.Route)) { + return nil + } + } + + if err := client.DeleteRoute(ctx, vpcEntry.ID, subnetEntry.ID, v3.UUID(c.Route)); err != nil { + return err + } + + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(vpcRouteCmd, &vpcRouteDeleteCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_route_list.go b/cmd/networking/vpc/vpc_route_list.go new file mode 100644 index 000000000..dbcdce91a --- /dev/null +++ b/cmd/networking/vpc/vpc_route_list.go @@ -0,0 +1,142 @@ +package vpc + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcRouteListItemOutput struct { + ID v3.UUID `json:"id"` + Kind string `json:"kind"` + Destination string `json:"destination"` + Target string `json:"target"` + Description string `json:"description"` +} + +type vpcRouteListOutput []vpcRouteListItemOutput + +func (o *vpcRouteListOutput) ToJSON() { output.JSON(o) } +func (o *vpcRouteListOutput) ToText() { output.Text(o) } +func (o *vpcRouteListOutput) ToTable() { output.Table(o) } + +type vpcRouteListCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"list"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + + Subnet string `cli-usage:"only list routes of this Subnet (NAME|ID)"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcRouteListCmd) CmdAliases() []string { return exocmd.GListAlias } + +func (c *vpcRouteListCmd) CmdShort() string { return "List VPC routes" } + +func (c *vpcRouteListCmd) CmdLong() string { + return fmt.Sprintf(`This command lists the routes of a Virtual Private Cloud. + +Without --subnet, the routes attached to the VPC are list. +With --subnet, all the routes impacting the Subnet are listed + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&vpcRouteListItemOutput{}), ", ")) +} + +func (c *vpcRouteListCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *vpcRouteListCmd) CmdRun(_ *cobra.Command, _ []string) error { + out, err := c.list() + if err != nil { + return err + } + + return c.OutputFunc(out, nil) +} + +// list resolves the VPC (and Subnet, when --subnet is set) and returns the +// routes to display, ordered by kind. +func (c *vpcRouteListCmd) list() (*vpcRouteListOutput, error) { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return nil, err + } + + vpcEntry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return nil, err + } + + var routes []v3.ListRouteEntry + + if c.Subnet != "" { + subnetEntry, err := FindSubnet(ctx, client, vpcEntry.ID, c.Subnet) + if err != nil { + return nil, err + } + + resp, err := client.ListRoutes(ctx, vpcEntry.ID, subnetEntry.ID) + if err != nil { + return nil, err + } + routes = resp.Routes + } else { + resp, err := client.ListVpcRoutes(ctx, vpcEntry.ID) + if err != nil { + return nil, err + } + routes = resp.Routes + } + + sortRoutesByKind(routes) + + out := make(vpcRouteListOutput, 0, len(routes)) + for _, r := range routes { + out = append(out, vpcRouteListItemOutput{ + ID: r.ID, + Kind: string(r.Kind), + Destination: r.Destination, + Target: r.Target, + Description: r.Description, + }) + } + + return &out, nil +} + +// sortRoutesByKind orders routes by kind, listing VPC routes before Subnet +// routes, then by destination so the output is stable. +func sortRoutesByKind(routes []v3.ListRouteEntry) { + rank := func(k v3.ListRouteEntryKind) int { + if k == v3.ListRouteEntryKindVpc { + return 0 + } + return 1 + } + + sort.SliceStable(routes, func(i, j int) bool { + if ri, rj := rank(routes[i].Kind), rank(routes[j].Kind); ri != rj { + return ri < rj + } + return routes[i].Destination < routes[j].Destination + }) +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(vpcRouteCmd, &vpcRouteListCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_route_list_test.go b/cmd/networking/vpc/vpc_route_list_test.go new file mode 100644 index 000000000..e86eb8a9c --- /dev/null +++ b/cmd/networking/vpc/vpc_route_list_test.go @@ -0,0 +1,122 @@ +package vpc + +import ( + "net/http" + "net/http/httptest" + "testing" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/testutils" + v3 "github.com/exoscale/egoscale/v3" +) + +const testSubnetID = "8f3a0000-0000-4000-8000-000000000002" + +func routeListMux(t *testing.T, hit *string) *http.ServeMux { + t.Helper() + + mux := http.NewServeMux() + mux.HandleFunc("/vpc", vpcListHandler(t)) + + // VPC-wide routes: GET /vpc/{id}/route + mux.HandleFunc("/vpc/"+testVPCID+"/route", func(w http.ResponseWriter, _ *http.Request) { + *hit = "vpc" + testutils.WriteJSON(t, w, http.StatusOK, v3.ListVpcRoutesResponse{ + Routes: []v3.ListRouteEntry{ + {ID: v3.UUID("00000000-0000-4000-8000-00000000000a"), Kind: v3.ListRouteEntryKindSubnet, Destination: "10.0.2.0/24"}, + {ID: v3.UUID("00000000-0000-4000-8000-00000000000b"), Kind: v3.ListRouteEntryKindVpc, Destination: "10.0.9.0/24"}, + {ID: v3.UUID("00000000-0000-4000-8000-00000000000c"), Kind: v3.ListRouteEntryKindSubnet, Destination: "10.0.1.0/24"}, + {ID: v3.UUID("00000000-0000-4000-8000-00000000000d"), Kind: v3.ListRouteEntryKindVpc, Destination: "10.0.8.0/24"}, + }, + }) + }) + + // Subnet listing, for --subnet resolution + 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(testSubnetID), Name: "web"}}, + }) + }) + + // Subnet-scoped routes: GET /vpc/{id}/subnet/{sid}/route + mux.HandleFunc("/vpc/"+testVPCID+"/subnet/"+testSubnetID+"/route", func(w http.ResponseWriter, _ *http.Request) { + *hit = "subnet" + testutils.WriteJSON(t, w, http.StatusOK, v3.ListRoutesResponse{ + Routes: []v3.ListRouteEntry{ + {ID: v3.UUID("00000000-0000-4000-8000-00000000000e"), Kind: v3.ListRouteEntryKindSubnet, Destination: "10.0.1.0/24"}, + }, + }) + }) + + return mux +} + +// TestVPCRouteListEndpointSelection asserts --subnet switches the CLI between +// the VPC-wide and the Subnet-scoped route endpoints. +func TestVPCRouteListEndpointSelection(t *testing.T) { + for _, tc := range []struct { + name string + subnet string + wantHit string + }{ + {name: "without --subnet", subnet: "", wantHit: "vpc"}, + {name: "with --subnet", subnet: "web", wantHit: "subnet"}, + } { + t.Run(tc.name, func(t *testing.T) { + var hit string + srv := httptest.NewServer(routeListMux(t, &hit)) + defer srv.Close() + testutils.SetupV3Client(t, srv.URL) + + c := &vpcRouteListCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + VPC: "prod", + Subnet: tc.subnet, + } + + if _, err := c.list(); err != nil { + t.Fatalf("route list: %v", err) + } + + if hit != tc.wantHit { + t.Errorf("endpoint: got %q, want %q", hit, tc.wantHit) + } + }) + } +} + +// TestVPCRouteListOrdersVpcKindFirst asserts VPC routes are emitted before +// Subnet routes, each group ordered by destination. +func TestVPCRouteListOrdersVpcKindFirst(t *testing.T) { + var hit string + srv := httptest.NewServer(routeListMux(t, &hit)) + defer srv.Close() + testutils.SetupV3Client(t, srv.URL) + + c := &vpcRouteListCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), VPC: "prod"} + + out, err := c.list() + if err != nil { + t.Fatalf("route list: %v", err) + } + + // Vpc-kind routes first, each group ordered by destination. + want := []vpcRouteListItemOutput{ + {Kind: "Vpc", Destination: "10.0.8.0/24"}, + {Kind: "Vpc", Destination: "10.0.9.0/24"}, + {Kind: "Subnet", Destination: "10.0.1.0/24"}, + {Kind: "Subnet", Destination: "10.0.2.0/24"}, + } + + if len(*out) != len(want) { + t.Fatalf("got %d routes, want %d: %+v", len(*out), len(want), *out) + } + + for i, w := range want { + got := (*out)[i] + if got.Kind != w.Kind || got.Destination != w.Destination { + t.Errorf("route %d: got %s/%s, want %s/%s", + i, got.Kind, got.Destination, w.Kind, w.Destination) + } + } +} diff --git a/cmd/networking/vpc/vpc_show.go b/cmd/networking/vpc/vpc_show.go new file mode 100644 index 000000000..75c3cdde3 --- /dev/null +++ b/cmd/networking/vpc/vpc_show.go @@ -0,0 +1,156 @@ +package vpc + +import ( + "bytes" + "fmt" + "os" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + "github.com/exoscale/cli/table" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcSubnetItemOutput struct { + Name string `json:"name"` + IPv4Block string `json:"ipv4_block"` +} + +type vpcShowOutput struct { + ID v3.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Default bool `json:"default"` + Zone v3.ZoneName `json:"zone"` + CreatedAt string `json:"created_at"` + Labels map[string]string `json:"labels"` + Subnets []vpcSubnetItemOutput `json:"subnets"` +} + +func (o *vpcShowOutput) ToJSON() { output.JSON(o) } +func (o *vpcShowOutput) ToText() { output.Text(o) } +func (o *vpcShowOutput) ToTable() { + t := table.NewTable(os.Stdout) + t.SetHeader([]string{"VPC"}) + defer t.Render() + + t.Append([]string{"ID", o.ID.String()}) + t.Append([]string{"Name", o.Name}) + t.Append([]string{"Description", o.Description}) + t.Append([]string{"Default", fmt.Sprintf("%v", o.Default)}) + t.Append([]string{"Zone", string(o.Zone)}) + t.Append([]string{"Created At", o.CreatedAt}) + t.Append([]string{"Labels", func() string { + if len(o.Labels) == 0 { + return "n/a" + } + + pairs := make([]string, 0, len(o.Labels)) + for k, v := range o.Labels { + pairs = append(pairs, fmt.Sprintf("%s:%s", k, v)) + } + return strings.Join(pairs, "\n") + }()}) + t.Append([]string{"Subnets", formatSubnets(o.Subnets)}) +} + +type vpcShowCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"show"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcShowCmd) CmdAliases() []string { return exocmd.GShowAlias } + +func (c *vpcShowCmd) CmdShort() string { return "Show a VPC details" } + +func (c *vpcShowCmd) CmdLong() string { + return fmt.Sprintf(`This command shows a Virtual Private Cloud details. + +Supported output template annotations for VPC: %s + +Supported output template annotations for VPC subnets: %s`, + strings.Join(output.TemplateAnnotations(&vpcShowOutput{}), ", "), + strings.Join(output.TemplateAnnotations(&vpcSubnetItemOutput{}), ", ")) +} + +func (c *vpcShowCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *vpcShowCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + entry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + vpc, err := client.GetVpc(ctx, entry.ID) + if err != nil { + return err + } + + out := vpcShowOutput{ + ID: vpc.ID, + Name: vpc.Name, + Description: vpc.Description, + Default: *vpc.Default, + Zone: c.Zone, + CreatedAt: vpc.CreatedAT.String(), + Labels: vpc.Labels, + Subnets: []vpcSubnetItemOutput{}, + } + + subnets, err := client.ListSubnets(ctx, vpc.ID) + if err != nil { + return fmt.Errorf("unable to list Subnets of VPC %s: %w", vpc.ID, err) + } + + for _, s := range subnets.Subnets { + out.Subnets = append(out.Subnets, vpcSubnetItemOutput{ + Name: s.Name, + IPv4Block: s.Ipv4Block, + }) + } + + return c.OutputFunc(&out, nil) +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &vpcShowCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} + +func formatSubnets(subnets []vpcSubnetItemOutput) string { + if len(subnets) == 0 { + return "-" + } + + buf := bytes.NewBuffer(nil) + at := table.NewEmbeddedTable(buf) + at.SetHeader([]string{" "}) + at.SetAlignment(tablewriter.ALIGN_LEFT) + + for _, s := range subnets { + at.Append([]string{s.Name, s.IPv4Block}) + } + at.Render() + + return buf.String() +} diff --git a/cmd/networking/vpc/vpc_subnet.go b/cmd/networking/vpc/vpc_subnet.go new file mode 100644 index 000000000..3ce88a9ec --- /dev/null +++ b/cmd/networking/vpc/vpc_subnet.go @@ -0,0 +1,14 @@ +package vpc + +import ( + "github.com/spf13/cobra" +) + +var vpcSubnetCmd = &cobra.Command{ + Use: "subnet", + Short: "Manage VPC Subnets", +} + +func init() { + Cmd.AddCommand(vpcSubnetCmd) +} diff --git a/cmd/networking/vpc/vpc_subnet_create.go b/cmd/networking/vpc/vpc_subnet_create.go new file mode 100644 index 000000000..04dcaa4a6 --- /dev/null +++ b/cmd/networking/vpc/vpc_subnet_create.go @@ -0,0 +1,106 @@ +package vpc + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + "github.com/exoscale/cli/utils" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcSubnetCreateCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"create"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + Name string `cli-arg:"#" cli-usage:"NAME"` + + IPv4Block string `cli-flag:"ipv4-block" cli-usage:"Subnet IPv4 CIDR (e.g. 10.0.0.0/24)"` + AddressFamily string `cli-flag:"address-family" cli-usage:"Subnet address family (currently only \"inet4\" is supported)"` + AddressSpace string `cli-flag:"address-space" cli-usage:"Subnet address space (currently only \"private\" is supported)"` + Description string `cli-usage:"Subnet description"` + Labels map[string]string `cli-flag:"label" cli-usage:"Subnet label (format: key=value)"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcSubnetCreateCmd) CmdAliases() []string { return exocmd.GCreateAlias } + +func (c *vpcSubnetCreateCmd) CmdShort() string { return "Create a VPC Subnet" } + +func (c *vpcSubnetCreateCmd) CmdLong() string { + return fmt.Sprintf(`This command creates a Subnet in a Virtual Private Cloud. + +--address-family and --address-space are required. + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&vpcSubnetShowOutput{}), ", ")) +} + +func (c *vpcSubnetCreateCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + if err := exocmd.CliCommandDefaultPreRun(c, cmd, args); err != nil { + return err + } + + return exocmd.CmdCheckRequiredFlags(cmd, []string{"address-family", "address-space"}) +} + +func (c *vpcSubnetCreateCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + vpcEntry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + req := v3.CreateSubnetRequest{ + Name: c.Name, + Description: c.Description, + Ipv4Block: c.IPv4Block, + AddressSpace: v3.CreateSubnetRequestAddressSpace(c.AddressSpace), + Addressfamily: v3.CreateSubnetRequestAddressfamily(c.AddressFamily), + } + + if len(c.Labels) > 0 { + req.Labels = c.Labels + } + + op, err := client.CreateSubnet(ctx, vpcEntry.ID, req) + if err != nil { + return err + } + + utils.DecorateAsyncOperation(fmt.Sprintf("Creating Subnet %q...", c.Name), func() { + op, err = client.Wait(ctx, op, v3.OperationStateSuccess) + }) + if err != nil { + return err + } + + if !globalstate.Quiet { + return (&vpcSubnetShowCmd{ + CliCommandSettings: c.CliCommandSettings, + VPC: vpcEntry.ID.String(), + Subnet: op.Reference.ID.String(), + Zone: c.Zone, + }).CmdRun(nil, nil) + } + + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(vpcSubnetCmd, &vpcSubnetCreateCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_subnet_delete.go b/cmd/networking/vpc/vpc_subnet_delete.go new file mode 100644 index 000000000..2ab13a0c4 --- /dev/null +++ b/cmd/networking/vpc/vpc_subnet_delete.go @@ -0,0 +1,71 @@ +package vpc + +import ( + "fmt" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/utils" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcSubnetDeleteCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"delete"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + Subnet string `cli-arg:"#" cli-usage:"SUBNET-NAME|ID"` + + Force bool `cli-short:"f" cli-usage:"don't prompt for confirmation"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcSubnetDeleteCmd) CmdAliases() []string { return exocmd.GRemoveAlias } + +func (c *vpcSubnetDeleteCmd) CmdShort() string { return "Delete a VPC Subnet" } + +func (c *vpcSubnetDeleteCmd) CmdLong() string { return "" } + +func (c *vpcSubnetDeleteCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *vpcSubnetDeleteCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + vpcEntry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + subnetEntry, err := FindSubnet(ctx, client, vpcEntry.ID, c.Subnet) + if err != nil { + return err + } + + if !c.Force { + if !utils.AskQuestion(ctx, fmt.Sprintf("Are you sure you want to delete Subnet %s?", c.Subnet)) { + return nil + } + } + + if err := client.DeleteSubnet(ctx, vpcEntry.ID, subnetEntry.ID); err != nil { + return err + } + + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(vpcSubnetCmd, &vpcSubnetDeleteCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_subnet_list.go b/cmd/networking/vpc/vpc_subnet_list.go new file mode 100644 index 000000000..77192c4c4 --- /dev/null +++ b/cmd/networking/vpc/vpc_subnet_list.go @@ -0,0 +1,100 @@ +package vpc + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcSubnetListItemOutput struct { + ID v3.UUID `json:"id"` + Name string `json:"name"` + IPv4Block string `json:"ipv4_block" outputLabel:"IPv4 Block"` + AddressFamily string `json:"address_family"` + Description string `json:"description"` +} + +type vpcSubnetListOutput []vpcSubnetListItemOutput + +func (o *vpcSubnetListOutput) ToJSON() { output.JSON(o) } +func (o *vpcSubnetListOutput) ToText() { output.Text(o) } +func (o *vpcSubnetListOutput) ToTable() { output.Table(o) } + +type vpcSubnetListCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"list"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcSubnetListCmd) CmdAliases() []string { return exocmd.GListAlias } + +func (c *vpcSubnetListCmd) CmdShort() string { return "List VPC Subnets" } + +func (c *vpcSubnetListCmd) CmdLong() string { + return fmt.Sprintf(`This command lists the Subnets of a Virtual Private Cloud. + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&vpcSubnetListItemOutput{}), ", ")) +} + +func (c *vpcSubnetListCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *vpcSubnetListCmd) CmdRun(_ *cobra.Command, _ []string) error { + out, err := c.list() + if err != nil { + return err + } + + return c.OutputFunc(out, nil) +} + +// list resolves the VPC and returns its Subnets to display. +func (c *vpcSubnetListCmd) list() (*vpcSubnetListOutput, error) { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return nil, err + } + + vpcEntry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return nil, err + } + + resp, err := client.ListSubnets(ctx, vpcEntry.ID) + if err != nil { + return nil, err + } + + out := make(vpcSubnetListOutput, 0, len(resp.Subnets)) + for _, s := range resp.Subnets { + out = append(out, vpcSubnetListItemOutput{ + ID: s.ID, + Name: s.Name, + IPv4Block: s.Ipv4Block, + AddressFamily: string(s.Addressfamily), + Description: s.Description, + }) + } + + return &out, nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(vpcSubnetCmd, &vpcSubnetListCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_subnet_show.go b/cmd/networking/vpc/vpc_subnet_show.go new file mode 100644 index 000000000..2ba882d23 --- /dev/null +++ b/cmd/networking/vpc/vpc_subnet_show.go @@ -0,0 +1,167 @@ +package vpc + +import ( + "bytes" + "fmt" + "os" + "strings" + + "github.com/olekukonko/tablewriter" + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + "github.com/exoscale/cli/table" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcSubnetInstanceOutput struct { + Name string `json:"name"` + IPv4 string `json:"ipv4"` +} + +type vpcSubnetShowOutput struct { + ID v3.UUID `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Zone v3.ZoneName `json:"zone"` + CreatedAt string `json:"created_at"` + AddressFamily string `json:"address_family"` + AddressSpace string `json:"address_space"` + IPv4Block string `json:"ipv4_block"` + Labels map[string]string `json:"labels"` + Instances []vpcSubnetInstanceOutput `json:"instances"` +} + +func (o *vpcSubnetShowOutput) ToJSON() { output.JSON(o) } +func (o *vpcSubnetShowOutput) ToText() { output.Text(o) } +func (o *vpcSubnetShowOutput) ToTable() { + t := table.NewTable(os.Stdout) + t.SetHeader([]string{"VPC Subnet"}) + defer t.Render() + + t.Append([]string{"ID", o.ID.String()}) + t.Append([]string{"Name", o.Name}) + t.Append([]string{"Description", o.Description}) + t.Append([]string{"Zone", string(o.Zone)}) + t.Append([]string{"Created At", o.CreatedAt}) + t.Append([]string{"Address Family", o.AddressFamily}) + t.Append([]string{"Address Space", o.AddressSpace}) + t.Append([]string{"IPv4 Block", o.IPv4Block}) + t.Append([]string{"Labels", func() string { + if len(o.Labels) == 0 { + return "n/a" + } + + pairs := make([]string, 0, len(o.Labels)) + for _, k := range o.Labels { + pairs = append(pairs, fmt.Sprintf("%s:%s", k, o.Labels[k])) + } + return strings.Join(pairs, "\n") + }()}) + t.Append([]string{"Instances", formatSubnetInstances(o.Instances)}) +} + +type vpcSubnetShowCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"show"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + Subnet string `cli-arg:"#" cli-usage:"SUBNET-NAME|ID"` + + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcSubnetShowCmd) CmdAliases() []string { return exocmd.GShowAlias } + +func (c *vpcSubnetShowCmd) CmdShort() string { return "Show a VPC Subnet details" } + +func (c *vpcSubnetShowCmd) CmdLong() string { + return fmt.Sprintf(`This command shows a VPC Subnet details. + +Supported output template annotations for Subnet: %s + +Supported output template annotations for Subnet instances: %s`, + strings.Join(output.TemplateAnnotations(&vpcSubnetShowOutput{}), ", "), + strings.Join(output.TemplateAnnotations(&vpcSubnetInstanceOutput{}), ", ")) +} + +func (c *vpcSubnetShowCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *vpcSubnetShowCmd) CmdRun(_ *cobra.Command, _ []string) error { + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + vpcEntry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + subnetEntry, err := FindSubnet(ctx, client, vpcEntry.ID, c.Subnet) + if err != nil { + return err + } + + subnet, err := client.GetSubnet(ctx, vpcEntry.ID, subnetEntry.ID) + if err != nil { + return err + } + + out := vpcSubnetShowOutput{ + ID: subnet.ID, + Name: subnet.Name, + Description: subnet.Description, + Zone: c.Zone, + CreatedAt: subnet.CreatedAT.String(), + AddressFamily: string(subnet.Addressfamily), + AddressSpace: string(subnet.AddressSpace), + IPv4Block: subnet.Ipv4Block, + Labels: subnet.Labels, + Instances: []vpcSubnetInstanceOutput{}, + } + + for _, i := range subnet.Instances { + ipv4 := "" + if i.Ipv4 != nil { + ipv4 = i.Ipv4.String() + } + out.Instances = append(out.Instances, vpcSubnetInstanceOutput{ + Name: i.Name, + IPv4: ipv4, + }) + } + + return c.OutputFunc(&out, nil) +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(vpcSubnetCmd, &vpcSubnetShowCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} + +func formatSubnetInstances(instances []vpcSubnetInstanceOutput) string { + if len(instances) == 0 { + return "-" + } + + buf := bytes.NewBuffer(nil) + at := table.NewEmbeddedTable(buf) + at.SetHeader([]string{" "}) + at.SetAlignment(tablewriter.ALIGN_LEFT) + + for _, i := range instances { + at.Append([]string{i.Name, i.IPv4}) + } + at.Render() + + return buf.String() +} diff --git a/cmd/networking/vpc/vpc_subnet_update.go b/cmd/networking/vpc/vpc_subnet_update.go new file mode 100644 index 000000000..f8865d971 --- /dev/null +++ b/cmd/networking/vpc/vpc_subnet_update.go @@ -0,0 +1,103 @@ +package vpc + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcSubnetUpdateCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"update"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + Subnet string `cli-arg:"#" cli-usage:"SUBNET-NAME|ID"` + + Name string `cli-usage:"Subnet name"` + Description string `cli-usage:"Subnet description"` + Labels map[string]string `cli-flag:"label" cli-usage:"Subnet label (format: key=value), clearing the labels is possible by passing [=]"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcSubnetUpdateCmd) CmdAliases() []string { return nil } + +func (c *vpcSubnetUpdateCmd) CmdShort() string { return "Update a VPC Subnet" } + +func (c *vpcSubnetUpdateCmd) CmdLong() string { + return fmt.Sprintf(`This command updates a VPC Subnet. + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&vpcSubnetShowOutput{}), ", ")) +} + +func (c *vpcSubnetUpdateCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *vpcSubnetUpdateCmd) CmdRun(cmd *cobra.Command, _ []string) error { + var updated bool + + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + vpcEntry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + subnetEntry, err := FindSubnet(ctx, client, vpcEntry.ID, c.Subnet) + if err != nil { + return err + } + + req := v3.UpdateSubnetRequest{} + + if cmd.Flags().Changed(exocmd.MustCLICommandFlagName(c, &c.Name)) { + req.Name = &c.Name + updated = true + } + + if cmd.Flags().Changed(exocmd.MustCLICommandFlagName(c, &c.Description)) { + req.Description = &c.Description + updated = true + } + + if cmd.Flags().Changed(exocmd.MustCLICommandFlagName(c, &c.Labels)) { + req.Labels = exocmd.ConvertIfSpecialEmptyMap(c.Labels) + updated = true + } + + if updated { + if _, err := client.UpdateSubnet(ctx, vpcEntry.ID, subnetEntry.ID, req); err != nil { + return err + } + } + + if !globalstate.Quiet { + return (&vpcSubnetShowCmd{ + CliCommandSettings: c.CliCommandSettings, + VPC: vpcEntry.ID.String(), + Subnet: subnetEntry.ID.String(), + Zone: c.Zone, + }).CmdRun(nil, nil) + } + + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(vpcSubnetCmd, &vpcSubnetUpdateCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_update.go b/cmd/networking/vpc/vpc_update.go new file mode 100644 index 000000000..128b00b8e --- /dev/null +++ b/cmd/networking/vpc/vpc_update.go @@ -0,0 +1,96 @@ +package vpc + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/globalstate" + "github.com/exoscale/cli/pkg/output" + v3 "github.com/exoscale/egoscale/v3" +) + +type vpcUpdateCmd struct { + exocmd.CliCommandSettings `cli-cmd:"-"` + + _ bool `cli-cmd:"update"` + + VPC string `cli-arg:"#" cli-usage:"VPC-NAME|ID"` + + Name string `cli-usage:"VPC name"` + Description string `cli-usage:"VPC description"` + Labels map[string]string `cli-flag:"label" cli-usage:"VPC label (format: key=value), clearing the labels is possible by passing [=]"` + Zone v3.ZoneName `cli-short:"z" cli-usage:"VPC zone"` +} + +func (c *vpcUpdateCmd) CmdAliases() []string { return nil } + +func (c *vpcUpdateCmd) CmdShort() string { return "Update a VPC" } + +func (c *vpcUpdateCmd) CmdLong() string { + return fmt.Sprintf(`This command updates a Virtual Private Cloud. + +Supported output template annotations: %s`, + strings.Join(output.TemplateAnnotations(&vpcShowOutput{}), ", ")) +} + +func (c *vpcUpdateCmd) CmdPreRun(cmd *cobra.Command, args []string) error { + exocmd.CmdSetZoneFlagFromDefault(cmd) + return exocmd.CliCommandDefaultPreRun(c, cmd, args) +} + +func (c *vpcUpdateCmd) CmdRun(cmd *cobra.Command, _ []string) error { + var updated bool + + ctx := exocmd.GContext + client, err := exocmd.SwitchClientZoneV3(ctx, globalstate.EgoscaleV3Client, c.Zone) + if err != nil { + return err + } + + entry, err := FindVPC(ctx, client, c.VPC) + if err != nil { + return err + } + + req := v3.UpdateVpcRequest{} + + if cmd.Flags().Changed(exocmd.MustCLICommandFlagName(c, &c.Name)) { + req.Name = &c.Name + updated = true + } + + if cmd.Flags().Changed(exocmd.MustCLICommandFlagName(c, &c.Description)) { + req.Description = &c.Description + updated = true + } + + if cmd.Flags().Changed(exocmd.MustCLICommandFlagName(c, &c.Labels)) { + req.Labels = exocmd.ConvertIfSpecialEmptyMap(c.Labels) + updated = true + } + + if updated { + if _, err := client.UpdateVpc(ctx, entry.ID, req); err != nil { + return err + } + } + + if !globalstate.Quiet { + return (&vpcShowCmd{ + CliCommandSettings: c.CliCommandSettings, + VPC: entry.ID.String(), + Zone: c.Zone, + }).CmdRun(nil, nil) + } + + return nil +} + +func init() { + cobra.CheckErr(exocmd.RegisterCLICommand(Cmd, &vpcUpdateCmd{ + CliCommandSettings: exocmd.DefaultCLICmdSettings(), + })) +} diff --git a/cmd/networking/vpc/vpc_update_test.go b/cmd/networking/vpc/vpc_update_test.go new file mode 100644 index 000000000..258b88689 --- /dev/null +++ b/cmd/networking/vpc/vpc_update_test.go @@ -0,0 +1,95 @@ +package vpc + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/spf13/cobra" + + exocmd "github.com/exoscale/cli/cmd" + "github.com/exoscale/cli/pkg/testutils" + v3 "github.com/exoscale/egoscale/v3" +) + +const testVPCID = "8f3a0000-0000-4000-8000-000000000001" + +// newUpdateFlagSet builds a cobra command carrying the update command's +// generated flags, so cmd.Flags().Changed() behaves as it does in production. +func newUpdateFlagSet(t *testing.T, c *vpcUpdateCmd, changed ...string) *cobra.Command { + t.Helper() + + parent := &cobra.Command{Use: "test"} + if err := exocmd.RegisterCLICommand(parent, c); err != nil { + t.Fatalf("register command: %v", err) + } + + cmd := parent.Commands()[0] + for _, name := range changed { + if err := cmd.Flags().Set(name, cmd.Flags().Lookup(name).Value.String()); err != nil { + t.Fatalf("set flag %s: %v", name, err) + } + } + + return cmd +} + +func vpcListHandler(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + testutils.WriteJSON(t, w, http.StatusOK, v3.ListVpcsResponse{ + Vpcs: []v3.ListVpcEntry{{ID: v3.UUID(testVPCID), Name: "prod"}}, + }) + } +} + +// TestVPCUpdateOmitsUnsetFields asserts that flags the user did not pass are +// left out of the PUT body entirely, rather than being sent as empty strings +// (which would wipe the description server-side). +func TestVPCUpdateOmitsUnsetFields(t *testing.T) { + var rawBody []byte + var called bool + + mux := http.NewServeMux() + mux.HandleFunc("/vpc", vpcListHandler(t)) + mux.HandleFunc("/vpc/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + called = true + rawBody, _ = io.ReadAll(r.Body) + _ = r.Body.Close() + testutils.WriteJSON(t, w, http.StatusOK, v3.Vpc{ID: v3.UUID(testVPCID), Name: "renamed"}) + }) + + srv := httptest.NewServer(mux) + defer srv.Close() + testutils.SetupV3Client(t, srv.URL) + + c := &vpcUpdateCmd{CliCommandSettings: exocmd.DefaultCLICmdSettings(), VPC: "prod"} + cmd := newUpdateFlagSet(t, c, "name") + c.VPC = "prod" + c.Name = "renamed" + + if err := c.CmdRun(cmd, nil); err != nil { + t.Fatalf("vpc update: %v", err) + } + + if !called { + t.Fatal("expected an update request to be issued") + } + + var body map[string]any + if err := json.Unmarshal(rawBody, &body); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + + if got := body["name"]; got != "renamed" { + t.Errorf("name: got %v, want %q", got, "renamed") + } + if _, ok := body["description"]; ok { + t.Errorf("description must be omitted when --description is not set, body was %s", rawBody) + } +} diff --git a/cmd/subcommands/init.go b/cmd/subcommands/init.go index d54545275..b06208975 100644 --- a/cmd/subcommands/init.go +++ b/cmd/subcommands/init.go @@ -29,5 +29,7 @@ import ( _ "github.com/exoscale/cli/cmd/kms" _ "github.com/exoscale/cli/cmd/kms/crypto" _ "github.com/exoscale/cli/cmd/kms/key" + _ "github.com/exoscale/cli/cmd/networking" + _ "github.com/exoscale/cli/cmd/networking/vpc" _ "github.com/exoscale/cli/cmd/storage" )