From 13bf4f826babc58494f725e937b5a64795f773ba Mon Sep 17 00:00:00 2001
From: JonasS <jonass@dev.jsje.de>
Date: Sun, 26 Feb 2023 13:01:05 +0100
Subject: [PATCH] feat: Add `--hetzner-user-data-file` (closes #99) - added
 `--hetzner-user-data-file` and deprecated `--hetzner-user-data-from-file` in
 favor of it (#99, thanks @perlun)

---
 README.md      |  20 ++++++--
 driver.go      |  59 ++++++++++++++++++------
 driver_test.go | 122 +++++++++++++++++++++++++++++++++++++++++++++++++
 go.mod         |   5 ++
 go.sum         |  18 +++++++-
 5 files changed, 205 insertions(+), 19 deletions(-)
 create mode 100644 driver_test.go

diff --git a/README.md b/README.md
index 0ae4e9d..251dacb 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.11.0/docker-machine-driver-hetzner_3.11.0_linux_amd64.tar.gz
-$ tar -xvf docker-machine-driver-hetzner_3.11.0_linux_amd64.tar.gz
+$ wget https://github.com/JonasProgrammer/docker-machine-driver-hetzner/releases/download/3.12.0/docker-machine-driver-hetzner_3.12.0_linux_amd64.tar.gz
+$ tar -xvf docker-machine-driver-hetzner_3.12.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
@@ -99,8 +99,9 @@ $ docker-machine create \
 - `--hetzner-existing-key-id`: **requires `--hetzner-existing-key-path`**. Use an existing (remote) SSH key instead of uploading the imported key pair,
   see [SSH Keys API](https://docs.hetzner.cloud/#resources-ssh-keys-get) for how to get a list
 - `--hetzner-additional-key`: Upload an additional public key associated with the server, or associate an existing one with the same fingerprint. Can be specified multiple times.
-- `--hetzner-user-data`: Cloud-init based User data
-- `--hetzner-user-data-from-file`: Use Cloud-init based User data as file, `--hetzner-user-data` as file name
+- `--hetzner-user-data`: Cloud-init based data, passed inline as-is.
+- `--hetzner-user-data-file`: Cloud-init based data, read from passed file.
+- `--hetzner-user-data-from-file`: DEPRECATED, use `--hetzner-user-data-file`. Read `--hetzner-user-data` as file name and use contents as user-data.
 - `--hetzner-volumes`: Volume IDs or names which should be attached to the server
 - `--hetzner-networks`: Network IDs or names which should be attached to the server private network interface
 - `--hetzner-use-private-network`: Use private network
@@ -142,6 +143,7 @@ was used during creation.
 | `--hetzner-existing-key-id`     | `HETZNER_EXISTING_KEY_ID`     | 0 *(upload new key)*       |
 | `--hetzner-additional-key`      | `HETZNER_ADDITIONAL_KEYS`     |                            |
 | `--hetzner-user-data`           | `HETZNER_USER_DATA`           |                            |
+| `--hetzner-user-data-file`      | `HETZNER_USER_DATA_FILE`      |                            |
 | `--hetzner-networks`            | `HETZNER_NETWORKS`            |                            |
 | `--hetzner-firewalls`           | `HETZNER_FIREWALLS`           |                            |
 | `--hetzner-volumes`             | `HETZNER_VOLUMES`             |                            |
@@ -229,3 +231,13 @@ $ export PATH="$PATH:$GOBIN"
 # Make docker-machine output help including hetzner-specific options
 $ docker-machine create --driver hetzner
 ```
+
+## Upcoming breaking changes
+
+### 4.0.0
+
+* `--hetzner-user-data-from-file` will be fully deprecated and its flag description will only read 'DEPRECATED, legacy'; current fallback behaviour will be retained. `--hetzner-flag-user-data-file` should be used instead.
+
+### 5.0.0
+
+* `--hetzner-user-data-from-file` will be removed entirely, including its fallback behavior
diff --git a/driver.go b/driver.go
index a4e26ec..1a241f1 100644
--- a/driver.go
+++ b/driver.go
@@ -39,7 +39,7 @@ type Driver struct {
 	ServerID          int
 	cachedServer      *hcloud.Server
 	userData          string
-	userDataFromFile  bool
+	userDataFile      string
 	Volumes           []string
 	Networks          []string
 	UsePrivateNetwork bool
@@ -74,7 +74,7 @@ const (
 	flagExKeyID           = "hetzner-existing-key-id"
 	flagExKeyPath         = "hetzner-existing-key-path"
 	flagUserData          = "hetzner-user-data"
-	flagUserDataFromFile  = "hetzner-user-data-from-file"
+	flagUserDataFile      = "hetzner-user-data-file"
 	flagVolumes           = "hetzner-volumes"
 	flagNetworks          = "hetzner-networks"
 	flagUsePrivateNetwork = "hetzner-use-private-network"
@@ -104,6 +104,8 @@ const (
 
 	flagWaitOnError    = "wait-on-error"
 	defaultWaitOnError = 0
+
+	legacyFlagUserDataFromFile = "hetzner-user-data-from-file"
 )
 
 // NewDriver initializes a new driver instance; see [drivers.Driver.NewDriver]
@@ -167,13 +169,19 @@ func (d *Driver) GetCreateFlags() []mcnflag.Flag {
 		mcnflag.StringFlag{
 			EnvVar: "HETZNER_USER_DATA",
 			Name:   flagUserData,
-			Usage:  "Cloud-init based User data",
+			Usage:  "Cloud-init based user data (inline).",
 			Value:  "",
 		},
 		mcnflag.BoolFlag{
 			EnvVar: "HETZNER_USER_DATA_FROM_FILE",
-			Name:   flagUserDataFromFile,
-			Usage:  "Cloud-init based User data is file",
+			Name:   legacyFlagUserDataFromFile,
+			Usage:  "DEPRECATED, use --hetzner-user-data-file. Treat --hetzner-user-data argument as filename.",
+		},
+		mcnflag.StringFlag{
+			EnvVar: "HETZNER_USER_DATA_FILE",
+			Name:   flagUserDataFile,
+			Usage:  "Cloud-init based user data (read from file)",
+			Value:  "",
 		},
 		mcnflag.StringSliceFlag{
 			EnvVar: "HETZNER_VOLUMES",
@@ -290,8 +298,10 @@ func (d *Driver) setConfigFromFlagsImpl(opts drivers.DriverOptions) error {
 	d.KeyID = opts.Int(flagExKeyID)
 	d.IsExistingKey = d.KeyID != 0
 	d.originalKey = opts.String(flagExKeyPath)
-	d.userData = opts.String(flagUserData)
-	d.userDataFromFile = opts.Bool(flagUserDataFromFile)
+	err := d.setUserDataFlags(opts)
+	if err != nil {
+		return err
+	}
 	d.Volumes = opts.StringSlice(flagVolumes)
 	d.Networks = opts.StringSlice(flagNetworks)
 	disablePublic := opts.Bool(flagDisablePublic)
@@ -316,7 +326,7 @@ func (d *Driver) setConfigFromFlagsImpl(opts drivers.DriverOptions) error {
 		d.placementGroup = autoSpreadPgName
 	}
 
-	err := d.setLabelsFromFlags(opts)
+	err = d.setLabelsFromFlags(opts)
 	if err != nil {
 		return err
 	}
@@ -351,6 +361,30 @@ func (d *Driver) setConfigFromFlagsImpl(opts drivers.DriverOptions) error {
 	return nil
 }
 
+func (d *Driver) setUserDataFlags(opts drivers.DriverOptions) error {
+	userData := opts.String(flagUserData)
+	userDataFile := opts.String(flagUserDataFile)
+
+	if opts.Bool(legacyFlagUserDataFromFile) {
+		if userDataFile != "" {
+			return d.flagFailure("--%v and --%v are mutually exclusive", flagUserDataFile, legacyFlagUserDataFromFile)
+		}
+
+		log.Warnf("--%v is deprecated, pass '--%v \"%v\"'", legacyFlagUserDataFromFile, flagUserDataFile, userData)
+		d.userDataFile = userData
+		return nil
+	}
+
+	d.userData = userData
+	d.userDataFile = userDataFile
+
+	if d.userData != "" && d.userDataFile != "" {
+		return d.flagFailure("--%v and --%v are mutually exclusive", flagUserData, flagUserDataFile)
+	}
+
+	return nil
+}
+
 // GetSSHUsername retrieves the SSH username used to connect to the server during provisioning
 func (d *Driver) GetSSHUsername() string {
 	return d.SSHUser
@@ -601,13 +635,12 @@ func (d *Driver) makeCreateServerOptions() (*hcloud.ServerCreateOpts, error) {
 }
 
 func (d *Driver) getUserData() (string, error) {
-	userData := d.userData
-
-	if !d.userDataFromFile {
-		return userData, nil
+	file := d.userDataFile
+	if file == "" {
+		return d.userData, nil
 	}
 
-	readUserData, err := os.ReadFile(d.userData)
+	readUserData, err := os.ReadFile(file)
 	if err != nil {
 		return "", err
 	}
diff --git a/driver_test.go b/driver_test.go
new file mode 100644
index 0000000..7ddea3b
--- /dev/null
+++ b/driver_test.go
@@ -0,0 +1,122 @@
+package main
+
+import (
+	"github.com/docker/machine/commands/commandstest"
+	"github.com/docker/machine/libmachine/drivers"
+	"os"
+	"strings"
+	"testing"
+)
+
+var defaultFlags = map[string]interface{}{
+	flagAPIToken: "foo",
+}
+
+func makeFlags(args map[string]interface{}) drivers.DriverOptions {
+	combined := make(map[string]interface{}, len(defaultFlags)+len(args))
+	for k, v := range defaultFlags {
+		combined[k] = v
+	}
+	for k, v := range args {
+		combined[k] = v
+	}
+
+	return &commandstest.FakeFlagger{Data: combined}
+}
+
+func TestUserData(t *testing.T) {
+	const fileContents = "User data from file"
+	const inlineContents = "User data"
+
+	file := t.TempDir() + string(os.PathSeparator) + "userData"
+	err := os.WriteFile(file, []byte(fileContents), 0644)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	// mutual exclusion data <=> data file
+	d := NewDriver()
+	err = d.setConfigFromFlagsImpl(makeFlags(map[string]interface{}{
+		flagUserData:     inlineContents,
+		flagUserDataFile: file,
+	}))
+	assertMutualExclusion(t, err, flagUserData, flagUserDataFile)
+
+	// mutual exclusion data file <=> legacy flag
+	d = NewDriver()
+	err = d.setConfigFromFlagsImpl(&commandstest.FakeFlagger{
+		Data: map[string]interface{}{
+			flagAPIToken:               "foo",
+			legacyFlagUserDataFromFile: true,
+			flagUserDataFile:           file,
+		},
+	})
+	assertMutualExclusion(t, err, legacyFlagUserDataFromFile, flagUserDataFile)
+
+	// inline user data
+	d = NewDriver()
+	err = d.setConfigFromFlagsImpl(makeFlags(map[string]interface{}{
+		flagAPIToken: "foo",
+		flagUserData: inlineContents,
+	}))
+	if err != nil {
+		t.Fatalf("unexpected error, %v", err)
+	}
+
+	data, err := d.getUserData()
+	if err != nil {
+		t.Fatalf("unexpected error, %v", err)
+	}
+	if data != inlineContents {
+		t.Error("content did not match (inline)")
+	}
+
+	// file user data
+	d = NewDriver()
+	err = d.setConfigFromFlagsImpl(makeFlags(map[string]interface{}{
+		flagAPIToken:     "foo",
+		flagUserDataFile: file,
+	}))
+	if err != nil {
+		t.Fatalf("unexpected error, %v", err)
+	}
+
+	data, err = d.getUserData()
+	if err != nil {
+		t.Fatalf("unexpected error, %v", err)
+	}
+	if data != fileContents {
+		t.Error("content did not match (file)")
+	}
+
+	// legacy file user data
+	d = NewDriver()
+	err = d.setConfigFromFlagsImpl(makeFlags(map[string]interface{}{
+		flagAPIToken:               "foo",
+		flagUserData:               file,
+		legacyFlagUserDataFromFile: true,
+	}))
+	if err != nil {
+		t.Fatalf("unexpected error, %v", err)
+	}
+
+	data, err = d.getUserData()
+	if err != nil {
+		t.Fatalf("unexpected error, %v", err)
+	}
+	if data != fileContents {
+		t.Error("content did not match (legacy-file)")
+	}
+}
+
+func assertMutualExclusion(t *testing.T, err error, flag1, flag2 string) {
+	if err == nil {
+		t.Errorf("expected mutually exclusive flags to fail, but no error was thrown: %v %v", flag1, flag2)
+		return
+	}
+
+	errstr := err.Error()
+	if !(strings.Contains(errstr, flag1) && strings.Contains(errstr, flag2) && strings.Contains(errstr, "mutually exclusive")) {
+		t.Errorf("expected mutually exclusive flags to fail, but message differs: %v %v %v", flag1, flag2, errstr)
+	}
+}
diff --git a/go.mod b/go.mod
index 4379c1f..0c3eb16 100644
--- a/go.mod
+++ b/go.mod
@@ -9,10 +9,14 @@ require (
 	golang.org/x/crypto v0.3.0
 )
 
+replace github.com/codegangsta/cli v1.22.12 => github.com/urfave/cli v1.22.12
+
 require (
 	github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/cespare/xxhash/v2 v2.1.2 // indirect
+	github.com/codegangsta/cli v1.22.12 // indirect
+	github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
 	github.com/docker/docker v20.10.21+incompatible // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect
@@ -21,6 +25,7 @@ require (
 	github.com/prometheus/client_model v0.3.0 // indirect
 	github.com/prometheus/common v0.37.0 // indirect
 	github.com/prometheus/procfs v0.8.0 // indirect
+	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 	golang.org/x/net v0.2.0 // indirect
 	golang.org/x/sys v0.2.0 // indirect
 	golang.org/x/term v0.2.0 // indirect
diff --git a/go.sum b/go.sum
index e53cc4a..5c803d8 100644
--- a/go.sum
+++ b/go.sum
@@ -34,6 +34,7 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7
 github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
 github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
+github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
 github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
 github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@@ -53,6 +54,8 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P
 github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
 github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
+github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -197,15 +200,24 @@ github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
 github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
 github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
+github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
+github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
 github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
 github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
-github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/urfave/cli v1.22.12 h1:igJgVw1JdKH+trcLWLeLwZjU9fEfPesQ+9/e4MQ44S8=
+github.com/urfave/cli v1.22.12/go.mod h1:sSBEIC79qR6OvcmsD4U3KABeOTxDqQtdDnaFuUN30b8=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -490,7 +502,9 @@ gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
-gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-- 
GitLab