Install the guest agent from a vendor-data script instead of editing the user's cloud-config
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>
This commit is contained in:
@@ -30,8 +30,9 @@ providers:
|
|||||||
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
|
knownHostsFile: /etc/proxy-operator/kvm/known_hosts
|
||||||
# insecureIgnoreHostKey: true # skip host-key checks (not recommended)
|
# insecureIgnoreHostKey: true # skip host-key checks (not recommended)
|
||||||
# diskSizeGb: 10 # per-VM overlay; cloud-init grows the root fs into it
|
# diskSizeGb: 10 # per-VM overlay; cloud-init grows the root fs into it
|
||||||
# vendorData: | # extra vendor-data; the qemu-guest-agent bootstrap is merged
|
# installGuestAgent: false # skip the vendor-data script that installs qemu-guest-agent
|
||||||
# #cloud-config # into each Proxy's cloud-config, not configured here
|
# vendorData: | # extra vendor-data, carried beside the agent script as a
|
||||||
|
# #cloud-config # MIME multipart. The Proxy's own cloud-init is never rewritten.
|
||||||
# ...
|
# ...
|
||||||
images:
|
images:
|
||||||
# placement.image names a key here (or is an https:// URL directly).
|
# placement.image names a key here (or is an https:// URL directly).
|
||||||
|
|||||||
+33
-24
@@ -27,13 +27,12 @@ Per host, once:
|
|||||||
`edk2-ovmf` on Fedora/RHEL. VMs boot UEFI by default (libvirt picks
|
`edk2-ovmf` on Fedora/RHEL. VMs boot UEFI by default (libvirt picks
|
||||||
the OVMF binary itself); a domain start fails with "no firmware
|
the OVMF binary itself); a domain start fails with "no firmware
|
||||||
found" without it. Only `firmware: bios` images skip this.
|
found" without it. Only `firmware: bios` images skip this.
|
||||||
6. Optionally, **outbound internet from the VMs**: the operator adds
|
6. Optionally, **outbound internet from the VMs**: a vendor-data script
|
||||||
`qemu-guest-agent` to every Proxy's cloud-config (`packages` plus a
|
installs `qemu-guest-agent` from the distro mirrors on first boot.
|
||||||
`runcmd` that enables it), so it is installed from the distro mirrors
|
Without it the VM still works — the operator falls back to the host's
|
||||||
on first boot. Without it the VM still works — the operator falls back
|
ARP table to find the IP, which fills in once the guest sends traffic —
|
||||||
to the host's ARP table to find the IP, which fills in once the guest
|
but the ARP table only sees traffic to the host itself, so on a plain
|
||||||
sends traffic — but note the ARP table only sees traffic to the host
|
bridge the agent is in practice required.
|
||||||
itself, so on a plain bridge the agent is in practice required.
|
|
||||||
|
|
||||||
Check from the operator's point of view before wiring it in:
|
Check from the operator's point of view before wiring it in:
|
||||||
|
|
||||||
@@ -136,7 +135,8 @@ ssh virt@nas1.home.lan ip link show br0
|
|||||||
| `knownHostsFile` | unless `insecureIgnoreHostKey` | | OpenSSH `known_hosts` to verify host keys against |
|
| `knownHostsFile` | unless `insecureIgnoreHostKey` | | OpenSSH `known_hosts` to verify host keys against |
|
||||||
| `insecureIgnoreHostKey` | | `false` | skip host-key verification entirely |
|
| `insecureIgnoreHostKey` | | `false` | skip host-key verification entirely |
|
||||||
| `diskSizeGb` | | `10` | capacity of each VM's overlay disk; cloud-init's growpart expands the root fs into it |
|
| `diskSizeGb` | | `10` | capacity of each VM's overlay disk; cloud-init's growpart expands the root fs into it |
|
||||||
| `vendorData` | | | extra cloud-init vendor-data placed on every seed next to the Proxy's user-data; cloud-init drops any top-level key here that the user-data also defines. The guest-agent bootstrap is not configured here, see *What lands on the host* |
|
| `vendorData` | | | extra cloud-init vendor-data placed on every seed next to the Proxy's user-data. Beware that cloud-init drops any top-level key of a vendor-data **cloud-config** that the user-data also defines. Shipped as one part of a MIME multipart alongside the agent script when `installGuestAgent` is on |
|
||||||
|
| `installGuestAgent` | | `true` | add the vendor-data script that installs and starts `qemu-guest-agent`. Turn off for images that ship it already or cannot reach a package mirror; IP discovery then depends on the host's ARP table |
|
||||||
| `images.<key>` | | | the key names the image in `placement.image`. Letters, digits, dots, dashes and underscores only — it becomes the cached volume name `base-<key>.<format>` |
|
| `images.<key>` | | | the key names the image in `placement.image`. Letters, digits, dots, dashes and underscores only — it becomes the cached volume name `base-<key>.<format>` |
|
||||||
| `images.<key>.url` | yes per entry | | http(s) URL of a cloud image (qcow2 or raw) |
|
| `images.<key>.url` | yes per entry | | http(s) URL of a cloud image (qcow2 or raw) |
|
||||||
| `images.<key>.sha256` | | | expected digest; verified after download, a change triggers a re-download |
|
| `images.<key>.sha256` | | | expected digest; verified after download, a change triggers a re-download |
|
||||||
@@ -182,20 +182,27 @@ console, the guest-agent channel, a virtio RNG and no graphics. Its
|
|||||||
creation time) — that is how orphan GC recognises its own VMs, since
|
creation time) — that is how orphan GC recognises its own VMs, since
|
||||||
libvirt has no labels.
|
libvirt has no labels.
|
||||||
|
|
||||||
The seed's `user-data` is the Proxy's `cloudInit` with the guest-agent
|
The seed's `user-data` is the Proxy's `cloudInit`, **byte for byte**. Any
|
||||||
bootstrap **merged into the document**: `qemu-guest-agent` appended to
|
format cloud-init accepts works, including a Jinja template, a MIME
|
||||||
`packages` and `[systemctl, enable, --now, qemu-guest-agent]` to `runcmd`
|
archive, a shell script or a gzipped payload; the operator never parses or
|
||||||
(neither duplicated if already there; other keys untouched, though the
|
re-renders it.
|
||||||
YAML is re-rendered). It is not shipped as vendor-data because cloud-init
|
|
||||||
merges vendor-data under user-data with its default mergers, which keep
|
The guest agent rides along in `vendor-data`, as a **shell script** rather
|
||||||
the user-data's value for any key both define and never append lists — a
|
than a cloud-config. That is the only shape cloud-init cannot drop:
|
||||||
cloud-config with its own `packages:` would silently lose the agent and
|
vendor-data cloud-config is merged under the user's own document with
|
||||||
the Proxy would sit in `Provisioning` forever. Only when the user-data is
|
mergers that keep the user-data's value for every key both define and never
|
||||||
not a cloud-config document (a shell script, `#include`, MIME archive)
|
append lists, so a Proxy carrying its own `packages:` silently lost the
|
||||||
does the bootstrap go to `vendor-data`, and then only if `vendorData` is
|
agent and sat in `Provisioning` forever. The strategy cannot be overridden
|
||||||
not configured. A cloud-config that does not parse as a YAML mapping, or
|
from either document — `merge_how` in user-data governs only the first
|
||||||
whose `packages`/`runcmd` is not a list, fails the Proxy permanently
|
source, and `merge_how` in vendor-data is consumed and discarded before the
|
||||||
rather than booting a VM without its config.
|
document is stored. A vendor-data script skips 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`. The script is idempotent, picks
|
||||||
|
whichever of `apt-get`, `dnf`, `yum`, `zypper` or `apk` the image has, and
|
||||||
|
logs into `/var/log/cloud-init-output.log`. Set `installGuestAgent: false`
|
||||||
|
to leave it out. When `vendorData` is also configured, the two travel as
|
||||||
|
separate parts of a MIME multipart vendor-data.
|
||||||
|
|
||||||
## The image cache
|
## The image cache
|
||||||
|
|
||||||
@@ -287,8 +294,10 @@ The provider logs through controller-runtime's logger, named `kvm`, with
|
|||||||
bridge with no DHCP never gets an address and stays `Provisioning`
|
bridge with no DHCP never gets an address and stays `Provisioning`
|
||||||
forever; check `virsh console`. The same symptom with a booted, addressed
|
forever; check `virsh console`. The same symptom with a booted, addressed
|
||||||
VM means the guest agent never came up: check `systemctl status
|
VM means the guest agent never came up: check `systemctl status
|
||||||
qemu-guest-agent` and `cloud-init status --long` in the guest (a VM
|
qemu-guest-agent`, `cloud-init status --long` and the vendor script's
|
||||||
without outbound internet cannot install it).
|
output in `/var/log/cloud-init-output.log` (a VM without outbound
|
||||||
|
internet cannot install it). A user-data that disables vendor-data
|
||||||
|
(`vendor_data: {enabled: false}`) also removes the agent, by design.
|
||||||
- **Debian 13 cloud images hang in GRUB under BIOS on a headless VM**
|
- **Debian 13 cloud images hang in GRUB under BIOS on a headless VM**
|
||||||
([Debian bug #1111240](https://bugs.debian.org/1111240), open, in
|
([Debian bug #1111240](https://bugs.debian.org/1111240), open, in
|
||||||
`grub2`): trixie's image ships `terminal_output gfxterm serial` in its
|
`grub2`): trixie's image ships `terminal_output gfxterm serial` in its
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Plan: [docs/plans/2026-09-10-2222-kvm-review-fixes.md](../plans/2026-09-10-2222-
|
|||||||
- [x] Step 1 — Unwedge deletion (finding 1)
|
- [x] Step 1 — Unwedge deletion (finding 1)
|
||||||
- [x] Step 2 — Validate names that become identifiers (finding 2)
|
- [x] Step 2 — Validate names that become identifiers (finding 2)
|
||||||
- [x] Step 3 — Read the SSH material lazily (finding 3)
|
- [x] Step 3 — Read the SSH material lazily (finding 3)
|
||||||
- [ ] Step 4 — Guest agent via vendor-data script (findings 4 and 5)
|
- [x] Step 4 — Guest agent via vendor-data script (findings 4 and 5)
|
||||||
- [ ] Step 5 — Validate, record, push
|
- [ ] Step 5 — Validate, record, push
|
||||||
|
|
||||||
## Step 0 — plan committed
|
## Step 0 — plan committed
|
||||||
@@ -98,6 +98,70 @@ a rotated Secret needs a manager restart. `TestNew_badKeyPathFailsFast` was
|
|||||||
inverted rather than deleted, so the old contract stays visible in the diff
|
inverted rather than deleted, so the old contract stays visible in the diff
|
||||||
next to the new one.
|
next to the new one.
|
||||||
|
|
||||||
|
## Step 4 — Guest agent via vendor-data script
|
||||||
|
|
||||||
|
The review filed two findings against `withGuestAgent` — detection too
|
||||||
|
narrow, and YAML re-typing on the round-trip. Reading cloud-init 26.1's
|
||||||
|
sources while planning turned both into one finding: the operator should
|
||||||
|
not be editing the user's document at all.
|
||||||
|
|
||||||
|
Vendor-data gets its own handler chain (`stages.py:_default_vendordata_handlers`),
|
||||||
|
with separate script and cloud-config paths. A vendor-data **script** is
|
||||||
|
therefore written to the vendor script directory and run by
|
||||||
|
`cc_scripts_vendor`, never touching the config merger that drops colliding
|
||||||
|
keys. On the final-stage module list it lands after
|
||||||
|
`package_update_upgrade_install` and before the user's `runcmd`, so the
|
||||||
|
agent now comes up *earlier* than the old `runcmd` append did.
|
||||||
|
|
||||||
|
That also closed the door the previous approach had left ajar. The merge
|
||||||
|
cannot be rescued from inside either document: `merge_how` in user-data
|
||||||
|
governs only the first source, and `merge_how` in vendor-data is popped by
|
||||||
|
`dict_extract_mergers` and discarded before the document is written. There
|
||||||
|
was no fix that kept the merge.
|
||||||
|
|
||||||
|
`withGuestAgent`, `isCloudConfig` and `appendUnique` are gone, along with
|
||||||
|
seed.go's `sigs.k8s.io/yaml` dependency. `installGuestAgent` (default true)
|
||||||
|
turns the script off. When `vendorData` is also configured the two ship as
|
||||||
|
a MIME multipart, with the user's document declared `text/plain` — a type
|
||||||
|
cloud-init re-sniffs from the content's own header line, so this code never
|
||||||
|
classifies what the user wrote.
|
||||||
|
|
||||||
|
Before deleting the old path, its damage was captured rather than taken on
|
||||||
|
trust. Round-tripping a plausible cloud-config through it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# temporary test in the package, deleted afterwards
|
||||||
|
go test -run TestZZProbeRetyping -v ./internal/provider/kvm/
|
||||||
|
# in: version: 1.10
|
||||||
|
# out: version: 1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
`1.10` arriving as `1.1` is worse than the reviewer's `passwd` example,
|
||||||
|
since nothing about it looks wrong in the seed. Comments were dropped and
|
||||||
|
keys reordered on the same pass.
|
||||||
|
|
||||||
|
The generated vendor-data was then checked against the cloud-init actually
|
||||||
|
installed on this machine, rather than against the docs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -c 'from cloudinit import user_data, handlers; ...'
|
||||||
|
# vendor-script: multipart=False sniffed=text/x-shellscript
|
||||||
|
# vendor-multipart: multipart=True part 1 text/plain -> text/cloud-config
|
||||||
|
# part 2 text/x-shellscript
|
||||||
|
```
|
||||||
|
|
||||||
|
and the script itself through `sh -n`.
|
||||||
|
|
||||||
|
Worth noting: the script has to choose a package manager where cloud-init's
|
||||||
|
`packages:` key was distro-agnostic. It covers apt, dnf, yum, zypper and
|
||||||
|
apk, and both systemd and OpenRC for the enable step, but an image outside
|
||||||
|
that set now needs `installGuestAgent: false` and the agent baked in. The
|
||||||
|
seed's user-data is byte-identical to the Proxy's `cloudInit` from here on,
|
||||||
|
which is what the new `TestBuildSeedISO_userDataIsNeverRewritten` pins
|
||||||
|
across seven formats. One consequence worth remembering: a user-data that
|
||||||
|
sets `vendor_data: {enabled: false}` disables the agent, and cloud-init
|
||||||
|
documents that as intended behaviour.
|
||||||
|
|
||||||
Worth noting: the review filed this as a KVM finding, but the fix is mostly
|
Worth noting: the review filed this as a KVM finding, but the fix is mostly
|
||||||
in the controller, because KVM is the only provider that resolves a
|
in the controller, because KVM is the only provider that resolves a
|
||||||
providerID segment against operator config at all. GCP and Kubernetes hand
|
providerID segment against operator config at all. GCP and Kubernetes hand
|
||||||
|
|||||||
@@ -23,10 +23,17 @@ type KVMConfig struct {
|
|||||||
// guest's cloud-init growpart expands the root filesystem into it.
|
// guest's cloud-init growpart expands the root filesystem into it.
|
||||||
DiskSizeGB int64 `json:"diskSizeGb,omitempty"`
|
DiskSizeGB int64 `json:"diskSizeGb,omitempty"`
|
||||||
// VendorData is optional cloud-init vendor-data placed on every seed next
|
// VendorData is optional cloud-init vendor-data placed on every seed next
|
||||||
// to the Proxy's own user-data. cloud-init drops any top-level key here
|
// to the Proxy's own user-data. Note that cloud-init drops any top-level
|
||||||
// that the user-data also defines. The qemu-guest-agent bootstrap does
|
// key of a vendor-data cloud-config that the user-data also defines.
|
||||||
// not live here: the provider merges it into each Proxy's cloud-config.
|
// When InstallGuestAgent is on, this document and the agent script are
|
||||||
|
// carried as separate parts of a MIME multipart vendor-data.
|
||||||
VendorData string `json:"vendorData,omitempty"`
|
VendorData string `json:"vendorData,omitempty"`
|
||||||
|
// InstallGuestAgent adds a vendor-data script that installs and starts
|
||||||
|
// qemu-guest-agent, which is how the provider learns a VM's address
|
||||||
|
// before the host's ARP table has seen traffic from it. Default true.
|
||||||
|
// Turn it off for images that already ship the agent, or that cannot
|
||||||
|
// reach a package mirror; discovery then depends on ARP alone.
|
||||||
|
InstallGuestAgent *bool `json:"installGuestAgent,omitempty"`
|
||||||
// Images are named base images; placement.image names a key here (or is
|
// Images are named base images; placement.image names a key here (or is
|
||||||
// an http(s) URL directly). The provider downloads each image into a
|
// an http(s) URL directly). The provider downloads each image into a
|
||||||
// host's storage pool on first use and caches it there.
|
// host's storage pool on first use and caches it there.
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ func (p *Provider) writeSeed(log logr.Logger, api libvirtAPI, pool libvirt.Stora
|
|||||||
if err := deleteIfExists(api, pool, seedName); err != nil {
|
if err := deleteIfExists(api, pool, seedName); err != nil {
|
||||||
return libvirt.StorageVol{}, err
|
return libvirt.StorageVol{}, err
|
||||||
}
|
}
|
||||||
userData, vendorData, err := withGuestAgent(userData, p.cfg.VendorData)
|
vendorData, err := buildVendorData(p.cfg.VendorData, p.cfg.InstallGuestAgent == nil || *p.cfg.InstallGuestAgent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return libvirt.StorageVol{}, fmt.Errorf("%w: %v", provider.ErrPermanent, err)
|
return libvirt.StorageVol{}, fmt.Errorf("%w: %v", provider.ErrPermanent, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,8 +188,11 @@ func assertCreated(t *testing.T, api *fakeAPI) {
|
|||||||
t.Errorf("seed = format %s, %d bytes, capacity %d; want raw with capacity == uploaded size", seed.format, len(seed.data), seed.capacity)
|
t.Errorf("seed = format %s, %d bytes, capacity %d; want raw with capacity == uploaded size", seed.format, len(seed.data), seed.capacity)
|
||||||
}
|
}
|
||||||
_, files := readSeed(t, seed.data)
|
_, files := readSeed(t, seed.data)
|
||||||
if ud := files["user-data"]; !strings.Contains(ud, "squid") || !strings.Contains(ud, guestAgentPackage) {
|
if files["user-data"] != testCloudInit {
|
||||||
t.Errorf("seed user-data = %q, want the Proxy's cloud-config with the guest agent merged in", ud)
|
t.Errorf("seed user-data = %q, want the Proxy's cloud-init verbatim", files["user-data"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(files["vendor-data"], "qemu-guest-agent") {
|
||||||
|
t.Errorf("seed vendor-data = %q, want the guest-agent script", files["vendor-data"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,110 +3,104 @@ package kvm
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
|
"net/textproto"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/kdomanski/iso9660"
|
"github.com/kdomanski/iso9660"
|
||||||
"sigs.k8s.io/yaml"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// seedLabel is the volume label cloud-init's NoCloud datasource looks for.
|
// seedLabel is the volume label cloud-init's NoCloud datasource looks for.
|
||||||
const seedLabel = "cidata"
|
const seedLabel = "cidata"
|
||||||
|
|
||||||
const (
|
// guestAgentScript installs and starts the QEMU guest agent, which is how
|
||||||
cloudConfigHeader = "#cloud-config"
|
// the provider learns a VM's address before the host's ARP table has seen
|
||||||
guestAgentPackage = "qemu-guest-agent"
|
// any traffic from it.
|
||||||
)
|
//
|
||||||
|
// It ships as vendor-data, and as a *script* rather than a cloud-config,
|
||||||
// guestAgentEnable is the runcmd entry that starts the agent once it is
|
// because that is the only shape cloud-init cannot drop. Vendor-data
|
||||||
// installed, so the provider can read the VM's IP before ARP sees traffic.
|
// cloud-config is merged under the Proxy's own user-data with mergers that
|
||||||
var guestAgentEnable = []any{"systemctl", "enable", "--now", guestAgentPackage}
|
// 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
|
||||||
// guestAgentVendorData is the same bootstrap as a standalone cloud-config.
|
// agent. The strategy cannot be overridden from either document: merge_how
|
||||||
// It is only used as vendor-data when the Proxy's user-data is not a
|
// in user-data governs only the first source, and merge_how in vendor-data
|
||||||
// cloud-config document (a shell script, a MIME archive, an #include), i.e.
|
// is consumed and discarded before the document is stored. A vendor-data
|
||||||
// when there are no top-level keys for it to collide with.
|
// script bypasses all of it — cloud-init gives vendor-data its own handler
|
||||||
const guestAgentVendorData = cloudConfigHeader + `
|
// chain, writes scripts to a separate directory and runs them from
|
||||||
packages:
|
// cc_scripts_vendor, after the user's packages are installed and before the
|
||||||
- qemu-guest-agent
|
// user's runcmd.
|
||||||
runcmd:
|
const guestAgentScript = `#!/bin/sh
|
||||||
- [systemctl, enable, --now, qemu-guest-agent]
|
# 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
|
||||||
`
|
`
|
||||||
|
|
||||||
// withGuestAgent decides where the guest-agent bootstrap goes and returns
|
// buildVendorData renders the seed's vendor-data. With nothing configured
|
||||||
// the user-data and vendor-data to put on the seed.
|
// it is the guest-agent script alone; with both, a MIME multipart carrying
|
||||||
//
|
// the configured document and the script as separate parts, since
|
||||||
// It cannot simply ride along as vendor-data: cloud-init merges vendor-data
|
// vendor-data is a single file and the two must not be concatenated.
|
||||||
// under user-data with its default mergers, which keep the user-data's
|
func buildVendorData(configured string, installAgent bool) (string, error) {
|
||||||
// value for any key both define and never append lists. A Proxy whose
|
switch {
|
||||||
// cloud-config has its own `packages:` (most do) would silently lose the
|
case !installAgent:
|
||||||
// agent and sit in Provisioning forever. So for a cloud-config user-data
|
return configured, nil
|
||||||
// the bootstrap is merged into the document itself: `qemu-guest-agent`
|
case strings.TrimSpace(configured) == "":
|
||||||
// appended to `packages`, the enable command to `runcmd`, both skipped
|
return guestAgentScript, nil
|
||||||
// when already present. Anything the operator cannot parse as a
|
|
||||||
// cloud-config mapping is an error — cloud-init would reject it too, and a
|
|
||||||
// VM that boots without its config is harder to diagnose than a Proxy that
|
|
||||||
// fails fast.
|
|
||||||
func withGuestAgent(userData, vendorData string) (string, string, error) {
|
|
||||||
if !isCloudConfig(userData) {
|
|
||||||
if vendorData == "" {
|
|
||||||
vendorData = guestAgentVendorData
|
|
||||||
}
|
|
||||||
return userData, vendorData, nil
|
|
||||||
}
|
|
||||||
var cfg map[string]any
|
|
||||||
if err := yaml.Unmarshal([]byte(userData), &cfg); err != nil {
|
|
||||||
return "", "", fmt.Errorf("parsing cloud-config user-data: %w", err)
|
|
||||||
}
|
|
||||||
if cfg == nil {
|
|
||||||
cfg = map[string]any{}
|
|
||||||
}
|
|
||||||
pkgs, err := appendUnique(cfg, "packages", guestAgentPackage)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
cfg["packages"] = pkgs
|
|
||||||
cmds, err := appendUnique(cfg, "runcmd", guestAgentEnable)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", err
|
|
||||||
}
|
|
||||||
cfg["runcmd"] = cmds
|
|
||||||
out, err := yaml.Marshal(cfg)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("rendering cloud-config user-data: %w", err)
|
|
||||||
}
|
|
||||||
return cloudConfigHeader + "\n" + string(out), vendorData, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// isCloudConfig reports whether userData is a cloud-config document by
|
|
||||||
// cloud-init's own rule: the first line is the #cloud-config header. Empty
|
|
||||||
// user-data counts, so a Proxy without cloud-init still gets the agent.
|
|
||||||
func isCloudConfig(userData string) bool {
|
|
||||||
if strings.TrimSpace(userData) == "" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
first, _, _ := strings.Cut(userData, "\n")
|
|
||||||
return strings.TrimSpace(first) == cloudConfigHeader
|
|
||||||
}
|
|
||||||
|
|
||||||
// appendUnique returns cfg[key] with item appended unless an equal entry
|
|
||||||
// is already there. A missing key is an empty list; anything but a list is
|
|
||||||
// an error, since cloud-init expects one there as well.
|
|
||||||
func appendUnique(cfg map[string]any, key string, item any) ([]any, error) {
|
|
||||||
var items []any
|
|
||||||
switch v := cfg[key].(type) {
|
|
||||||
case nil:
|
|
||||||
case []any:
|
|
||||||
items = v
|
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("cloud-config %s: want a list, got %T", key, v)
|
return multipartVendorData(configured, guestAgentScript)
|
||||||
}
|
}
|
||||||
for _, it := range items {
|
}
|
||||||
if reflect.DeepEqual(it, item) {
|
|
||||||
return items, nil
|
// 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return append(items, item), nil
|
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,
|
// buildSeedISO renders a NoCloud seed: user-data and vendor-data verbatim,
|
||||||
|
|||||||
+122
-138
@@ -4,12 +4,12 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"io"
|
"io"
|
||||||
"maps"
|
"maps"
|
||||||
"reflect"
|
"mime"
|
||||||
|
"mime/multipart"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/kdomanski/iso9660"
|
"github.com/kdomanski/iso9660"
|
||||||
"sigs.k8s.io/yaml"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func readSeed(t *testing.T, iso []byte) (label string, files map[string]string) {
|
func readSeed(t *testing.T, iso []byte) (label string, files map[string]string) {
|
||||||
@@ -44,7 +44,7 @@ func readSeed(t *testing.T, iso []byte) (label string, files map[string]string)
|
|||||||
func TestBuildSeedISO_contents(t *testing.T) {
|
func TestBuildSeedISO_contents(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
userData := "#cloud-config\npackages: [squid]\n"
|
userData := "#cloud-config\npackages: [squid]\n"
|
||||||
iso, err := buildSeedISO("proxy-abc123def456ghij", userData, guestAgentVendorData)
|
iso, err := buildSeedISO("proxy-abc123def456ghij", userData, guestAgentScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildSeedISO() error = %v", err)
|
t.Fatalf("buildSeedISO() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -58,7 +58,7 @@ func TestBuildSeedISO_contents(t *testing.T) {
|
|||||||
if files["user-data"] != userData {
|
if files["user-data"] != userData {
|
||||||
t.Errorf("user-data = %q, want verbatim cloud-init", files["user-data"])
|
t.Errorf("user-data = %q, want verbatim cloud-init", files["user-data"])
|
||||||
}
|
}
|
||||||
if files["vendor-data"] != guestAgentVendorData {
|
if files["vendor-data"] != guestAgentScript {
|
||||||
t.Errorf("vendor-data = %q", files["vendor-data"])
|
t.Errorf("vendor-data = %q", files["vendor-data"])
|
||||||
}
|
}
|
||||||
if got, want := files["meta-data"], "instance-id: proxy-abc123def456ghij\nlocal-hostname: proxy-abc123def456ghij\n"; got != want {
|
if got, want := files["meta-data"], "instance-id: proxy-abc123def456ghij\nlocal-hostname: proxy-abc123def456ghij\n"; got != want {
|
||||||
@@ -91,7 +91,7 @@ func TestBuildSeedISO_deterministicContent(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuildSeedISO_emptyUserData(t *testing.T) {
|
func TestBuildSeedISO_emptyUserData(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
iso, err := buildSeedISO("proxy-x", "", guestAgentVendorData)
|
iso, err := buildSeedISO("proxy-x", "", guestAgentScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildSeedISO() error = %v", err)
|
t.Fatalf("buildSeedISO() error = %v", err)
|
||||||
}
|
}
|
||||||
@@ -101,149 +101,133 @@ func TestBuildSeedISO_emptyUserData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseCloudConfig is what cloud-init sees: the document under the header.
|
// The whole point of shipping the agent as vendor-data: whatever the Proxy
|
||||||
func parseCloudConfig(t *testing.T, doc string) map[string]any {
|
// wrote reaches the guest untouched, in any of cloud-init's formats. The
|
||||||
t.Helper()
|
// previous implementation parsed and re-rendered cloud-config user-data,
|
||||||
if first, _, _ := strings.Cut(doc, "\n"); first != cloudConfigHeader {
|
// which dropped comments, reordered keys and re-typed unquoted scalars
|
||||||
t.Fatalf("first line = %q, want %q", first, cloudConfigHeader)
|
// (version: 1.10 arrived as 1.1).
|
||||||
}
|
func TestBuildSeedISO_userDataIsNeverRewritten(t *testing.T) {
|
||||||
var cfg map[string]any
|
|
||||||
if err := yaml.Unmarshal([]byte(doc), &cfg); err != nil {
|
|
||||||
t.Fatalf("merged user-data is not YAML: %v\n%s", err, doc)
|
|
||||||
}
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithGuestAgent_mergesIntoCloudConfig(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
agentCmd := []any{"systemctl", "enable", "--now", "qemu-guest-agent"}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
userData string
|
|
||||||
wantPackages []any
|
|
||||||
wantRuncmd []any
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty document",
|
|
||||||
userData: "#cloud-config\n",
|
|
||||||
wantPackages: []any{"qemu-guest-agent"},
|
|
||||||
wantRuncmd: []any{agentCmd},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no user-data at all",
|
|
||||||
userData: "",
|
|
||||||
wantPackages: []any{"qemu-guest-agent"},
|
|
||||||
wantRuncmd: []any{agentCmd},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// The case that motivated this: cloud-init's default merger keeps
|
|
||||||
// the user-data's lists and drops vendor-data's, so the agent must
|
|
||||||
// be appended to the user's own packages and runcmd.
|
|
||||||
name: "user packages and runcmd are kept and extended",
|
|
||||||
userData: "#cloud-config\npackages:\n - squid\nruncmd:\n - systemctl restart squid\n" +
|
|
||||||
"write_files:\n - path: /etc/squid/conf.d/x.conf\n content: |\n http_port 3128\n via off\n",
|
|
||||||
wantPackages: []any{"squid", "qemu-guest-agent"},
|
|
||||||
wantRuncmd: []any{"systemctl restart squid", agentCmd},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "agent already present is not duplicated",
|
|
||||||
userData: "#cloud-config\npackages: [qemu-guest-agent]\nruncmd:\n - [systemctl, enable, --now, qemu-guest-agent]\n",
|
|
||||||
wantPackages: []any{"qemu-guest-agent"},
|
|
||||||
wantRuncmd: []any{agentCmd},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "header with trailing whitespace",
|
|
||||||
userData: "#cloud-config \r\npackages: [curl]\n",
|
|
||||||
wantPackages: []any{"curl", "qemu-guest-agent"},
|
|
||||||
wantRuncmd: []any{agentCmd},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
user, vendor, err := withGuestAgent(tc.userData, "")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("withGuestAgent() error = %v", err)
|
|
||||||
}
|
|
||||||
if vendor != "" {
|
|
||||||
t.Errorf("vendor-data = %q, want none when the agent is merged into the user-data", vendor)
|
|
||||||
}
|
|
||||||
cfg := parseCloudConfig(t, user)
|
|
||||||
if !reflect.DeepEqual(cfg["packages"], tc.wantPackages) {
|
|
||||||
t.Errorf("packages = %#v, want %#v", cfg["packages"], tc.wantPackages)
|
|
||||||
}
|
|
||||||
if !reflect.DeepEqual(cfg["runcmd"], tc.wantRuncmd) {
|
|
||||||
t.Errorf("runcmd = %#v, want %#v", cfg["runcmd"], tc.wantRuncmd)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithGuestAgent_keepsOtherKeysAndVendorData(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
userData := "#cloud-config\nhostname: px\nwrite_files:\n - path: /etc/x\n content: |\n line one\n line two\n permissions: '0644'\nbootcmd: [echo hi]\n"
|
|
||||||
user, vendor, err := withGuestAgent(userData, "#cloud-config\nntp: {enabled: true}\n")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("withGuestAgent() error = %v", err)
|
|
||||||
}
|
|
||||||
if vendor != "#cloud-config\nntp: {enabled: true}\n" {
|
|
||||||
t.Errorf("vendor-data = %q, want the configured one verbatim", vendor)
|
|
||||||
}
|
|
||||||
got := parseCloudConfig(t, user)
|
|
||||||
delete(got, "packages")
|
|
||||||
delete(got, "runcmd")
|
|
||||||
want := parseCloudConfig(t, userData)
|
|
||||||
if !reflect.DeepEqual(got, want) {
|
|
||||||
t.Errorf("merged user-data changed the user's keys:\n got %#v\nwant %#v", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithGuestAgent_nonCloudConfigFallsBackToVendorData(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
userData string
|
|
||||||
vendorData string
|
|
||||||
wantVendor string
|
|
||||||
}{
|
|
||||||
{"shell script, no vendor-data", "#!/bin/sh\napt-get install -y squid\n", "", guestAgentVendorData},
|
|
||||||
{"include, no vendor-data", "#include\nhttps://example/x\n", "", guestAgentVendorData},
|
|
||||||
{"shell script, own vendor-data wins", "#!/bin/sh\n", "#cloud-config\nntp: {}\n", "#cloud-config\nntp: {}\n"},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
user, vendor, err := withGuestAgent(tc.userData, tc.vendorData)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("withGuestAgent() error = %v", err)
|
|
||||||
}
|
|
||||||
if user != tc.userData {
|
|
||||||
t.Errorf("user-data = %q, want it untouched", user)
|
|
||||||
}
|
|
||||||
if vendor != tc.wantVendor {
|
|
||||||
t.Errorf("vendor-data = %q, want %q", vendor, tc.wantVendor)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWithGuestAgent_rejectsBrokenCloudConfig(t *testing.T) {
|
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
userData string
|
userData string
|
||||||
}{
|
}{
|
||||||
{"not yaml", "#cloud-config\npackages: [squid\n"},
|
{"cloud-config with its own packages", "#cloud-config\n# keep me\npackages:\n - squid\nversion: 1.10\nchpasswd: {list: 'u:0123456789'}\n"},
|
||||||
{"not a mapping", "#cloud-config\n- squid\n"},
|
{"jinja template", "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n"},
|
||||||
{"packages is not a list", "#cloud-config\npackages: squid\n"},
|
{"shell script", "#!/bin/sh\napt-get install -y squid\n"},
|
||||||
{"runcmd is not a list", "#cloud-config\nruncmd:\n a: b\n"},
|
{"include", "#include\nhttps://example.invalid/x\n"},
|
||||||
|
{"mime multipart", "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"x\"\r\n\r\n--x\r\nContent-Type: text/cloud-config\r\n\r\n#cloud-config\npackages: [squid]\n\r\n--x--\r\n"},
|
||||||
|
{"gzip payload", "\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03binary\x00bytes"},
|
||||||
|
{"leading blank line", "\n\n#cloud-config\npackages: [squid]\n"},
|
||||||
}
|
}
|
||||||
for _, tc := range tests {
|
for _, tc := range tests {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
if _, _, err := withGuestAgent(tc.userData, ""); err == nil {
|
vendor, err := buildVendorData("", true)
|
||||||
t.Errorf("withGuestAgent() = nil error, want one for %q", tc.userData)
|
if err != nil {
|
||||||
|
t.Fatalf("buildVendorData() error = %v", err)
|
||||||
|
}
|
||||||
|
iso, err := buildSeedISO("proxy-x", tc.userData, vendor)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildSeedISO() error = %v", err)
|
||||||
|
}
|
||||||
|
_, files := readSeed(t, iso)
|
||||||
|
if files["user-data"] != tc.userData {
|
||||||
|
t.Errorf("user-data was rewritten:\n got %q\nwant %q", files["user-data"], tc.userData)
|
||||||
|
}
|
||||||
|
if !strings.Contains(files["vendor-data"], "qemu-guest-agent") {
|
||||||
|
t.Errorf("vendor-data = %q, want the agent script", files["vendor-data"])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildVendorData_agentScriptAlone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got, err := buildVendorData("", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildVendorData() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != guestAgentScript {
|
||||||
|
t.Errorf("vendor-data = %q, want the bare script", got)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(got, "#!") {
|
||||||
|
t.Error("vendor-data must start with a shebang or cloud-init treats it as something other than a script")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildVendorData_optOutKeepsConfiguredOnly(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
configured := "#cloud-config\nntp: {enabled: true}\n"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configured string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"nothing configured", "", ""},
|
||||||
|
{"configured document passes through", configured, configured},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got, err := buildVendorData(tc.configured, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildVendorData() error = %v", err)
|
||||||
|
}
|
||||||
|
if got != tc.want {
|
||||||
|
t.Errorf("vendor-data = %q, want %q", got, tc.want)
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "qemu-guest-agent") {
|
||||||
|
t.Error("agent script present although installGuestAgent is off")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both documents have to survive in one file, and cloud-init only looks for
|
||||||
|
// the multipart marker in the first 4096 bytes.
|
||||||
|
func TestBuildVendorData_multipartCarriesBoth(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
configured := "#cloud-config\nntp:\n enabled: true\n"
|
||||||
|
got, err := buildVendorData(configured, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("buildVendorData() error = %v", err)
|
||||||
|
}
|
||||||
|
head := got
|
||||||
|
if len(head) > 4096 {
|
||||||
|
head = head[:4096]
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.ToLower(head), "mime-version:") {
|
||||||
|
t.Fatalf("no mime-version: in the first 4096 bytes; cloud-init would treat this as a single document:\n%s", head)
|
||||||
|
}
|
||||||
|
_, params, err := mime.ParseMediaType(strings.SplitN(got, "\r\n\r\n", 2)[0][strings.Index(got, "Content-Type:")+len("Content-Type: "):])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsing the multipart content type: %v", err)
|
||||||
|
}
|
||||||
|
body := strings.SplitN(got, "\r\n\r\n", 2)[1]
|
||||||
|
r := multipart.NewReader(strings.NewReader(body), params["boundary"])
|
||||||
|
var bodies, types []string
|
||||||
|
for {
|
||||||
|
part, err := r.NextPart()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading part: %v", err)
|
||||||
|
}
|
||||||
|
content, err := io.ReadAll(part)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading part body: %v", err)
|
||||||
|
}
|
||||||
|
bodies = append(bodies, string(content))
|
||||||
|
types = append(types, part.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
if len(bodies) != 2 || bodies[0] != configured || bodies[1] != guestAgentScript {
|
||||||
|
t.Fatalf("parts = %q, want the configured document then the script", bodies)
|
||||||
|
}
|
||||||
|
// text/plain makes cloud-init re-sniff the user's document from its own
|
||||||
|
// header line, so this code never has to classify it.
|
||||||
|
if types[0] != "text/plain" || types[1] != "text/x-shellscript" {
|
||||||
|
t.Errorf("part types = %v, want [text/plain text/x-shellscript]", types)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user