a73x

internal/cloudinit/cloudinit.go

Ref:   Size: 5.8 KiB   History

// Package cloudinit merges eitri's structured VM inputs into user-supplied
// cloud-init user-data. It is a neutral leaf: the control plane (server/api)
// uses it at create time so a bad merge fails fast as a 400, and it holds no
// dependency on either plane.
//
// Today it does one thing — install an SSH public key into whatever user-data
// the caller supplied — but it exists as a package because "eitri owns a bit of
// your cloud-init" is a real responsibility that deserves one home and real
// tests, not a string hack buried in a handler.
package cloudinit

import (
	"errors"
	"fmt"
	"io"
	"strings"

	"gopkg.in/yaml.v3"
)

// errNotCloudConfig reports user-data that is not a `#cloud-config` document —
// a shell script, a jinja-templated config, MIME multipart, etc. There is no
// ssh_authorized_keys to merge into, so the caller must reject rather than
// silently drop the key (the exact bug this package removes).
var errNotCloudConfig = errors.New("user-data is not #cloud-config; cannot merge an ssh key into it")

// mergeSSHKey returns userData with key added to the top-level
// ssh_authorized_keys list — cloud-init's canonical way to add a key to the
// image's default user, robust whether or not the doc defines a `users:` block.
// (An operator who REPLACES the default user with their own named user should
// put the key in that users block themselves.)
//
// The edit is done IN PLACE on the YAML node tree, so every other value,
// comment, and key order in the user's document is preserved byte-for-byte —
// only the ssh_authorized_keys sequence is touched (created, or appended to
// with de-duplication; a scalar value is normalized to a sequence first). The
// `#cloud-config` header is re-emitted.
//
// Multi-document YAML (a second `---`) is rejected: cloud-init rejects it too,
// and accepting-then-silently-dropping the later documents would mask an error
// the guest would otherwise raise.
//
// key must already be validated single-line by the caller. Even so, the key is
// added as a YAML scalar node (not string-interpolated), so a value with
// YAML-significant characters — or a bypassed multi-line value — is emitted as
// a quoted/block scalar and cannot inject structure.
func mergeSSHKey(userData, key string) (string, error) {
	if !isCloudConfig(userData) {
		return "", errNotCloudConfig
	}

	dec := yaml.NewDecoder(strings.NewReader(userData))
	var doc yaml.Node
	switch err := dec.Decode(&doc); {
	case errors.Is(err, io.EOF):
		// A #cloud-config with only comments / no body: synthesize an empty
		// mapping document to hang the key on.
		doc = yaml.Node{Kind: yaml.DocumentNode, Content: []*yaml.Node{{Kind: yaml.MappingNode}}}
	case err != nil:
		return "", fmt.Errorf("parse cloud-config: %w", err)
	default:
		// Reject a second document (cloud-init rejects multi-doc cloud-config).
		var extra yaml.Node
		if err := dec.Decode(&extra); err == nil {
			return "", errors.New("multi-document cloud-config is not supported")
		} else if !errors.Is(err, io.EOF) {
			return "", fmt.Errorf("parse cloud-config: %w", err)
		}
	}

	if len(doc.Content) == 0 {
		doc.Content = []*yaml.Node{{Kind: yaml.MappingNode}}
	}
	root := doc.Content[0]
	if root.Kind != yaml.MappingNode {
		return "", fmt.Errorf("cloud-config root must be a mapping, not %s", kindName(root.Kind))
	}
	if err := appendAuthorizedKey(root, key); err != nil {
		return "", err
	}

	out, err := yaml.Marshal(&doc)
	if err != nil {
		return "", fmt.Errorf("marshal cloud-config: %w", err)
	}
	return "#cloud-config\n" + string(out), nil
}

// appendAuthorizedKey adds key to the mapping's ssh_authorized_keys sequence,
// creating the entry if absent, normalizing a scalar to a sequence, and
// de-duplicating. Every other node in the mapping is left untouched.
func appendAuthorizedKey(root *yaml.Node, key string) error {
	keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}
	for i := 0; i+1 < len(root.Content); i += 2 {
		if root.Content[i].Value != "ssh_authorized_keys" {
			continue
		}
		val := root.Content[i+1]
		switch val.Kind {
		case yaml.SequenceNode:
			for _, e := range val.Content {
				if e.Value == key {
					return nil // already present
				}
			}
			val.Content = append(val.Content, keyNode)
		case yaml.ScalarNode:
			if val.Value == key {
				return nil
			}
			existing := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: val.Value}
			*val = yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{existing, keyNode}}
		default:
			return fmt.Errorf("ssh_authorized_keys must be a string or list, not %s", kindName(val.Kind))
		}
		return nil
	}
	// Absent: append `ssh_authorized_keys: [key]`.
	root.Content = append(root.Content,
		&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "ssh_authorized_keys"},
		&yaml.Node{Kind: yaml.SequenceNode, Content: []*yaml.Node{keyNode}})
	return nil
}

// isCloudConfig reports whether s is a cloud-config document: its first
// non-blank line's first whitespace-delimited token must be exactly
// `#cloud-config` (so a trailing comment like `#cloud-config # my vm` still
// counts, but `#cloud-config-archive` — a different format — does not). A
// `## template: jinja` preamble or any other first line is rejected.
func isCloudConfig(s string) bool {
	return firstMarker(s) == "#cloud-config"
}

// firstMarker returns the first whitespace-delimited token of the first
// non-blank line — cloud-init's per-format "magic" marker.
func firstMarker(s string) string {
	if f := strings.Fields(firstNonBlankLine(s)); len(f) > 0 {
		return f[0]
	}
	return ""
}

func kindName(k yaml.Kind) string {
	switch k {
	case yaml.DocumentNode:
		return "document"
	case yaml.SequenceNode:
		return "list"
	case yaml.MappingNode:
		return "mapping"
	case yaml.ScalarNode:
		return "scalar"
	case yaml.AliasNode:
		return "alias"
	default:
		return "unknown"
	}
}