Merging the bootstrap into each Proxy's cloud-config was the wrong mechanism twice over. Detection only matched a literal #cloud-config first line, so a jinja template or a MIME archive still fell back to vendor-data, where cloud-init's default mergers drop it — the exact failure the merge existed to prevent. And the YAML round-trip re-typed unquoted scalars: version: 1.10 reached the guest as 1.1, comments were dropped and keys reordered. Neither is worth patching, because vendor-data delivered as a shell script bypasses the merger entirely: cloud-init hands vendor-data to its own handler chain, writes scripts to a separate directory and runs them from cc_scripts_vendor, after the user's packages and before the user's runcmd. The merge could not have been rescued anyway — merge_how in user-data governs only the first source, and merge_how in vendor-data is discarded before the document is stored. The Proxy's cloud-init now reaches the seed byte for byte in any format. installGuestAgent (default true) turns the script off; a configured vendorData travels beside it as a MIME multipart, declared text/plain so cloud-init re-sniffs it and this code never classifies user content. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
234 lines
7.6 KiB
Go
234 lines
7.6 KiB
Go
package kvm
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"maps"
|
|
"mime"
|
|
"mime/multipart"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/kdomanski/iso9660"
|
|
)
|
|
|
|
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, guestAgentScript)
|
|
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"] != guestAgentScript {
|
|
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", "", guestAgentScript)
|
|
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)
|
|
}
|
|
}
|
|
|
|
// The whole point of shipping the agent as vendor-data: whatever the Proxy
|
|
// wrote reaches the guest untouched, in any of cloud-init's formats. The
|
|
// previous implementation parsed and re-rendered cloud-config user-data,
|
|
// which dropped comments, reordered keys and re-typed unquoted scalars
|
|
// (version: 1.10 arrived as 1.1).
|
|
func TestBuildSeedISO_userDataIsNeverRewritten(t *testing.T) {
|
|
t.Parallel()
|
|
tests := []struct {
|
|
name string
|
|
userData string
|
|
}{
|
|
{"cloud-config with its own packages", "#cloud-config\n# keep me\npackages:\n - squid\nversion: 1.10\nchpasswd: {list: 'u:0123456789'}\n"},
|
|
{"jinja template", "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n"},
|
|
{"shell script", "#!/bin/sh\napt-get install -y squid\n"},
|
|
{"include", "#include\nhttps://example.invalid/x\n"},
|
|
{"mime multipart", "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"x\"\r\n\r\n--x\r\nContent-Type: text/cloud-config\r\n\r\n#cloud-config\npackages: [squid]\n\r\n--x--\r\n"},
|
|
{"gzip payload", "\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\x03binary\x00bytes"},
|
|
{"leading blank line", "\n\n#cloud-config\npackages: [squid]\n"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
vendor, err := buildVendorData("", true)
|
|
if err != nil {
|
|
t.Fatalf("buildVendorData() error = %v", err)
|
|
}
|
|
iso, err := buildSeedISO("proxy-x", tc.userData, vendor)
|
|
if err != nil {
|
|
t.Fatalf("buildSeedISO() error = %v", err)
|
|
}
|
|
_, files := readSeed(t, iso)
|
|
if files["user-data"] != tc.userData {
|
|
t.Errorf("user-data was rewritten:\n got %q\nwant %q", files["user-data"], tc.userData)
|
|
}
|
|
if !strings.Contains(files["vendor-data"], "qemu-guest-agent") {
|
|
t.Errorf("vendor-data = %q, want the agent script", files["vendor-data"])
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildVendorData_agentScriptAlone(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := buildVendorData("", true)
|
|
if err != nil {
|
|
t.Fatalf("buildVendorData() error = %v", err)
|
|
}
|
|
if got != guestAgentScript {
|
|
t.Errorf("vendor-data = %q, want the bare script", got)
|
|
}
|
|
if !strings.HasPrefix(got, "#!") {
|
|
t.Error("vendor-data must start with a shebang or cloud-init treats it as something other than a script")
|
|
}
|
|
}
|
|
|
|
func TestBuildVendorData_optOutKeepsConfiguredOnly(t *testing.T) {
|
|
t.Parallel()
|
|
configured := "#cloud-config\nntp: {enabled: true}\n"
|
|
tests := []struct {
|
|
name string
|
|
configured string
|
|
want string
|
|
}{
|
|
{"nothing configured", "", ""},
|
|
{"configured document passes through", configured, configured},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
got, err := buildVendorData(tc.configured, false)
|
|
if err != nil {
|
|
t.Fatalf("buildVendorData() error = %v", err)
|
|
}
|
|
if got != tc.want {
|
|
t.Errorf("vendor-data = %q, want %q", got, tc.want)
|
|
}
|
|
if strings.Contains(got, "qemu-guest-agent") {
|
|
t.Error("agent script present although installGuestAgent is off")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Both documents have to survive in one file, and cloud-init only looks for
|
|
// the multipart marker in the first 4096 bytes.
|
|
func TestBuildVendorData_multipartCarriesBoth(t *testing.T) {
|
|
t.Parallel()
|
|
configured := "#cloud-config\nntp:\n enabled: true\n"
|
|
got, err := buildVendorData(configured, true)
|
|
if err != nil {
|
|
t.Fatalf("buildVendorData() error = %v", err)
|
|
}
|
|
head := got
|
|
if len(head) > 4096 {
|
|
head = head[:4096]
|
|
}
|
|
if !strings.Contains(strings.ToLower(head), "mime-version:") {
|
|
t.Fatalf("no mime-version: in the first 4096 bytes; cloud-init would treat this as a single document:\n%s", head)
|
|
}
|
|
_, params, err := mime.ParseMediaType(strings.SplitN(got, "\r\n\r\n", 2)[0][strings.Index(got, "Content-Type:")+len("Content-Type: "):])
|
|
if err != nil {
|
|
t.Fatalf("parsing the multipart content type: %v", err)
|
|
}
|
|
body := strings.SplitN(got, "\r\n\r\n", 2)[1]
|
|
r := multipart.NewReader(strings.NewReader(body), params["boundary"])
|
|
var bodies, types []string
|
|
for {
|
|
part, err := r.NextPart()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
t.Fatalf("reading part: %v", err)
|
|
}
|
|
content, err := io.ReadAll(part)
|
|
if err != nil {
|
|
t.Fatalf("reading part body: %v", err)
|
|
}
|
|
bodies = append(bodies, string(content))
|
|
types = append(types, part.Header.Get("Content-Type"))
|
|
}
|
|
if len(bodies) != 2 || bodies[0] != configured || bodies[1] != guestAgentScript {
|
|
t.Fatalf("parts = %q, want the configured document then the script", bodies)
|
|
}
|
|
// text/plain makes cloud-init re-sniff the user's document from its own
|
|
// header line, so this code never has to classify it.
|
|
if types[0] != "text/plain" || types[1] != "text/x-shellscript" {
|
|
t.Errorf("part types = %v, want [text/plain text/x-shellscript]", types)
|
|
}
|
|
}
|