Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ docker-machine create -d linode --linode-token=<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.
Comment thread
lots0logs marked this conversation as resolved.
Outdated
| `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'
Expand Down
47 changes: 46 additions & 1 deletion pkg/drivers/linode/linode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
lots0logs marked this conversation as resolved.
Tags string
}

// VERSION represents the semver version of the package
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
}
Comment on lines +289 to +297
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SetConfigFromFlags reads/validates --linode-user-data (including reading the file) before checking required options like --linode-token. This makes error precedence inconsistent with other validations in this method (e.g., StackScript parsing happens after the token check) and can surface a user-data file error even when required auth is missing. Consider moving the user-data validation block to after the d.APIToken == "" check so required-flag errors are reported first and validation order stays consistent.

Copilot uses AI. Check for mistakes.

d.SetSwarmConfigFromFlags(flags)

if d.APIToken == "" {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions pkg/drivers/linode/linode_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package linode

import (
"encoding/base64"
"net"
"os"
"path/filepath"
"reflect"
"testing"

Expand All @@ -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{
Expand Down
Loading