Merge the qemu-guest-agent bootstrap into each Proxy's cloud-config instead of vendor-data
In the kind cluster two KVM Proxies with real cloud-configs booted fine but never left Provisioning: the guest agent was not installed, so the provider could not learn the IP. cloud-init merges vendor-data under user-data with its default mergers, which keep the user-data's value for any key both define and never append lists, so a cloud-config with its own `packages:` silently dropped the vendor-data's packages and runcmd. The integration test passed only because its user-data was empty. withGuestAgent now appends qemu-guest-agent to `packages` and the enable command to `runcmd` of the Proxy's own cloud-config (skipping entries already present) and re-renders it. Vendor-data is no longer defaulted; `vendorData` is plain extra data. User-data that is not a cloud-config document (script, #include, MIME) still gets the bootstrap as vendor-data. A cloud-config that is not a YAML mapping, or whose packages/runcmd is not a list, fails the Proxy as ErrPermanent. Validated in-cluster: both Proxies Ready and healthy in two minutes, 204 through squid from the LAN. Docs, sample comment, CHANGELOG and the plan execution log (Run 7) updated. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -3,28 +3,114 @@ package kvm
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/kdomanski/iso9660"
|
||||
"sigs.k8s.io/yaml"
|
||||
)
|
||||
|
||||
// seedLabel is the volume label cloud-init's NoCloud datasource looks for.
|
||||
const seedLabel = "cidata"
|
||||
|
||||
// defaultVendorData is the cloud-init vendor-data shipped on every seed
|
||||
// unless the config overrides it. cloud-init merges it under the Proxy's own
|
||||
// user-data, so stock images grow a guest agent (which lets the provider
|
||||
// read the VM's IP before ARP sees traffic) without the user's cloud-init
|
||||
// having to know about it.
|
||||
const defaultVendorData = `#cloud-config
|
||||
const (
|
||||
cloudConfigHeader = "#cloud-config"
|
||||
guestAgentPackage = "qemu-guest-agent"
|
||||
)
|
||||
|
||||
// guestAgentEnable is the runcmd entry that starts the agent once it is
|
||||
// installed, so the provider can read the VM's IP before ARP sees traffic.
|
||||
var guestAgentEnable = []any{"systemctl", "enable", "--now", guestAgentPackage}
|
||||
|
||||
// guestAgentVendorData is the same bootstrap as a standalone cloud-config.
|
||||
// It is only used as vendor-data when the Proxy's user-data is not a
|
||||
// cloud-config document (a shell script, a MIME archive, an #include), i.e.
|
||||
// when there are no top-level keys for it to collide with.
|
||||
const guestAgentVendorData = cloudConfigHeader + `
|
||||
packages:
|
||||
- qemu-guest-agent
|
||||
runcmd:
|
||||
- [systemctl, enable, --now, qemu-guest-agent]
|
||||
`
|
||||
|
||||
// buildSeedISO renders a NoCloud seed: user-data verbatim, vendor-data as
|
||||
// given, and a meta-data whose instance-id is the deterministic VM name, so
|
||||
// withGuestAgent decides where the guest-agent bootstrap goes and returns
|
||||
// the user-data and vendor-data to put on the seed.
|
||||
//
|
||||
// It cannot simply ride along as vendor-data: cloud-init merges vendor-data
|
||||
// under user-data with its default mergers, which keep the user-data's
|
||||
// value for any key both define and never append lists. A Proxy whose
|
||||
// cloud-config has its own `packages:` (most do) would silently lose the
|
||||
// agent and sit in Provisioning forever. So for a cloud-config user-data
|
||||
// the bootstrap is merged into the document itself: `qemu-guest-agent`
|
||||
// appended to `packages`, the enable command to `runcmd`, both skipped
|
||||
// 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:
|
||||
return nil, fmt.Errorf("cloud-config %s: want a list, got %T", key, v)
|
||||
}
|
||||
for _, it := range items {
|
||||
if reflect.DeepEqual(it, item) {
|
||||
return items, nil
|
||||
}
|
||||
}
|
||||
return append(items, item), 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.
|
||||
|
||||
Reference in New Issue
Block a user