Files
egress-proxies-operator/internal/provider/kvm/seed.go
T
kacerrandClaude Fable 5.1 366736f53f 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>
2026-09-10 21:06:54 +00:00

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
}