diff --git a/README.md b/README.md index 9186b71..38a6ec6 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 | 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 096c6df..b24e645 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: "Path to a cloud-init user-data file for the Linode Metadata service", + }, 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,24 @@ func (d *Driver) SetConfigFromFlags(flags drivers.DriverOptions) error { return nil } +func encodeUserData(userData string) (string, error) { + if userData == "" { + return "", nil + } + + path := strings.TrimSpace(userData) + if path == "" { + return "", fmt.Errorf("--linode-user-data requires a file path") + } + + 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(content), 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 +461,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..a66b7d9 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,67 @@ func TestSetConfigFromFlags(t *testing.T) { assert.Empty(t, checkFlags.InvalidFlags) } +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{