Skip to content

Commit 51f9365

Browse files
Codexlots0logsCopilot
authored
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>
1 parent fc8bc66 commit 51f9365

3 files changed

Lines changed: 130 additions & 1 deletion

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ docker-machine create -d linode --linode-token=<linode-token> linode
6262
| `linode-swap-size` | `LINODE_SWAP_SIZE` | `512` | The amount of swap space provisioned on the Linode Instance
6363
| `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*.
6464
| `linode-stackscript-data` | `LINODE_STACKSCRIPT_DATA` | None | A JSON string specifying data that is passed (via UDF) to the selected StackScript.
65+
| `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.
6566
| `linode-create-private-ip` | `LINODE_CREATE_PRIVATE_IP` | None | A flag specifying to create private IP for the Linode instance.
6667
| `linode-tags` | `LINODE_TAGS` | None | A comma separated list of tags to apply to the Linode resource
6768
| `linode-ua-prefix` | `LINODE_UA_PREFIX` | None | Prefix the User-Agent in Linode API calls with some 'product/version'

pkg/drivers/linode/linode.go

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,9 @@ type Driver struct {
5151
StackScriptLabel string
5252
StackScriptData map[string]string
5353

54-
Tags string
54+
// UserData contains base64-encoded cloud-init user data for the Linode Metadata service.
55+
UserData string
56+
Tags string
5557
}
5658

5759
// VERSION represents the semver version of the package
@@ -221,6 +223,11 @@ func (d *Driver) GetCreateFlags() []mcnflag.Flag {
221223
Usage: "A JSON string specifying data for the selected StackScript",
222224
Value: "",
223225
},
226+
mcnflag.StringFlag{
227+
EnvVar: "LINODE_USER_DATA",
228+
Name: "linode-user-data",
229+
Usage: "Cloud-init user data for the Linode Metadata service (inline or @path to file)",
230+
},
224231
mcnflag.BoolFlag{
225232
EnvVar: "LINODE_CREATE_PRIVATE_IP",
226233
Name: "linode-create-private-ip",
@@ -279,6 +286,16 @@ func (d *Driver) SetConfigFromFlags(flags drivers.DriverOptions) error {
279286
d.UserAgentPrefix = flags.String("linode-ua-prefix")
280287
d.Tags = flags.String("linode-tags")
281288

289+
userData := flags.String("linode-user-data")
290+
if userData != "" {
291+
encodedUserData, err := encodeUserData(userData)
292+
if err != nil {
293+
return err
294+
}
295+
296+
d.UserData = encodedUserData
297+
}
298+
282299
d.SetSwarmConfigFromFlags(flags)
283300

284301
if d.APIToken == "" {
@@ -323,6 +340,28 @@ func (d *Driver) SetConfigFromFlags(flags drivers.DriverOptions) error {
323340
return nil
324341
}
325342

343+
func encodeUserData(userData string) (string, error) {
344+
if userData == "" {
345+
return "", nil
346+
}
347+
348+
if strings.HasPrefix(userData, "@") {
349+
path := strings.TrimSpace(strings.TrimPrefix(userData, "@"))
350+
if path == "" {
351+
return "", fmt.Errorf("--linode-user-data requires a file path after '@'")
352+
}
353+
354+
content, err := os.ReadFile(path)
355+
if err != nil {
356+
return "", fmt.Errorf("failed to read user data from --linode-user-data file %q: %w", path, err)
357+
}
358+
359+
userData = string(content)
360+
}
361+
362+
return base64.StdEncoding.EncodeToString([]byte(userData)), nil
363+
}
364+
326365
// PreCreateCheck allows for pre-create operations to make sure a driver is ready for creation
327366
func (d *Driver) PreCreateCheck() error {
328367
// TODO(displague) linode-stackscript-file should be read and uploaded (private), then used for boot.
@@ -426,6 +465,12 @@ func (d *Driver) Create() error {
426465
log.Infof("Using StackScript %d: %s/%s", d.StackScriptID, d.StackScriptUser, d.StackScriptLabel)
427466
}
428467

468+
if d.UserData != "" {
469+
createOpts.Metadata = &linodego.InstanceMetadataOptions{
470+
UserData: d.UserData,
471+
}
472+
}
473+
429474
linode, err := client.CreateInstance(context.TODO(), createOpts)
430475
if err != nil {
431476
return err

pkg/drivers/linode/linode_test.go

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
package linode
22

33
import (
4+
"encoding/base64"
45
"net"
6+
"os"
7+
"path/filepath"
58
"reflect"
69
"testing"
710

@@ -27,6 +30,86 @@ func TestSetConfigFromFlags(t *testing.T) {
2730
assert.Empty(t, checkFlags.InvalidFlags)
2831
}
2932

33+
func TestSetConfigFromFlagsUserDataInline(t *testing.T) {
34+
driver := NewDriver("", "")
35+
36+
userData := "#cloud-config\npackages:\n - htop\n"
37+
checkFlags := &drivers.CheckDriverOptions{
38+
FlagsValues: map[string]interface{}{
39+
"linode-token": "PROJECT",
40+
"linode-root-pass": "ROOTPASS",
41+
"linode-user-data": userData,
42+
},
43+
CreateFlags: driver.GetCreateFlags(),
44+
}
45+
46+
err := driver.SetConfigFromFlags(checkFlags)
47+
48+
assert.NoError(t, err)
49+
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(userData)), driver.UserData)
50+
}
51+
52+
func TestSetConfigFromFlagsUserDataFile(t *testing.T) {
53+
driver := NewDriver("", "")
54+
55+
dir := t.TempDir()
56+
userDataPath := filepath.Join(dir, "user-data.yaml")
57+
userData := "#cloud-config\npackages:\n - curl\n"
58+
if err := os.WriteFile(userDataPath, []byte(userData), 0o600); err != nil {
59+
t.Fatalf("failed to write user data fixture: %s", err)
60+
}
61+
62+
checkFlags := &drivers.CheckDriverOptions{
63+
FlagsValues: map[string]interface{}{
64+
"linode-token": "PROJECT",
65+
"linode-root-pass": "ROOTPASS",
66+
"linode-user-data": "@" + userDataPath,
67+
},
68+
CreateFlags: driver.GetCreateFlags(),
69+
}
70+
71+
err := driver.SetConfigFromFlags(checkFlags)
72+
73+
assert.NoError(t, err)
74+
assert.Equal(t, base64.StdEncoding.EncodeToString([]byte(userData)), driver.UserData)
75+
}
76+
77+
func TestSetConfigFromFlagsUserDataMissingFile(t *testing.T) {
78+
driver := NewDriver("", "")
79+
80+
checkFlags := &drivers.CheckDriverOptions{
81+
FlagsValues: map[string]interface{}{
82+
"linode-token": "PROJECT",
83+
"linode-root-pass": "ROOTPASS",
84+
"linode-user-data": "@/does/not/exist",
85+
},
86+
CreateFlags: driver.GetCreateFlags(),
87+
}
88+
89+
err := driver.SetConfigFromFlags(checkFlags)
90+
91+
assert.Error(t, err)
92+
assert.Contains(t, err.Error(), "--linode-user-data")
93+
}
94+
95+
func TestSetConfigFromFlagsUserDataEmptyPath(t *testing.T) {
96+
driver := NewDriver("", "")
97+
98+
checkFlags := &drivers.CheckDriverOptions{
99+
FlagsValues: map[string]interface{}{
100+
"linode-token": "PROJECT",
101+
"linode-root-pass": "ROOTPASS",
102+
"linode-user-data": "@",
103+
},
104+
CreateFlags: driver.GetCreateFlags(),
105+
}
106+
107+
err := driver.SetConfigFromFlags(checkFlags)
108+
109+
assert.Error(t, err)
110+
assert.Contains(t, err.Error(), "--linode-user-data")
111+
}
112+
30113
func TestPrivateIP(t *testing.T) {
31114
ip := net.IP{}
32115
for _, addr := range [][]byte{

0 commit comments

Comments
 (0)