From d1bec1ad93f21fa8d1bce90930fe866cdfc765cd Mon Sep 17 00:00:00 2001
From: JonasS <jonass@dev.jsje.de>
Date: Fri, 21 Oct 2022 16:42:28 +0200
Subject: [PATCH] Version 3.9.0 - added support for user-specified primary IPs
 (#88, thanks @ItsReddi)

---
 README.md |  23 +++++++++--
 driver.go | 114 +++++++++++++++++++++++++++++++++++++++++++++++++++---
 2 files changed, 129 insertions(+), 8 deletions(-)

diff --git a/README.md b/README.md
index 2f7f9f5..513dfa2 100644
--- a/README.md
+++ b/README.md
@@ -15,8 +15,8 @@ You can find sources and pre-compiled binaries [here](https://github.com/JonasPr
 
 ```bash
 # Download the binary (this example downloads the binary for linux amd64)
-$ wget https://github.com/JonasProgrammer/docker-machine-driver-hetzner/releases/download/3.8.1/docker-machine-driver-hetzner_3.8.1_linux_amd64.tar.gz
-$ tar -xvf docker-machine-driver-hetzner_3.8.1_linux_amd64.tar.gz
+$ wget https://github.com/JonasProgrammer/docker-machine-driver-hetzner/releases/download/3.9.0/docker-machine-driver-hetzner_3.9.0_linux_amd64.tar.gz
+$ tar -xvf docker-machine-driver-hetzner_3.9.0_linux_amd64.tar.gz
 
 # Make it executable and copy the binary in a directory accessible with your $PATH
 $ chmod +x docker-machine-driver-hetzner
@@ -110,6 +110,7 @@ $ docker-machine create \
 - `--hetzner-auto-spread`: Add to a `docker-machine` provided `spread` group (mutually exclusive with `--hetzner-placement-group`)
 - `--hetzner-ssh-user`: Change the default SSH-User
 - `--hetzner-ssh-port`: Change the default SSH-Port
+- `--hetzner-primary-ipv4/6`: Sets an existing primary IP (v4 or v6 respectively) for the server, as documented in [Networking](#networking)
 
 #### Existing SSH keys
 
@@ -152,8 +153,24 @@ was used during creation.
 | `--hetzner-auto-spread`         | `HETZNER_AUTO_SPREAD`         | false                      |
 | `--hetzner-ssh-user`            | `HETZNER_SSH_USER`            | root                       |
 | `--hetzner-ssh-port`            | `HETZNER_SSH_PORT`            | 22                         |
+| `--hetzner-primary-ipv4`        | `HETZNER_PRIMARY_IPV4`        |                            |
+| `--hetzner-primary-ipv6`        | `HETZNER_PRIMARY_IPV6`        |                            |
 
-**Networking hint:** When disabling all public IPs, `--hetzner-use-private-network` must be given.
+#### Networking
+
+Given `--hetzner-primary-ipv4` or `--hetzner-primary-ipv6`, the driver
+attempts to set up machine creation with an existing [primary IP](https://docs.hetzner.com/cloud/servers/primary-ips/overview/)
+as follows: If the passed argument parses to a valid IP address, the primary IP is resolved via address.
+Otherwise, it is resolved in the default Hetzner Cloud API way (i.e. via ID and name as a fallback).
+
+No address family validation is performed, so when specifying an IP address it is the user's responsibility to pass the
+appropriate type. This also applies to any given preconditions regarding the state of the address being attached.
+
+If no existing primary IPs are specified and public address creation is not disabled for a given address family, a new
+primary IP will be auto-generated by default. Primary IPs created in that fashion will exhibit whatever default behavior
+Hetzner assigns them at the given time, so users should take care what retention flags etc. are being set.
+
+When disabling all public IPs, `--hetzner-use-private-network` must be given.
 `--hetzner-disable-public` will take care of that, and behaves as if
 `--hetzner-disable-public-4 --hetzner-disable-public-6 --hetzner-use-private-network`
 were given.
diff --git a/driver.go b/driver.go
index 68d78e8..9cc3db4 100644
--- a/driver.go
+++ b/driver.go
@@ -44,6 +44,10 @@ type Driver struct {
 	UsePrivateNetwork bool
 	DisablePublic4    bool
 	DisablePublic6    bool
+	PrimaryIPv4       string
+	cachedPrimaryIPv4 *hcloud.PrimaryIP
+	PrimaryIPv6       string
+	cachedPrimaryIPv6 *hcloud.PrimaryIP
 	Firewalls         []string
 	ServerLabels      map[string]string
 	keyLabels         map[string]string
@@ -72,6 +76,8 @@ const (
 	flagUsePrivateNetwork = "hetzner-use-private-network"
 	flagDisablePublic4    = "hetzner-disable-public-4"
 	flagDisablePublic6    = "hetzner-disable-public-6"
+	flagPrimary4          = "hetzner-primary-ipv4"
+	flagPrimary6          = "hetzner-primary-ipv6"
 	flagDisablePublic     = "hetzner-disable-public"
 	flagFirewalls         = "hetzner-firewalls"
 	flagAdditionalKeys    = "hetzner-additional-key"
@@ -189,6 +195,18 @@ func (d *Driver) GetCreateFlags() []mcnflag.Flag {
 			Name:   flagDisablePublic,
 			Usage:  "Disable public ip (v4 & v6)",
 		},
+		mcnflag.StringFlag{
+			EnvVar: "HETZNER_PRIMARY_IPV4",
+			Name:   flagPrimary4,
+			Usage:  "Existing primary IPv4 address",
+			Value:  "",
+		},
+		mcnflag.StringFlag{
+			EnvVar: "HETZNER_PRIMARY_IPV6",
+			Name:   flagPrimary6,
+			Usage:  "Existing primary IPv6 address",
+			Value:  "",
+		},
 		mcnflag.StringSliceFlag{
 			EnvVar: "HETZNER_FIREWALLS",
 			Name:   flagFirewalls,
@@ -261,6 +279,8 @@ func (d *Driver) setConfigFromFlagsImpl(opts drivers.DriverOptions) error {
 	d.UsePrivateNetwork = opts.Bool(flagUsePrivateNetwork) || disablePublic
 	d.DisablePublic4 = opts.Bool(flagDisablePublic4) || disablePublic
 	d.DisablePublic6 = opts.Bool(flagDisablePublic6) || disablePublic
+	d.PrimaryIPv4 = opts.String(flagPrimary4)
+	d.PrimaryIPv6 = opts.String(flagPrimary6)
 	d.Firewalls = opts.StringSlice(flagFirewalls)
 	d.AdditionalKeys = opts.StringSlice(flagAdditionalKeys)
 
@@ -297,6 +317,14 @@ func (d *Driver) setConfigFromFlagsImpl(opts drivers.DriverOptions) error {
 			flagUsePrivateNetwork, flagDisablePublic)
 	}
 
+	if d.DisablePublic4 && d.PrimaryIPv4 != "" {
+		return d.flagFailure("--%v and --%v are mutually exclusive", flagPrimary4, flagDisablePublic4)
+	}
+
+	if d.DisablePublic6 && d.PrimaryIPv6 != "" {
+		return d.flagFailure("--%v and --%v are mutually exclusive", flagPrimary6, flagDisablePublic6)
+	}
+
 	return nil
 }
 
@@ -375,6 +403,14 @@ func (d *Driver) PreCreateCheck() error {
 		return fmt.Errorf("could not create placement group: %w", err)
 	}
 
+	if _, err := d.getPrimaryIPv4(); err != nil {
+		return fmt.Errorf("could not resolve primary IPv4: %w", err)
+	}
+
+	if _, err := d.getPrimaryIPv6(); err != nil {
+		return fmt.Errorf("could not resolve primary IPv6: %w", err)
+	}
+
 	if d.UsePrivateNetwork && len(d.Networks) == 0 {
 		return errors.Errorf("No private network attached.")
 	}
@@ -495,11 +531,9 @@ func (d *Driver) makeCreateServerOptions() (*hcloud.ServerCreateOpts, error) {
 		PlacementGroup: pgrp,
 	}
 
-	if d.DisablePublic4 || d.DisablePublic6 {
-		srvopts.PublicNet = &hcloud.ServerCreatePublicNet{
-			EnableIPv4: !d.DisablePublic4,
-			EnableIPv6: !d.DisablePublic6,
-		}
+	err = d.setPublicNetIfRequired(srvopts)
+	if err != nil {
+		return nil, err
 	}
 
 	networks, err := d.createNetworks()
@@ -537,6 +571,27 @@ func (d *Driver) makeCreateServerOptions() (*hcloud.ServerCreateOpts, error) {
 	return &srvopts, nil
 }
 
+func (d *Driver) setPublicNetIfRequired(srvopts hcloud.ServerCreateOpts) error {
+	pip4, err := d.getPrimaryIPv4()
+	if err != nil {
+		return err
+	}
+	pip6, err := d.getPrimaryIPv6()
+	if err != nil {
+		return err
+	}
+
+	if d.DisablePublic4 || d.DisablePublic6 || pip4 != nil || pip6 != nil {
+		srvopts.PublicNet = &hcloud.ServerCreatePublicNet{
+			EnableIPv4: !d.DisablePublic4,
+			EnableIPv6: !d.DisablePublic6,
+			IPv4:       pip4,
+			IPv6:       pip6,
+		}
+	}
+	return nil
+}
+
 func (d *Driver) createNetworks() ([]*hcloud.Network, error) {
 	networks := []*hcloud.Network{}
 	for _, networkIDorName := range d.Networks {
@@ -1094,3 +1149,52 @@ func (d *Driver) removeEmptyServerPlacementGroup(srv *hcloud.Server) error {
 		return nil
 	}
 }
+
+func (d *Driver) getPrimaryIPv4() (*hcloud.PrimaryIP, error) {
+	raw := d.PrimaryIPv4
+	if raw == "" {
+		return nil, nil
+	} else if d.cachedPrimaryIPv4 != nil {
+		return d.cachedPrimaryIPv4, nil
+	}
+
+	ip, err := d.resolvePrimaryIP(raw)
+	d.cachedPrimaryIPv4 = ip
+	return ip, err
+}
+
+func (d *Driver) getPrimaryIPv6() (*hcloud.PrimaryIP, error) {
+	raw := d.PrimaryIPv6
+	if raw == "" {
+		return nil, nil
+	} else if d.cachedPrimaryIPv6 != nil {
+		return d.cachedPrimaryIPv6, nil
+	}
+
+	ip, err := d.resolvePrimaryIP(raw)
+	d.cachedPrimaryIPv6 = ip
+	return ip, err
+}
+
+func (d *Driver) resolvePrimaryIP(raw string) (*hcloud.PrimaryIP, error) {
+	client := d.getClient().PrimaryIP
+
+	var getter func(context.Context, string) (*hcloud.PrimaryIP, *hcloud.Response, error)
+	if net.ParseIP(raw) != nil {
+		getter = client.GetByIP
+	} else {
+		getter = client.Get
+	}
+
+	ip, _, err := getter(context.Background(), raw)
+
+	if err != nil {
+		return nil, fmt.Errorf("could not get primary IP: %w", err)
+	}
+
+	if ip != nil {
+		return ip, nil
+	}
+
+	return nil, fmt.Errorf("primary IP not found: %v", raw)
+}
-- 
GitLab