From 51f93656b583d948d6f4f52b2412632fa0fed207 Mon Sep 17 00:00:00 2001 From: Codex <242516109+Codex@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:50:54 -0500 Subject: [PATCH 1/2] Add cloud-init user data support via Linode Metadata (#4) * feat: support metadata user data Co-authored-by: lots0logs <4675662+lots0logs@users.noreply.github.com> * fix: address cloud-init review comments Agent-Logs-Url: https://github.com/lots0logs/docker-machine-driver-linode/sessions/7c66e84c-87fb-4861-ab2d-8874c175f8f5 Co-authored-by: lots0logs <4675662+lots0logs@users.noreply.github.com> --------- Co-authored-by: openai-code-agent[bot] <242516109+Codex@users.noreply.github.com> Co-authored-by: lots0logs <4675662+lots0logs@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- README.md | 1 + pkg/drivers/linode/linode.go | 47 ++++++++++++++++- pkg/drivers/linode/linode_test.go | 83 +++++++++++++++++++++++++++++++ 3 files changed, 130 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9186b71..4b3c6bf 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ docker-machine create -d linode --linode-token= linode | `linode-swap-size` | `LINODE_SWAP_SIZE` | `512` | The amount of swap space provisioned on the Linode Instance | `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-user-data` | `LINODE_USER_DATA` | None | Cloud-init user data passed to the Linode Metadata service; use inline content or prefix with `@` to read from a file. Content is base64-encoded automatically. | `linode-create-private-ip` | `LINODE_CREATE_PRIVATE_IP` | None | A flag specifying to create private IP for the Linode instance. | `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' diff --git a/pkg/drivers/linode/linode.go b/pkg/drivers/linode/linode.go index 096c6df..fd381a5 100644 --- a/pkg/drivers/linode/linode.go +++ b/pkg/drivers/linode/linode.go @@ -51,7 +51,9 @@ type Driver struct { StackScriptLabel string StackScriptData map[string]string - Tags string + // UserData contains base64-encoded cloud-init user data for the Linode Metadata service. + UserData string + Tags string } // VERSION represents the semver version of the package @@ -221,6 +223,11 @@ func (d *Driver) GetCreateFlags() []mcnflag.Flag { Usage: "A JSON string specifying data for the selected StackScript", Value: "", }, + mcnflag.StringFlag{ + EnvVar: "LINODE_USER_DATA", + Name: "linode-user-data", + Usage: "Cloud-init user data for the Linode Metadata service (inline or @path to file)", + }, mcnflag.BoolFlag{ EnvVar: "LINODE_CREATE_PRIVATE_IP", Name: "linode-create-private-ip", @@ -279,6 +286,16 @@ func (d *Driver) SetConfigFromFlags(flags drivers.DriverOptions) error { d.UserAgentPrefix = flags.String("linode-ua-prefix") d.Tags = flags.String("linode-tags") + userData := flags.String("linode-user-data") + if userData != "" { + encodedUserData, err := encodeUserData(userData) + if err != nil { + return err + } + + d.UserData = encodedUserData + } + d.SetSwarmConfigFromFlags(flags) if d.APIToken == "" { @@ -323,6 +340,28 @@ func (d *Driver) SetConfigFromFlags(flags drivers.DriverOptions) error { return nil } +func encodeUserData(userData string) (string, error) { + if userData == "" { + return "", nil + } + + if strings.HasPrefix(userData, "@") { + path := strings.TrimSpace(strings.TrimPrefix(userData, "@")) + if path == "" { + return "", fmt.Errorf("--linode-user-data requires a file path after '@'") + } + + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read user data from --linode-user-data file %q: %w", path, err) + } + + userData = string(content) + } + + return base64.StdEncoding.EncodeToString([]byte(userData)), nil +} + // PreCreateCheck allows for pre-create operations to make sure a driver is ready for creation func (d *Driver) PreCreateCheck() error { // TODO(displague) linode-stackscript-file should be read and uploaded (private), then used for boot. @@ -426,6 +465,12 @@ func (d *Driver) Create() error { log.Infof("Using StackScript %d: %s/%s", d.StackScriptID, d.StackScriptUser, d.StackScriptLabel) } + if d.UserData != "" { + createOpts.Metadata = &linodego.InstanceMetadataOptions{ + UserData: d.UserData, + } + } + linode, err := client.CreateInstance(context.TODO(), createOpts) if err != nil { return err diff --git a/pkg/drivers/linode/linode_test.go b/pkg/drivers/linode/linode_test.go index efc16e6..a97a174 100644 --- a/pkg/drivers/linode/linode_test.go +++ b/pkg/drivers/linode/linode_test.go @@ -1,7 +1,10 @@ package linode import ( + "encoding/base64" "net" + "os" + "path/filepath" "reflect" "testing" @@ -27,6 +30,86 @@ func TestSetConfigFromFlags(t *testing.T) { assert.Empty(t, checkFlags.InvalidFlags) } +func TestSetConfigFromFlagsUserDataInline(t *testing.T) { + driver := NewDriver("", "") + + userData := "#cloud-config\npackages:\n - htop\n" + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-root-pass": "ROOTPASS", + "linode-user-data": userData, + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + + assert.NoError(t, err) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(userData)), driver.UserData) +} + +func TestSetConfigFromFlagsUserDataFile(t *testing.T) { + driver := NewDriver("", "") + + dir := t.TempDir() + userDataPath := filepath.Join(dir, "user-data.yaml") + userData := "#cloud-config\npackages:\n - curl\n" + if err := os.WriteFile(userDataPath, []byte(userData), 0o600); err != nil { + t.Fatalf("failed to write user data fixture: %s", err) + } + + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-root-pass": "ROOTPASS", + "linode-user-data": "@" + userDataPath, + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + + assert.NoError(t, err) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(userData)), driver.UserData) +} + +func TestSetConfigFromFlagsUserDataMissingFile(t *testing.T) { + driver := NewDriver("", "") + + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-root-pass": "ROOTPASS", + "linode-user-data": "@/does/not/exist", + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "--linode-user-data") +} + +func TestSetConfigFromFlagsUserDataEmptyPath(t *testing.T) { + driver := NewDriver("", "") + + checkFlags := &drivers.CheckDriverOptions{ + FlagsValues: map[string]interface{}{ + "linode-token": "PROJECT", + "linode-root-pass": "ROOTPASS", + "linode-user-data": "@", + }, + CreateFlags: driver.GetCreateFlags(), + } + + err := driver.SetConfigFromFlags(checkFlags) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "--linode-user-data") +} + func TestPrivateIP(t *testing.T) { ip := net.IP{} for _, addr := range [][]byte{ From 14feddab8f2ba001ea040d940413e7f0550e41d2 Mon Sep 17 00:00:00 2001 From: Dustin Falgout Date: Fri, 10 Apr 2026 13:51:11 -0500 Subject: [PATCH 2/2] fix: require file-backed linode user data --- README.md | 2 +- pkg/drivers/linode/linode.go | 22 +++++++++------------- pkg/drivers/linode/linode_test.go | 25 +++---------------------- 3 files changed, 13 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 4b3c6bf..38a6ec6 100644 --- a/README.md +++ b/README.md @@ -62,7 +62,7 @@ docker-machine create -d linode --linode-token= linode | `linode-swap-size` | `LINODE_SWAP_SIZE` | `512` | The amount of swap space provisioned on the Linode Instance | `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-user-data` | `LINODE_USER_DATA` | None | Cloud-init user data passed to the Linode Metadata service; use inline content or prefix with `@` to read from a file. Content is base64-encoded automatically. +| `linode-user-data` | `LINODE_USER_DATA` | None | Path to a cloud-init user-data file passed to the Linode Metadata service. File contents are base64-encoded automatically. | `linode-create-private-ip` | `LINODE_CREATE_PRIVATE_IP` | None | A flag specifying to create private IP for the Linode instance. | `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' diff --git a/pkg/drivers/linode/linode.go b/pkg/drivers/linode/linode.go index fd381a5..b24e645 100644 --- a/pkg/drivers/linode/linode.go +++ b/pkg/drivers/linode/linode.go @@ -226,7 +226,7 @@ func (d *Driver) GetCreateFlags() []mcnflag.Flag { mcnflag.StringFlag{ EnvVar: "LINODE_USER_DATA", Name: "linode-user-data", - Usage: "Cloud-init user data for the Linode Metadata service (inline or @path to file)", + Usage: "Path to a cloud-init user-data file for the Linode Metadata service", }, mcnflag.BoolFlag{ EnvVar: "LINODE_CREATE_PRIVATE_IP", @@ -345,21 +345,17 @@ func encodeUserData(userData string) (string, error) { return "", nil } - if strings.HasPrefix(userData, "@") { - path := strings.TrimSpace(strings.TrimPrefix(userData, "@")) - if path == "" { - return "", fmt.Errorf("--linode-user-data requires a file path after '@'") - } - - content, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("failed to read user data from --linode-user-data file %q: %w", path, err) - } + path := strings.TrimSpace(userData) + if path == "" { + return "", fmt.Errorf("--linode-user-data requires a file path") + } - userData = string(content) + content, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("failed to read user data from --linode-user-data file %q: %w", path, err) } - return base64.StdEncoding.EncodeToString([]byte(userData)), nil + return base64.StdEncoding.EncodeToString(content), nil } // PreCreateCheck allows for pre-create operations to make sure a driver is ready for creation diff --git a/pkg/drivers/linode/linode_test.go b/pkg/drivers/linode/linode_test.go index a97a174..a66b7d9 100644 --- a/pkg/drivers/linode/linode_test.go +++ b/pkg/drivers/linode/linode_test.go @@ -30,25 +30,6 @@ func TestSetConfigFromFlags(t *testing.T) { assert.Empty(t, checkFlags.InvalidFlags) } -func TestSetConfigFromFlagsUserDataInline(t *testing.T) { - driver := NewDriver("", "") - - userData := "#cloud-config\npackages:\n - htop\n" - checkFlags := &drivers.CheckDriverOptions{ - FlagsValues: map[string]interface{}{ - "linode-token": "PROJECT", - "linode-root-pass": "ROOTPASS", - "linode-user-data": userData, - }, - CreateFlags: driver.GetCreateFlags(), - } - - err := driver.SetConfigFromFlags(checkFlags) - - assert.NoError(t, err) - assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(userData)), driver.UserData) -} - func TestSetConfigFromFlagsUserDataFile(t *testing.T) { driver := NewDriver("", "") @@ -63,7 +44,7 @@ func TestSetConfigFromFlagsUserDataFile(t *testing.T) { FlagsValues: map[string]interface{}{ "linode-token": "PROJECT", "linode-root-pass": "ROOTPASS", - "linode-user-data": "@" + userDataPath, + "linode-user-data": userDataPath, }, CreateFlags: driver.GetCreateFlags(), } @@ -81,7 +62,7 @@ func TestSetConfigFromFlagsUserDataMissingFile(t *testing.T) { FlagsValues: map[string]interface{}{ "linode-token": "PROJECT", "linode-root-pass": "ROOTPASS", - "linode-user-data": "@/does/not/exist", + "linode-user-data": "/does/not/exist", }, CreateFlags: driver.GetCreateFlags(), } @@ -99,7 +80,7 @@ func TestSetConfigFromFlagsUserDataEmptyPath(t *testing.T) { FlagsValues: map[string]interface{}{ "linode-token": "PROJECT", "linode-root-pass": "ROOTPASS", - "linode-user-data": "@", + "linode-user-data": " ", }, CreateFlags: driver.GetCreateFlags(), }