Merging the bootstrap into each Proxy's cloud-config was the wrong mechanism twice over. Detection only matched a literal #cloud-config first line, so a jinja template or a MIME archive still fell back to vendor-data, where cloud-init's default mergers drop it — the exact failure the merge existed to prevent. And the YAML round-trip re-typed unquoted scalars: version: 1.10 reached the guest as 1.1, comments were dropped and keys reordered. Neither is worth patching, because vendor-data delivered as a shell script bypasses the merger entirely: cloud-init hands vendor-data to its own handler chain, writes scripts to a separate directory and runs them from cc_scripts_vendor, after the user's packages and before the user's runcmd. The merge could not have been rescued anyway — merge_how in user-data governs only the first source, and merge_how in vendor-data is discarded before the document is stored. The Proxy's cloud-init now reaches the seed byte for byte in any format. installGuestAgent (default true) turns the script off; a configured vendorData travels beside it as a MIME multipart, declared text/plain so cloud-init re-sniffs it and this code never classifies user content. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
134 lines
5.1 KiB
Go
134 lines
5.1 KiB
Go
package kvm
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/textproto"
|
|
"strings"
|
|
|
|
"github.com/kdomanski/iso9660"
|
|
)
|
|
|
|
// seedLabel is the volume label cloud-init's NoCloud datasource looks for.
|
|
const seedLabel = "cidata"
|
|
|
|
// guestAgentScript installs and starts the QEMU guest agent, which is how
|
|
// the provider learns a VM's address before the host's ARP table has seen
|
|
// any traffic from it.
|
|
//
|
|
// It ships as vendor-data, and as a *script* rather than a cloud-config,
|
|
// because that is the only shape cloud-init cannot drop. Vendor-data
|
|
// cloud-config is merged under the Proxy's own user-data with mergers that
|
|
// keep the user-data's value for every key both documents define and never
|
|
// append lists, so a Proxy carrying its own packages: silently lost the
|
|
// agent. The strategy cannot be overridden from either document: merge_how
|
|
// in user-data governs only the first source, and merge_how in vendor-data
|
|
// is consumed and discarded before the document is stored. A vendor-data
|
|
// script bypasses all of it — cloud-init gives vendor-data its own handler
|
|
// chain, writes scripts to a separate directory and runs them from
|
|
// cc_scripts_vendor, after the user's packages are installed and before the
|
|
// user's runcmd.
|
|
const guestAgentScript = `#!/bin/sh
|
|
# Added by egress-proxies-operator so the hypervisor can report this VM's
|
|
# address. Set installGuestAgent: false on the provider to leave it out.
|
|
set -e
|
|
if ! command -v qemu-ga >/dev/null 2>&1 && [ ! -x /usr/sbin/qemu-ga ]; then
|
|
if command -v apt-get >/dev/null 2>&1; then
|
|
apt-get update
|
|
DEBIAN_FRONTEND=noninteractive apt-get install -y qemu-guest-agent
|
|
elif command -v dnf >/dev/null 2>&1; then
|
|
dnf install -y qemu-guest-agent
|
|
elif command -v yum >/dev/null 2>&1; then
|
|
yum install -y qemu-guest-agent
|
|
elif command -v zypper >/dev/null 2>&1; then
|
|
zypper --non-interactive install qemu-guest-agent
|
|
elif command -v apk >/dev/null 2>&1; then
|
|
apk add qemu-guest-agent
|
|
else
|
|
echo "no known package manager; install qemu-guest-agent in the image" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
if command -v systemctl >/dev/null 2>&1; then
|
|
systemctl enable --now qemu-guest-agent
|
|
elif command -v rc-update >/dev/null 2>&1; then
|
|
rc-update add qemu-guest-agent default
|
|
rc-service qemu-guest-agent start
|
|
fi
|
|
`
|
|
|
|
// buildVendorData renders the seed's vendor-data. With nothing configured
|
|
// it is the guest-agent script alone; with both, a MIME multipart carrying
|
|
// the configured document and the script as separate parts, since
|
|
// vendor-data is a single file and the two must not be concatenated.
|
|
func buildVendorData(configured string, installAgent bool) (string, error) {
|
|
switch {
|
|
case !installAgent:
|
|
return configured, nil
|
|
case strings.TrimSpace(configured) == "":
|
|
return guestAgentScript, nil
|
|
default:
|
|
return multipartVendorData(configured, guestAgentScript)
|
|
}
|
|
}
|
|
|
|
// multipartVendorData packs several documents into one vendor-data blob.
|
|
// cloud-init decides a payload is multipart by finding "mime-version:"
|
|
// within its first 4096 bytes, so those headers lead. The configured
|
|
// document goes in as text/plain on purpose: cloud-init re-sniffs that type
|
|
// from the content's own header line, which keeps this code out of the
|
|
// business of classifying what the user wrote.
|
|
func multipartVendorData(configured, script string) (string, error) {
|
|
var body bytes.Buffer
|
|
w := multipart.NewWriter(&body)
|
|
parts := []struct{ contentType, content string }{
|
|
{"text/plain", configured},
|
|
{"text/x-shellscript", script},
|
|
}
|
|
for _, p := range parts {
|
|
part, err := w.CreatePart(textproto.MIMEHeader{"Content-Type": {p.contentType}})
|
|
if err != nil {
|
|
return "", fmt.Errorf("building vendor-data part: %w", err)
|
|
}
|
|
if _, err := io.WriteString(part, p.content); err != nil {
|
|
return "", fmt.Errorf("writing vendor-data part: %w", err)
|
|
}
|
|
}
|
|
if err := w.Close(); err != nil {
|
|
return "", fmt.Errorf("closing vendor-data: %w", err)
|
|
}
|
|
return fmt.Sprintf("MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=%q\r\n\r\n%s",
|
|
w.Boundary(), body.String()), nil
|
|
}
|
|
|
|
// buildSeedISO renders a NoCloud seed: user-data and vendor-data verbatim,
|
|
// and a meta-data whose instance-id is the deterministic VM name, so
|
|
// cloud-init never mistakes a reboot for a new instance. The ISO's volume
|
|
// descriptor carries the build time, so two builds differ in a few
|
|
// timestamp bytes; the files and label are identical.
|
|
func buildSeedISO(name, userData, vendorData string) ([]byte, error) {
|
|
w, err := iso9660.NewWriter()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("creating iso writer: %w", err)
|
|
}
|
|
defer w.Cleanup() //nolint:errcheck // temp-dir cleanup; nothing to do on failure
|
|
|
|
files := []struct{ name, body string }{
|
|
{"user-data", userData},
|
|
{"meta-data", fmt.Sprintf("instance-id: %s\nlocal-hostname: %s\n", name, name)},
|
|
{"vendor-data", vendorData},
|
|
}
|
|
for _, f := range files {
|
|
if err := w.AddFile(strings.NewReader(f.body), f.name); err != nil {
|
|
return nil, fmt.Errorf("adding %s to seed iso: %w", f.name, err)
|
|
}
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := w.WriteTo(&buf, seedLabel); err != nil {
|
|
return nil, fmt.Errorf("writing seed iso: %w", err)
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|