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>
140 lines
4.7 KiB
Go
140 lines
4.7 KiB
Go
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"
|
|
|
|
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]
|
|
`
|
|
|
|
// 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.
|
|
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
|
|
}
|