// Package nocloud builds cloud-init NoCloud seed ISOs in process, shared by // the providers that attach a seed as a CD-ROM (kvm2, proxmox). It never // parses or logs the payload. package nocloud 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" // ErrEmptyName is returned by BuildSeedISO for an empty instance name. var ErrEmptyName = errors.New("seed ISO: instance name is empty") // GuestAgentScript is the vendor-data payload that gets qemu-guest-agent // running so the provider can read the VM's address from the hypervisor. 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 from the hypervisor. # 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 ErrEmptyName } 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 }