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 }