Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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* <sup>1</sup> | If set, amount of memory in MiB to configure for the machine. |
Expand Down
219 changes: 205 additions & 14 deletions cmd/docker-machine-driver-pve/driver/cloud-init.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,23 @@ import (
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"

"github.com/luthermonson/go-proxmox"
"github.com/rancher/machine/libmachine/log"
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)
Expand Down Expand Up @@ -105,26 +114,208 @@ 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)
}

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
}
Loading