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