diff --git a/README.md b/README.md index d9ae1ea..13105b4 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,9 @@ You can use [sample Ubuntu Server template](deploy/templates/ubuntu-server) for | `--pve-network-interface` | `PVE_NETWORK_INTERFACE` | N/A (required) | Bus/Device of the network interface to read machine's IP address from (e.g. `net0`). | | `--pve-ssh-user` | `PVE_SSH_USER` | `service` | Username for the SSH user that will be created via cloud-init. | | `--pve-ssh-port` | `PVE_SSH_PORT` | `22` | Port to use when connecting to the machine via SSH. | +| `--pve-user-data` | `PVE_USER_DATA` | *unset* | Optional inline cloud-init user-data YAML to merge into user-data. | +| `--pve-cloud-init` | `PVE_CLOUD_INIT` | *unset* | Optional cloud-init user-data source (filepath or URL). Ignored when `--pve-user-data` is set. | +| `--pve-cloud-config` | `PVE_CLOUD_CONFIG` | *unset* | Optional filepath to a cloud-config YAML file to merge into user-data. Ignored when `--pve-user-data` is set. | | `--pve-processor-sockets` | `PVE_PROCESSOR_SOCKETS` | *unset* | If set, number of processor sockets to configure for the machine. | | `--pve-processor-cores` | `PVE_PROCESSOR_CORES` | *unset* | If set, number of processor cores to configure for the machine. | | `--pve-memory` | `PVE_MEMORY` | *unset* 1 | If set, amount of memory in MiB to configure for the machine. | diff --git a/cmd/docker-machine-driver-pve/driver/cloud-init.go b/cmd/docker-machine-driver-pve/driver/cloud-init.go index c96d58c..eac4c26 100644 --- a/cmd/docker-machine-driver-pve/driver/cloud-init.go +++ b/cmd/docker-machine-driver-pve/driver/cloud-init.go @@ -4,7 +4,11 @@ import ( "context" "errors" "fmt" + "io" + "net/http" + "net/url" "os" + "strings" "time" "github.com/luthermonson/go-proxmox" @@ -12,6 +16,11 @@ import ( yaml "gopkg.in/yaml.v3" ) +const ( + cloudInitHTTPTimeout = 30 * time.Second + cloudInitSudoRule = "ALL=(ALL) NOPASSWD:ALL" +) + // Configures cloud-init for the current machine. func (d *Driver) setupCloudinit(ctx context.Context) error { machine, err := d.getCurrentMachine(ctx) @@ -105,22 +114,14 @@ func (d *Driver) generateCloudinitUserdata() (string, error) { return "", fmt.Errorf("failed to read machine's SSH public key: %w", err) } - userdata := map[string]interface{}{ - "hostname": d.MachineName, - "preserve_hostname": false, - "create_hostname_file": true, - "users": []map[string]interface{}{ - { - "name": d.SSHUser, - "lock_passwd": true, - "sudo": "ALL=(ALL) NOPASSWD:ALL", - "ssh_authorized_keys": []string{ - string(sshPublicKey), - }, - }, - }, + userdata, err := d.getBaseCloudinitUserdata() + if err != nil { + return "", err } + defaultCloudInitUserdata(userdata, d.MachineName) + upsertCloudInitSSHUser(userdata, d.SSHUser, strings.TrimSpace(string(sshPublicKey))) + userdataYAML, err := yaml.Marshal(&userdata) if err != nil { return "", fmt.Errorf("failed to marshal cloud-init userdata: %w", err) @@ -128,3 +129,193 @@ func (d *Driver) generateCloudinitUserdata() (string, error) { return fmt.Sprintf("#cloud-config\n%s", userdataYAML), nil } + +func (d *Driver) getBaseCloudinitUserdata() (map[string]interface{}, error) { + cloudConfig := strings.TrimSpace(d.UserData) + if cloudConfig == "" { + cloudConfig = strings.TrimSpace(d.CloudConfig) + } + + if cloudConfig == "" { + loadedCloudConfig, err := loadCloudConfigFromSource(strings.TrimSpace(d.CloudInit)) + if err != nil { + return nil, err + } + + cloudConfig = loadedCloudConfig + } + + cloudConfig = strings.TrimSpace(cloudConfig) + if cloudConfig == "" { + return map[string]interface{}{}, nil + } + + cloudConfig = strings.TrimPrefix(cloudConfig, "#cloud-config") + cloudConfig = strings.TrimSpace(cloudConfig) + + userdata := map[string]interface{}{} + if err := yaml.Unmarshal([]byte(cloudConfig), &userdata); err != nil { + return nil, fmt.Errorf("failed to parse cloud-init user-data: %w", err) + } + + return userdata, nil +} + +func loadCloudConfigFromSource(source string) (string, error) { + source = strings.TrimSpace(source) + if source == "" { + return "", nil + } + + parsedURL, err := url.ParseRequestURI(source) + if err == nil && parsedURL.Scheme != "" && parsedURL.Host != "" { + client := &http.Client{Timeout: cloudInitHTTPTimeout} + + resp, reqErr := client.Get(source) //nolint:noctx // User-provided cloud-init URL. + if reqErr != nil { + return "", fmt.Errorf("failed to fetch cloud-init URL '%s': %w", source, reqErr) + } + + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return "", fmt.Errorf("failed to fetch cloud-init URL '%s': status code %d", source, resp.StatusCode) + } + + content, readErr := io.ReadAll(resp.Body) + if readErr != nil { + return "", fmt.Errorf("failed to read cloud-init URL response '%s': %w", source, readErr) + } + + return string(content), nil + } + + content, readErr := os.ReadFile(source) + if readErr != nil { + return "", fmt.Errorf("failed to read cloud-init source '%s': %w", source, readErr) + } + + return string(content), nil +} + +func defaultCloudInitUserdata(userdata map[string]interface{}, machineName string) { + if _, ok := userdata["hostname"]; !ok { + userdata["hostname"] = machineName + } + + if _, ok := userdata["preserve_hostname"]; !ok { + userdata["preserve_hostname"] = false + } + + if _, ok := userdata["create_hostname_file"]; !ok { + userdata["create_hostname_file"] = true + } +} + +func upsertCloudInitSSHUser(userdata map[string]interface{}, sshUser, sshPublicKey string) { + defaultUser := defaultCloudInitUser(sshUser) + + usersValue, usersSet := userdata["users"] + if !usersSet { + defaultUser["ssh_authorized_keys"] = []interface{}{sshPublicKey} + userdata["users"] = []interface{}{defaultUser} + + return + } + + usersList, ok := usersValue.([]interface{}) + if !ok { + defaultUser["ssh_authorized_keys"] = []interface{}{sshPublicKey} + userdata["users"] = []interface{}{usersValue, defaultUser} + + return + } + + userIndex, userMap, found := findCloudInitUser(usersList, sshUser) + if !found { + defaultUser["ssh_authorized_keys"] = []interface{}{sshPublicKey} + userdata["users"] = append(usersList, defaultUser) + + return + } + + if _, hasLockPasswd := userMap["lock_passwd"]; !hasLockPasswd { + userMap["lock_passwd"] = true + } + + if _, hasSudo := userMap["sudo"]; !hasSudo { + userMap["sudo"] = cloudInitSudoRule + } + + userMap["ssh_authorized_keys"] = upsertCloudInitAuthorizedKeys(userMap["ssh_authorized_keys"], sshPublicKey) + + usersList[userIndex] = userMap + userdata["users"] = usersList +} + +func defaultCloudInitUser(sshUser string) map[string]interface{} { + return map[string]interface{}{ + "name": sshUser, + "lock_passwd": true, + "sudo": cloudInitSudoRule, + } +} + +func findCloudInitUser(usersList []interface{}, sshUser string) (int, map[string]interface{}, bool) { + for idx, entry := range usersList { + userMap, mapOK := entry.(map[string]interface{}) + if !mapOK { + continue + } + + userName, nameOK := userMap["name"].(string) + if !nameOK || userName != sshUser { + continue + } + + return idx, userMap, true + } + + return -1, nil, false +} + +func upsertCloudInitAuthorizedKeys(rawKeys interface{}, sshPublicKey string) []interface{} { + keysAsInterfaces := cloudInitAuthorizedKeysToInterfaces(rawKeys) + if cloudInitContainsAuthorizedKey(keysAsInterfaces, sshPublicKey) { + return keysAsInterfaces + } + + return append(keysAsInterfaces, sshPublicKey) +} + +func cloudInitAuthorizedKeysToInterfaces(rawKeys interface{}) []interface{} { + switch keys := rawKeys.(type) { + case []interface{}: + return keys + case []string: + keysAsInterfaces := make([]interface{}, 0, len(keys)) + + for _, key := range keys { + keysAsInterfaces = append(keysAsInterfaces, key) + } + + return keysAsInterfaces + default: + return []interface{}{} + } +} + +func cloudInitContainsAuthorizedKey(keys []interface{}, expectedKey string) bool { + for _, key := range keys { + existingKey, isString := key.(string) + if !isString { + continue + } + + if strings.TrimSpace(existingKey) == expectedKey { + return true + } + } + + return false +} diff --git a/cmd/docker-machine-driver-pve/driver/cloud_init_test.go b/cmd/docker-machine-driver-pve/driver/cloud_init_test.go new file mode 100644 index 0000000..3c2b63e --- /dev/null +++ b/cmd/docker-machine-driver-pve/driver/cloud_init_test.go @@ -0,0 +1,185 @@ +package driver + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + yaml "gopkg.in/yaml.v3" +) + +func TestGenerateCloudinitUserdata_Default(t *testing.T) { + testDriver := newCloudInitTestDriver(t) + + userdata, err := testDriver.generateCloudinitUserdata() + require.NoError(t, err) + require.True(t, strings.HasPrefix(userdata, "#cloud-config\n")) + + parsed := decodeCloudConfig(t, userdata) + require.Equal(t, testDriver.MachineName, parsed["hostname"]) + require.Equal(t, false, parsed["preserve_hostname"]) + require.Equal(t, true, parsed["create_hostname_file"]) + + user := requireUser(t, parsed, testDriver.SSHUser) + require.Equal(t, true, user["lock_passwd"]) + require.Equal(t, "ALL=(ALL) NOPASSWD:ALL", user["sudo"]) + require.Contains(t, toStringSlice(user["ssh_authorized_keys"]), "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey") +} + +func TestGenerateCloudinitUserdata_WithCloudConfig(t *testing.T) { + testDriver := newCloudInitTestDriver(t) + testDriver.CloudConfig = ` +#cloud-config +package_update: true +users: + - name: ` + testDriver.SSHUser + ` + ssh_authorized_keys: + - ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExistingKey +` + + userdata, err := testDriver.generateCloudinitUserdata() + require.NoError(t, err) + + parsed := decodeCloudConfig(t, userdata) + require.Equal(t, true, parsed["package_update"]) + + user := requireUser(t, parsed, testDriver.SSHUser) + keys := toStringSlice(user["ssh_authorized_keys"]) + require.Contains(t, keys, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExistingKey") + require.Contains(t, keys, "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey") +} + +func TestGenerateCloudinitUserdata_WithUserData(t *testing.T) { + testDriver := newCloudInitTestDriver(t) + testDriver.UserData = ` +#cloud-config +package_update: false +packages: + - qemu-guest-agent +` + + userdata, err := testDriver.generateCloudinitUserdata() + require.NoError(t, err) + + parsed := decodeCloudConfig(t, userdata) + require.Equal(t, false, parsed["package_update"]) + + packages, ok := parsed["packages"].([]interface{}) + require.True(t, ok) + require.Contains(t, packages, "qemu-guest-agent") +} + +func TestGenerateCloudinitUserdata_UserDataPrecedence(t *testing.T) { + testDriver := newCloudInitTestDriver(t) + testDriver.UserData = ` +#cloud-config +package_update: false +` + testDriver.CloudConfig = ` +#cloud-config +package_update: true +` + testDriver.CloudInit = "https://example.com/ignored" + + userdata, err := testDriver.generateCloudinitUserdata() + require.NoError(t, err) + + parsed := decodeCloudConfig(t, userdata) + require.Equal(t, false, parsed["package_update"]) +} + +func TestLoadCloudConfigFromSource_FileAndURL(t *testing.T) { + tempDir := t.TempDir() + filepath := filepath.Join(tempDir, "cloud-config.yaml") + fileContent := "#cloud-config\nruncmd:\n - echo from-file\n" + require.NoError(t, os.WriteFile(filepath, []byte(fileContent), 0o600)) + + fromFile, err := loadCloudConfigFromSource(filepath) + require.NoError(t, err) + require.Equal(t, fileContent, fromFile) + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) { + _, _ = writer.Write([]byte("#cloud-config\nruncmd:\n - echo from-url\n")) + })) + defer server.Close() + + fromURL, err := loadCloudConfigFromSource(server.URL) + require.NoError(t, err) + require.Contains(t, fromURL, "from-url") +} + +func newCloudInitTestDriver(t *testing.T) *Driver { + t.Helper() + + testDriver := NewDriver("cloudinit-test", t.TempDir()) + testDriver.SSHUser = defaultSSHUser + + publicKeyPath := testDriver.GetSSHPublicKeyPath() + require.NoError(t, os.MkdirAll(filepath.Dir(publicKeyPath), 0o700)) + require.NoError( + t, + os.WriteFile(publicKeyPath, []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey\n"), 0o600), + ) + + return testDriver +} + +func decodeCloudConfig(t *testing.T, cloudConfig string) map[string]interface{} { + t.Helper() + + trimmedCloudConfig := strings.TrimPrefix(cloudConfig, "#cloud-config\n") + result := map[string]interface{}{} + require.NoError(t, yaml.Unmarshal([]byte(trimmedCloudConfig), &result)) + + return result +} + +func requireUser(t *testing.T, userdata map[string]interface{}, userName string) map[string]interface{} { + t.Helper() + + usersRaw, ok := userdata["users"] + require.True(t, ok) + + usersList, ok := usersRaw.([]interface{}) + require.True(t, ok) + + for _, userRaw := range usersList { + userMap, userMapOk := userRaw.(map[string]interface{}) + if !userMapOk { + continue + } + + name, nameOk := userMap["name"].(string) + if nameOk && name == userName { + return userMap + } + } + + require.FailNowf(t, "user not found", "failed to find user '%s' in cloud-config users list", userName) + + return nil +} + +func toStringSlice(value interface{}) []string { + switch values := value.(type) { + case []string: + return values + case []interface{}: + result := make([]string, 0, len(values)) + + for _, item := range values { + converted, ok := item.(string) + if ok { + result = append(result, converted) + } + } + + return result + default: + return nil + } +} diff --git a/cmd/docker-machine-driver-pve/driver/config.go b/cmd/docker-machine-driver-pve/driver/config.go index ee0a62e..399d967 100644 --- a/cmd/docker-machine-driver-pve/driver/config.go +++ b/cmd/docker-machine-driver-pve/driver/config.go @@ -3,6 +3,7 @@ package driver import ( "fmt" "net/url" + "os" "strconv" "strings" @@ -22,6 +23,9 @@ const ( flagNetworkInterface = "pve-network-interface" flagSSHUser = "pve-ssh-user" flagSSHPort = "pve-ssh-port" + flagCloudInit = "pve-cloud-init" + flagCloudConfig = "pve-cloud-config" + flagUserData = "pve-user-data" flagProcessorSockets = "pve-processor-sockets" flagProcessorCores = "pve-processor-cores" flagMemory = "pve-memory" @@ -65,6 +69,15 @@ type config struct { // If set, number of processor sockets to configure for the machine. ProcessorSockets *int + // Optional inline cloud-init user-data YAML content. + UserData string + + // Optional cloud-init user-data source (file path or URL). + CloudInit string + + // Optional cloud-config content loaded from file path. + CloudConfig string + // If set, number of processor cores to configure for the machine. ProcessorCores *int @@ -135,6 +148,21 @@ func (d *Driver) GetCreateFlags() []mcnflag.Flag { EnvVar: flagEnvVarFromFlagName(flagSSHPort), Usage: fmt.Sprintf("Port to use when connecting to the machine via SSH, defaults to '%d'", defaultSSHPort), }, + mcnflag.StringFlag{ + Name: flagUserData, + EnvVar: flagEnvVarFromFlagName(flagUserData), + Usage: "Optional inline cloud-init user-data YAML content to merge into user-data", + }, + mcnflag.StringFlag{ + Name: flagCloudInit, + EnvVar: flagEnvVarFromFlagName(flagCloudInit), + Usage: "Optional cloud-init user-data source (filepath or URL)", + }, + mcnflag.StringFlag{ + Name: flagCloudConfig, + EnvVar: flagEnvVarFromFlagName(flagCloudConfig), + Usage: "Optional filepath to a cloud-config YAML file to merge into user-data", + }, mcnflag.StringFlag{ Name: flagProcessorSockets, EnvVar: flagEnvVarFromFlagName(flagProcessorSockets), @@ -170,7 +198,7 @@ func (d *Driver) GetCreateFlags() []mcnflag.Flag { // SetConfigFromFlags implements drivers.Driver. // -//nolint:cyclop,gocyclo +//nolint:cyclop,gocyclo,gocognit func (d *Driver) SetConfigFromFlags(opts drivers.DriverOptions) error { d.URL = opts.String(flagURL) if d.URL == "" { @@ -225,6 +253,19 @@ func (d *Driver) SetConfigFromFlags(opts drivers.DriverOptions) error { return fmt.Errorf("flag '--%s' must be > 0", flagSSHPort) } + d.UserData = strings.TrimSpace(opts.String(flagUserData)) + d.CloudInit = strings.TrimSpace(opts.String(flagCloudInit)) + + d.CloudConfig = strings.TrimSpace(opts.String(flagCloudConfig)) + if d.CloudConfig != "" { + content, err := os.ReadFile(d.CloudConfig) + if err != nil { + return fmt.Errorf("failed to read flag '--%s': %w", flagCloudConfig, err) + } + + d.CloudConfig = string(content) + } + var err error if d.ProcessorSockets, err = parseStringFlagToInt(opts.String(flagProcessorSockets)); err != nil { diff --git a/pkg/pve-node-driver/l10n/en-us.yaml b/pkg/pve-node-driver/l10n/en-us.yaml index 2a15cd3..43bf25e 100644 --- a/pkg/pve-node-driver/l10n/en-us.yaml +++ b/pkg/pve-node-driver/l10n/en-us.yaml @@ -70,3 +70,11 @@ cluster: port: label: Port tooltip: Port to use when connecting to the machine via SSH + cloudInit: + header: Cloud-init + userData: + label: User Data + tooltip: Optional cloud-init user-data YAML to merge with the driver defaults (hostname and SSH user/key) + source: + label: User-data URL + tooltip: Optional cloud-init user-data source (filepath or URL). Ignored when User Data is set. diff --git a/pkg/pve-node-driver/machine-config/pve.vue b/pkg/pve-node-driver/machine-config/pve.vue index f74cd6b..02bd0e7 100644 --- a/pkg/pve-node-driver/machine-config/pve.vue +++ b/pkg/pve-node-driver/machine-config/pve.vue @@ -55,6 +55,9 @@ export default { networkInterface: this.value.networkInterface ?? '', sshUser: this.value.sshUser ? this.value.sshUser : 'service', sshPort: this.value.sshPort ? parseInt(this.value.sshPort) : 22, + cloudInit: this.value.cloudInit ?? '', + userData: this.value.userData ?? '', + cloudConfig: this.value.cloudConfig ?? '', processorSockets: this.value.processorSockets ? parseInt(this.value.processorSockets) : "", processorCores: this.value.processorCores ? parseInt(this.value.processorCores) : "", memory: this.value.memory ? parseInt(this.value.memory) : "", @@ -240,11 +243,15 @@ export default { this.value.networkInterface = this.currentValue.networkInterface; this.value.sshUser = this.currentValue.sshUser; this.value.sshPort = this.currentValue.sshPort.toString(); + this.value.cloudInit = this.currentValue.cloudInit; + this.value.userData = this.currentValue.userData; + this.value.cloudConfig = this.currentValue.cloudConfig; this.value.processorSockets = this.currentValue.processorSockets.toString(); this.value.processorCores = this.currentValue.processorCores.toString(); this.value.memory = this.currentValue.memory.toString(); this.value.memoryBalloon = this.currentValue.memoryBalloon.toString(); this.value.fullClone = this.currentValue.fullClone; + this.value.tags = this.currentValue.tags; this.$emit('validationChanged', true); }, @@ -616,6 +623,45 @@ export default { +