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) } }) } }