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>
250 lines
7.9 KiB
Go
250 lines
7.9 KiB
Go
package kvm
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"maps"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kdomanski/iso9660"
|
|
"sigs.k8s.io/yaml"
|
|
)
|
|
|
|
func readSeed(t *testing.T, iso []byte) (label string, files map[string]string) {
|
|
t.Helper()
|
|
img, err := iso9660.OpenImage(bytes.NewReader(iso))
|
|
if err != nil {
|
|
t.Fatalf("OpenImage() error = %v", err)
|
|
}
|
|
label, err = img.Label()
|
|
if err != nil {
|
|
t.Fatalf("Label() error = %v", err)
|
|
}
|
|
root, err := img.RootDir()
|
|
if err != nil {
|
|
t.Fatalf("RootDir() error = %v", err)
|
|
}
|
|
children, err := root.GetChildren()
|
|
if err != nil {
|
|
t.Fatalf("GetChildren() error = %v", err)
|
|
}
|
|
files = make(map[string]string, len(children))
|
|
for _, c := range children {
|
|
body, err := io.ReadAll(c.Reader())
|
|
if err != nil {
|
|
t.Fatalf("reading %s: %v", c.Name(), err)
|
|
}
|
|
files[c.Name()] = string(body)
|
|
}
|
|
return label, files
|
|
}
|
|
|
|
func TestBuildSeedISO_contents(t *testing.T) {
|
|
t.Parallel()
|
|
userData := "#cloud-config\npackages: [squid]\n"
|
|
iso, err := buildSeedISO("proxy-abc123def456ghij", userData, guestAgentVendorData)
|
|
if err != nil {
|
|
t.Fatalf("buildSeedISO() error = %v", err)
|
|
}
|
|
label, files := readSeed(t, iso)
|
|
if !strings.EqualFold(label, seedLabel) {
|
|
t.Errorf("label = %q, want %q", label, seedLabel)
|
|
}
|
|
if len(files) != 3 {
|
|
t.Errorf("files = %v, want exactly user-data, meta-data, vendor-data", files)
|
|
}
|
|
if files["user-data"] != userData {
|
|
t.Errorf("user-data = %q, want verbatim cloud-init", files["user-data"])
|
|
}
|
|
if files["vendor-data"] != guestAgentVendorData {
|
|
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 {
|
|
t.Errorf("meta-data = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// The writer stamps the build time into the volume descriptor, so two
|
|
// builds are not byte-identical; what must not change between them is
|
|
// what cloud-init reads: the label and the three files.
|
|
func TestBuildSeedISO_deterministicContent(t *testing.T) {
|
|
t.Parallel()
|
|
a, err := buildSeedISO("proxy-x", "#cloud-config\n", "")
|
|
if err != nil {
|
|
t.Fatalf("first buildSeedISO() error = %v", err)
|
|
}
|
|
b, err := buildSeedISO("proxy-x", "#cloud-config\n", "")
|
|
if err != nil {
|
|
t.Fatalf("second buildSeedISO() error = %v", err)
|
|
}
|
|
labelA, filesA := readSeed(t, a)
|
|
labelB, filesB := readSeed(t, b)
|
|
if labelA != labelB || !maps.Equal(filesA, filesB) {
|
|
t.Errorf("two builds for the same inputs differ: %q %v vs %q %v", labelA, filesA, labelB, filesB)
|
|
}
|
|
if len(a) != len(b) {
|
|
t.Errorf("sizes differ: %d vs %d; the seed volume is created with the ISO's exact size", len(a), len(b))
|
|
}
|
|
}
|
|
|
|
func TestBuildSeedISO_emptyUserData(t *testing.T) {
|
|
t.Parallel()
|
|
iso, err := buildSeedISO("proxy-x", "", guestAgentVendorData)
|
|
if err != nil {
|
|
t.Fatalf("buildSeedISO() error = %v", err)
|
|
}
|
|
_, files := readSeed(t, iso)
|
|
if body, ok := files["user-data"]; !ok || body != "" {
|
|
t.Errorf("user-data = %q (present=%v), want an empty file to still exist", body, ok)
|
|
}
|
|
}
|
|
|
|
// parseCloudConfig is what cloud-init sees: the document under the header.
|
|
func parseCloudConfig(t *testing.T, doc string) map[string]any {
|
|
t.Helper()
|
|
if first, _, _ := strings.Cut(doc, "\n"); first != cloudConfigHeader {
|
|
t.Fatalf("first line = %q, want %q", first, cloudConfigHeader)
|
|
}
|
|
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()
|
|
tests := []struct {
|
|
name string
|
|
userData string
|
|
}{
|
|
{"not yaml", "#cloud-config\npackages: [squid\n"},
|
|
{"not a mapping", "#cloud-config\n- squid\n"},
|
|
{"packages is not a list", "#cloud-config\npackages: squid\n"},
|
|
{"runcmd is not a list", "#cloud-config\nruncmd:\n a: b\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if _, _, err := withGuestAgent(tc.userData, ""); err == nil {
|
|
t.Errorf("withGuestAgent() = nil error, want one for %q", tc.userData)
|
|
}
|
|
})
|
|
}
|
|
}
|