125 lines
4.4 KiB
Go
125 lines
4.4 KiB
Go
package kvm2
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"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 is the vendor-data payload that gets qemu-guest-agent
|
|
// running so the provider can read the VM's address through libvirt. It is
|
|
// a shell script and not a cloud-config on purpose: cloud-init merges a
|
|
// vendor-data cloud-config under the user-data with mergers that keep the
|
|
// user-data's value for every top-level key and never append lists, so a
|
|
// Proxy with its own packages: would silently lose the agent. Scripts
|
|
// bypass the merge.
|
|
const guestAgentScript = `#!/bin/sh
|
|
# Added by egress-proxies-operator: installs and starts qemu-guest-agent so
|
|
# the operator can learn this VM's IP address through libvirt.
|
|
# To leave this out, set installGuestAgent: false in the provider's config.
|
|
set -e
|
|
if command -v qemu-ga >/dev/null 2>&1 || [ -x /usr/sbin/qemu-ga ]; then
|
|
echo "egress-proxies-operator: qemu-guest-agent already installed"
|
|
elif command -v apt-get >/dev/null 2>&1; then
|
|
export DEBIAN_FRONTEND=noninteractive
|
|
apt-get update
|
|
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 "egress-proxies-operator: no supported package manager found; install qemu-guest-agent by hand" >&2
|
|
exit 1
|
|
fi
|
|
if command -v systemctl >/dev/null 2>&1; then
|
|
systemctl enable --now qemu-guest-agent
|
|
else
|
|
rc-update add qemu-guest-agent default
|
|
rc-service qemu-guest-agent start
|
|
fi
|
|
`
|
|
|
|
// buildSeedISO writes a NoCloud seed to w: user-data byte for byte,
|
|
// meta-data naming the instance, and vendor-data (an empty file when there
|
|
// is none, so cloud-init does not look elsewhere). iso9660.NewWriter stages
|
|
// the files in os.MkdirTemp("", ""), that is under os.TempDir(): $TMPDIR
|
|
// when set, else /tmp, the manager's 64Mi emptyDir. The staging dir is
|
|
// removed before returning. Nothing about the payload is logged here.
|
|
func buildSeedISO(name, userData, vendorData string, w io.Writer) (err error) {
|
|
if name == "" {
|
|
return permanentf("seed ISO: instance name is empty")
|
|
}
|
|
iw, err := iso9660.NewWriter()
|
|
if err != nil {
|
|
return fmt.Errorf("creating seed ISO staging dir: %w", err)
|
|
}
|
|
defer func() {
|
|
if cerr := iw.Cleanup(); cerr != nil {
|
|
err = errors.Join(err, fmt.Errorf("removing seed ISO staging dir: %w", cerr))
|
|
}
|
|
}()
|
|
metaData := "instance-id: " + name + "\nlocal-hostname: " + name + "\n"
|
|
files := []struct{ name, content string }{
|
|
{"user-data", userData},
|
|
{"meta-data", metaData},
|
|
{"vendor-data", vendorData},
|
|
}
|
|
for _, f := range files {
|
|
if err := iw.AddFile(strings.NewReader(f.content), f.name); err != nil {
|
|
return fmt.Errorf("adding %s to seed ISO: %w", f.name, err)
|
|
}
|
|
}
|
|
if err := iw.WriteTo(w, seedLabel); err != nil {
|
|
return fmt.Errorf("writing seed ISO: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// buildVendorData composes the seed's vendor-data from the configured
|
|
// document and the guest agent script. With both, they travel as separate
|
|
// parts of a MIME multipart, so cloud-init handles each by its own type; the
|
|
// MIME-Version header comes first because cloud-init detects multipart by
|
|
// finding "mime-version:" within the first 4096 bytes.
|
|
func buildVendorData(configured string, installAgent bool) (string, error) {
|
|
switch {
|
|
case !installAgent:
|
|
return configured, nil
|
|
case strings.TrimSpace(configured) == "":
|
|
return guestAgentScript, nil
|
|
}
|
|
var buf bytes.Buffer
|
|
mw := multipart.NewWriter(&buf)
|
|
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=%q\r\n\r\n", mw.Boundary())
|
|
parts := []struct{ contentType, body string }{
|
|
{"text/plain", configured},
|
|
{"text/x-shellscript", guestAgentScript},
|
|
}
|
|
for _, p := range parts {
|
|
pw, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {p.contentType}})
|
|
if err != nil {
|
|
return "", fmt.Errorf("creating vendor-data part %s: %w", p.contentType, err)
|
|
}
|
|
if _, err := io.WriteString(pw, p.body); err != nil {
|
|
return "", fmt.Errorf("writing vendor-data part %s: %w", p.contentType, err)
|
|
}
|
|
}
|
|
if err := mw.Close(); err != nil {
|
|
return "", fmt.Errorf("closing vendor-data multipart: %w", err)
|
|
}
|
|
return buf.String(), nil
|
|
}
|