diff --git a/README.md b/README.md index 9186b71..22c1996 100644 --- a/README.md +++ b/README.md @@ -63,9 +63,19 @@ docker-machine create -d linode --linode-token= linode | `linode-stackscript` | `LINODE_STACKSCRIPT` | None | Specifies the Linode StackScript to use to create the instance, either by numeric ID, or using the form *username*/*label*. | `linode-stackscript-data` | `LINODE_STACKSCRIPT_DATA` | None | A JSON string specifying data that is passed (via UDF) to the selected StackScript. | `linode-create-private-ip` | `LINODE_CREATE_PRIVATE_IP` | None | A flag specifying to create private IP for the Linode instance. +| `linode-use-interfaces` | `LINODE_USE_INTERFACES` | None | Opt-in to Linode's interface/VPC networking stack (requires `linode-vpc-subnet-id`; conflicts with `linode-create-private-ip`). +| `linode-vpc-subnet-id` | `LINODE_VPC_SUBNET_ID` | None | VPC subnet ID to attach when using interface networking. +| `linode-vpc-private-ip` | `LINODE_VPC_PRIVATE_IP` | None | Optional IPv4 address to request on the VPC interface (requires `linode-use-interfaces`). +| `linode-vpc-interface-firewall-id` | `LINODE_VPC_INTERFACE_FIREWALL_ID` | None | Firewall ID to attach to the VPC interface when using the interface/VPC networking stack. +| `linode-public-interface-firewall-id` | `LINODE_PUBLIC_INTERFACE_FIREWALL_ID` | None | Firewall ID to attach to the public interface when using the interface/VPC networking stack. | `linode-tags` | `LINODE_TAGS` | None | A comma separated list of tags to apply to the Linode resource | `linode-ua-prefix` | `LINODE_UA_PREFIX` | None | Prefix the User-Agent in Linode API calls with some 'product/version' +## Networking Modes + +- **Legacy (default):** uses public networking and optionally `--linode-create-private-ip` to attach a private address. +- **Interface/VPC (opt-in):** enable with `--linode-use-interfaces` plus `--linode-vpc-subnet-id`. If your account does not define default interface firewalls, set `--linode-public-interface-firewall-id` and/or `--linode-vpc-interface-firewall-id` to avoid API errors. This mode is incompatible with `--linode-create-private-ip`. + ## Notes * When using the `linode/containerlinux` `linode-image`, the `linode-ssh-user` will default to `core` @@ -126,6 +136,24 @@ Are you sure? (y/n): y Successfully removed linode ``` +### Interface/VPC Networking Example + +Use Linode's newer interface generation to attach a VPC subnet while keeping the public interface for provisioning traffic. + +```bash +docker-machine create \ + -d linode \ + --linode-token=$LINODE_TOKEN \ + --linode-use-interfaces \ + --linode-vpc-subnet-id=67890 \ + --linode-vpc-private-ip=10.0.0.25 \ + linode-vpc +``` + +If your account does not have default interface firewalls configured, include `--linode-public-interface-firewall-id=` and/or `--linode-vpc-interface-firewall-id=` to satisfy the Linode API requirement. + +The `--linode-use-interfaces` flag is incompatible with `--linode-create-private-ip` to keep networking behavior deterministic. Omit `--linode-vpc-private-ip` to request an automatically assigned address from the subnet. Without `--linode-use-interfaces`, legacy networking remains the default. + ### Provisioning Docker Swarm The following script serves as an example for creating a [Docker Swarm](https://docs.docker.com/engine/swarm/) with master and worker nodes using the Linode Docker machine driver and private networking. diff --git a/pkg/drivers/linode/linode.go b/pkg/drivers/linode/linode.go index 096c6df..88493c3 100644 --- a/pkg/drivers/linode/linode.go +++ b/pkg/drivers/linode/linode.go @@ -28,12 +28,17 @@ type Driver struct { *drivers.BaseDriver client *linodego.Client - APIToken string - UserAgentPrefix string - IPAddress string - PrivateIPAddress string - CreatePrivateIP bool - DockerPort int + APIToken string + UserAgentPrefix string + IPAddress string + PrivateIPAddress string + CreatePrivateIP bool + UseInterfaces bool + VPCSubnetID int + VPCPrivateIP string + VPCInterfaceFirewallID int + PublicInterfaceFirewallID int + DockerPort int InstanceID int InstanceLabel string @@ -122,6 +127,19 @@ func createRandomRootPassword() (string, error) { return rootPass, nil } +// FirewallID is a **int in linodego so callers can distinguish between +// omitting the field entirely and explicitly sending a value. +func firewallIDPtr(id int) **int { + if id == 0 { + return nil + } + + value := id + valuePtr := &value + + return &valuePtr +} + // DriverName returns the name of the driver func (d *Driver) DriverName() string { return "linode" @@ -226,6 +244,31 @@ func (d *Driver) GetCreateFlags() []mcnflag.Flag { Name: "linode-create-private-ip", Usage: "Create private IP for the instance", }, + mcnflag.BoolFlag{ + EnvVar: "LINODE_USE_INTERFACES", + Name: "linode-use-interfaces", + Usage: "Enable Linode interface/VPC networking (opt-in, keeps legacy defaults otherwise)", + }, + mcnflag.IntFlag{ + EnvVar: "LINODE_VPC_SUBNET_ID", + Name: "linode-vpc-subnet-id", + Usage: "VPC subnet ID to attach when using interface/VPC networking", + }, + mcnflag.StringFlag{ + EnvVar: "LINODE_VPC_PRIVATE_IP", + Name: "linode-vpc-private-ip", + Usage: "Optional IPv4 address to request on the VPC interface (requires --linode-use-interfaces)", + }, + mcnflag.IntFlag{ + EnvVar: "LINODE_PUBLIC_INTERFACE_FIREWALL_ID", + Name: "linode-public-interface-firewall-id", + Usage: "Firewall ID to attach to the public interface when using interface/VPC networking", + }, + mcnflag.IntFlag{ + EnvVar: "LINODE_VPC_INTERFACE_FIREWALL_ID", + Name: "linode-vpc-interface-firewall-id", + Usage: "Firewall ID to attach to the VPC interface when using interface/VPC networking", + }, mcnflag.StringFlag{ EnvVar: "LINODE_UA_PREFIX", Name: "linode-ua-prefix", @@ -276,6 +319,11 @@ func (d *Driver) SetConfigFromFlags(flags drivers.DriverOptions) error { d.SwapSize = flags.Int("linode-swap-size") d.DockerPort = flags.Int("linode-docker-port") d.CreatePrivateIP = flags.Bool("linode-create-private-ip") + d.UseInterfaces = flags.Bool("linode-use-interfaces") + d.VPCSubnetID = flags.Int("linode-vpc-subnet-id") + d.VPCPrivateIP = strings.TrimSpace(flags.String("linode-vpc-private-ip")) + d.VPCInterfaceFirewallID = flags.Int("linode-vpc-interface-firewall-id") + d.PublicInterfaceFirewallID = flags.Int("linode-public-interface-firewall-id") d.UserAgentPrefix = flags.String("linode-ua-prefix") d.Tags = flags.String("linode-tags") @@ -320,6 +368,34 @@ func (d *Driver) SetConfigFromFlags(flags drivers.DriverOptions) error { d.InstanceLabel = newLabel + if d.PublicInterfaceFirewallID < 0 { + return fmt.Errorf("invalid value for --linode-public-interface-firewall-id: must be zero or positive") + } + if d.VPCInterfaceFirewallID < 0 { + return fmt.Errorf("invalid value for --linode-vpc-interface-firewall-id: must be zero or positive") + } + + if d.UseInterfaces && d.CreatePrivateIP { + return fmt.Errorf("cannot combine --linode-use-interfaces with --linode-create-private-ip; choose one networking mode") + } + + if d.UseInterfaces { + if d.VPCSubnetID == 0 { + return fmt.Errorf("linode interface networking requires --linode-vpc-subnet-id") + } + + if d.VPCPrivateIP != "" { + parsed := net.ParseIP(d.VPCPrivateIP) + if parsed == nil || parsed.To4() == nil { + return fmt.Errorf("linode VPC private IP must be a valid IPv4 address") + } + } + } else { + if d.VPCSubnetID != 0 || d.VPCPrivateIP != "" || d.PublicInterfaceFirewallID != 0 || d.VPCInterfaceFirewallID != 0 { + return fmt.Errorf("VPC/interface options require --linode-use-interfaces to be set") + } + } + return nil } @@ -391,6 +467,10 @@ func (d *Driver) Create() error { log.Infof("Using SSH port %d", d.SSHPort) } + if d.UseInterfaces { + log.Infof("Using interface/VPC networking (subnet %d)", d.VPCSubnetID) + } + publicKey, err := d.createSSHKey() if err != nil { return err @@ -426,6 +506,38 @@ func (d *Driver) Create() error { log.Infof("Using StackScript %d: %s/%s", d.StackScriptID, d.StackScriptUser, d.StackScriptLabel) } + if d.UseInterfaces { + defaultRoute := true + vpcInterface := linodego.LinodeInterfaceCreateOptions{ + VPC: &linodego.VPCInterfaceCreateOptions{ + SubnetID: d.VPCSubnetID, + }, + } + vpcInterface.FirewallID = firewallIDPtr(d.VPCInterfaceFirewallID) + + if d.VPCPrivateIP != "" { + address := d.VPCPrivateIP + primary := true + vpcInterface.VPC.IPv4 = &linodego.VPCInterfaceIPv4CreateOptions{ + Addresses: &[]linodego.VPCInterfaceIPv4AddressCreateOptions{ + { + Address: &address, + Primary: &primary, + }, + }, + } + } + + createOpts.InterfaceGeneration = linodego.GenerationLinode + createOpts.PrivateIP = false + publicInterface := linodego.LinodeInterfaceCreateOptions{ + DefaultRoute: &linodego.InterfaceDefaultRoute{IPv4: &defaultRoute}, + Public: &linodego.PublicInterfaceCreateOptions{}, + FirewallID: firewallIDPtr(d.PublicInterfaceFirewallID), + } + createOpts.LinodeInterfaces = []linodego.LinodeInterfaceCreateOptions{publicInterface, vpcInterface} + } + linode, err := client.CreateInstance(context.TODO(), createOpts) if err != nil { return err @@ -437,33 +549,59 @@ func (d *Driver) Create() error { // Don't persist alias region names d.Region = linode.Region - for _, address := range linode.IPv4 { - if private := privateIP(*address); !private { - d.IPAddress = address.String() - } else if d.CreatePrivateIP { - d.PrivateIPAddress = address.String() + if d.UseInterfaces { + ips, err := client.GetInstanceIPAddresses(context.TODO(), linode.ID) + if err != nil { + return err } - } - if d.IPAddress == "" { - return errors.New("Linode IP Address is not found") - } + if ips == nil || ips.IPv4 == nil { + return errors.New("Linode IP information is not available") + } + + d.IPAddress = firstInstanceIP(ips.IPv4.Public) + if d.IPAddress == "" { + d.IPAddress = firstInstanceIP(ips.IPv4.Shared) + } + if d.IPAddress == "" { + d.IPAddress = firstInstanceIP(ips.IPv4.Reserved) + } + + d.PrivateIPAddress = firstVPCIPv4(ips.IPv4.VPC) - if d.CreatePrivateIP && d.PrivateIPAddress == "" { - return errors.New("Linode Private IP Address is not found") + if d.IPAddress == "" { + return errors.New("Linode public IP address was not found") + } + + if d.PrivateIPAddress == "" { + return fmt.Errorf("Linode VPC private IP address not found for subnet %d", d.VPCSubnetID) + } + } else { + for _, address := range linode.IPv4 { + if private := privateIP(*address); !private { + d.IPAddress = address.String() + } else if d.CreatePrivateIP { + d.PrivateIPAddress = address.String() + } + } + + if d.IPAddress == "" { + return errors.New("Linode IP Address is not found") + } + + if d.CreatePrivateIP && d.PrivateIPAddress == "" { + return errors.New("Linode Private IP Address is not found") + } } - log.Debugf("Created Linode Instance %s (%d), IP address %q, Private IP address %q", + log.Debugf("Created Linode Instance %s (%d), IP address %q, Private IP address %q (interfaces enabled: %t)", d.InstanceLabel, d.InstanceID, d.IPAddress, d.PrivateIPAddress, + d.UseInterfaces, ) - if err != nil { - return err - } - if d.CreatePrivateIP { log.Debugf("Enabling Network Helper for Private IP configuration...") @@ -614,6 +752,32 @@ func ipInCIDR(ip net.IP, CIDR string) bool { return ipNet.Contains(ip) } +func firstInstanceIP(addresses []*linodego.InstanceIP) string { + for _, address := range addresses { + if address == nil { + continue + } + if address.Address != "" { + return address.Address + } + } + + return "" +} + +func firstVPCIPv4(addresses []*linodego.VPCIP) string { + for _, address := range addresses { + if address == nil { + continue + } + if address.Address != nil && *address.Address != "" { + return *address.Address + } + } + + return "" +} + const noLabelDuplicates = "._-" func normalizeInstanceLabel(label string) (string, error) { diff --git a/pkg/drivers/linode/linode_test.go b/pkg/drivers/linode/linode_test.go index efc16e6..473e320 100644 --- a/pkg/drivers/linode/linode_test.go +++ b/pkg/drivers/linode/linode_test.go @@ -7,6 +7,7 @@ import ( "github.com/docker/machine/libmachine/drivers" "github.com/google/go-cmp/cmp" + "github.com/linode/linodego" "github.com/stretchr/testify/assert" ) @@ -27,6 +28,132 @@ func TestSetConfigFromFlags(t *testing.T) { assert.Empty(t, checkFlags.InvalidFlags) } +func TestSetConfigFromFlagsInterfaceRequiresVPC(t *testing.T) { + driver := NewDriver("", "") + + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-use-interfaces": true, + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + assert.Error(t, err) + assert.Contains(t, err.Error(), "requires --linode-vpc-subnet-id") +} + +func TestSetConfigFromFlagsInterfaceConflictsWithLegacyPrivateIP(t *testing.T) { + driver := NewDriver("", "") + + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-use-interfaces": true, + "linode-vpc-subnet-id": 456, + "linode-create-private-ip": true, + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + assert.Error(t, err) + assert.Contains(t, err.Error(), "linode-use-interfaces") +} + +func TestSetConfigFromFlagsInterfaceHappyPath(t *testing.T) { + driver := NewDriver("", "") + + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-use-interfaces": true, + "linode-vpc-subnet-id": 456, + "linode-vpc-private-ip": "10.0.0.10", + "linode-vpc-interface-firewall-id": 321, + "linode-public-interface-firewall-id": 789, + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + assert.NoError(t, err) + assert.True(t, driver.UseInterfaces) + assert.Equal(t, 456, driver.VPCSubnetID) + assert.Equal(t, "10.0.0.10", driver.VPCPrivateIP) + assert.Equal(t, 321, driver.VPCInterfaceFirewallID) + assert.Equal(t, 789, driver.PublicInterfaceFirewallID) +} + +func TestSetConfigFromFlagsInterfaceFirewallRequiresInterfaces(t *testing.T) { + driver := NewDriver("", "") + + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-public-interface-firewall-id": 111, + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + assert.Error(t, err) + assert.Contains(t, err.Error(), "linode-use-interfaces") +} + +func TestSetConfigFromFlagsInterfaceFirewallMustBeNonNegative(t *testing.T) { + driver := NewDriver("", "") + + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-use-interfaces": true, + "linode-vpc-subnet-id": 456, + "linode-public-interface-firewall-id": -2, + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--linode-public-interface-firewall-id") +} + +func TestSetConfigFromFlagsVPCInterfaceFirewallRequiresInterfaces(t *testing.T) { + driver := NewDriver("", "") + + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-vpc-interface-firewall-id": 222, + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + assert.Error(t, err) + assert.Contains(t, err.Error(), "linode-use-interfaces") +} + +func TestSetConfigFromFlagsVPCInterfaceFirewallMustBeNonNegative(t *testing.T) { + driver := NewDriver("", "") + + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-use-interfaces": true, + "linode-vpc-subnet-id": 456, + "linode-vpc-interface-firewall-id": -2, + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + assert.Error(t, err) + assert.Contains(t, err.Error(), "--linode-vpc-interface-firewall-id") +} + func TestPrivateIP(t *testing.T) { ip := net.IP{} for _, addr := range [][]byte{ @@ -69,3 +196,15 @@ func TestNormalizeInstanceLabel(t *testing.T) { t.Fatal(cmp.Diff(result, expectedResult)) } } + +func TestFirstVPCIPv4SkipsRanges(t *testing.T) { + ip := "10.0.0.5" + ipRange := "10.0.0.0/24" + + got := firstVPCIPv4([]*linodego.VPCIP{ + {AddressRange: &ipRange}, + {Address: &ip}, + }) + + assert.Equal(t, ip, got) +}