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 }