provider: extract nocloud seed builder and keyedlock from kvm2
kvm2's NoCloud seed builder moves to internal/provider/nocloud (exported, with an ErrEmptyName sentinel instead of permanentf) and its keyed locks to internal/provider/keyedlock, tests moved under their existing names. kvm2 behaviour is unchanged. proxmox gains the per-name lock and the pure seed helpers (seedISOName, seedVolID, buildSeed); unlike kvm2 (review finding S5.1), a seed staging failure there is Transient, not Permanent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
// Package nocloud builds cloud-init NoCloud seed ISOs in process, shared by
|
||||
// the providers that attach a seed as a CD-ROM (kvm2, proxmox). It never
|
||||
// parses or logs the payload.
|
||||
package nocloud
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
"github.com/kdomanski/iso9660"
|
||||
)
|
||||
|
||||
// SeedLabel is the volume label cloud-init's NoCloud datasource looks for.
|
||||
const SeedLabel = "cidata"
|
||||
|
||||
// ErrEmptyName is returned by BuildSeedISO for an empty instance name.
|
||||
var ErrEmptyName = errors.New("seed ISO: instance name is empty")
|
||||
|
||||
// GuestAgentScript is the vendor-data payload that gets qemu-guest-agent
|
||||
// running so the provider can read the VM's address from the hypervisor. It is
|
||||
// a shell script and not a cloud-config on purpose: cloud-init merges a
|
||||
// vendor-data cloud-config under the user-data with mergers that keep the
|
||||
// user-data's value for every top-level key and never append lists, so a
|
||||
// Proxy with its own packages: would silently lose the agent. Scripts
|
||||
// bypass the merge.
|
||||
const GuestAgentScript = `#!/bin/sh
|
||||
# Added by egress-proxies-operator: installs and starts qemu-guest-agent so
|
||||
# the operator can learn this VM's IP address from the hypervisor.
|
||||
# To leave this out, set installGuestAgent: false in the provider's config.
|
||||
set -e
|
||||
if command -v qemu-ga >/dev/null 2>&1 || [ -x /usr/sbin/qemu-ga ]; then
|
||||
echo "egress-proxies-operator: qemu-guest-agent already installed"
|
||||
elif command -v apt-get >/dev/null 2>&1; then
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y qemu-guest-agent
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y qemu-guest-agent
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y qemu-guest-agent
|
||||
elif command -v zypper >/dev/null 2>&1; then
|
||||
zypper --non-interactive install qemu-guest-agent
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add qemu-guest-agent
|
||||
else
|
||||
echo "egress-proxies-operator: no supported package manager found; install qemu-guest-agent by hand" >&2
|
||||
exit 1
|
||||
fi
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
systemctl enable --now qemu-guest-agent
|
||||
else
|
||||
rc-update add qemu-guest-agent default
|
||||
rc-service qemu-guest-agent start
|
||||
fi
|
||||
`
|
||||
|
||||
// BuildSeedISO writes a NoCloud seed to w: user-data byte for byte,
|
||||
// meta-data naming the instance, and vendor-data (an empty file when there
|
||||
// is none, so cloud-init does not look elsewhere). iso9660.NewWriter stages
|
||||
// the files in os.MkdirTemp("", ""), that is under os.TempDir(): $TMPDIR
|
||||
// when set, else /tmp, the manager's 64Mi emptyDir. The staging dir is
|
||||
// removed before returning. Nothing about the payload is logged here.
|
||||
func BuildSeedISO(name, userData, vendorData string, w io.Writer) (err error) {
|
||||
if name == "" {
|
||||
return ErrEmptyName
|
||||
}
|
||||
iw, err := iso9660.NewWriter()
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating seed ISO staging dir: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if cerr := iw.Cleanup(); cerr != nil {
|
||||
err = errors.Join(err, fmt.Errorf("removing seed ISO staging dir: %w", cerr))
|
||||
}
|
||||
}()
|
||||
metaData := "instance-id: " + name + "\nlocal-hostname: " + name + "\n"
|
||||
files := []struct{ name, content string }{
|
||||
{"user-data", userData},
|
||||
{"meta-data", metaData},
|
||||
{"vendor-data", vendorData},
|
||||
}
|
||||
for _, f := range files {
|
||||
if err := iw.AddFile(strings.NewReader(f.content), f.name); err != nil {
|
||||
return fmt.Errorf("adding %s to seed ISO: %w", f.name, err)
|
||||
}
|
||||
}
|
||||
if err := iw.WriteTo(w, SeedLabel); err != nil {
|
||||
return fmt.Errorf("writing seed ISO: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildVendorData composes the seed's vendor-data from the configured
|
||||
// document and the guest agent script. With both, they travel as separate
|
||||
// parts of a MIME multipart, so cloud-init handles each by its own type; the
|
||||
// MIME-Version header comes first because cloud-init detects multipart by
|
||||
// finding "mime-version:" within the first 4096 bytes.
|
||||
func BuildVendorData(configured string, installAgent bool) (string, error) {
|
||||
switch {
|
||||
case !installAgent:
|
||||
return configured, nil
|
||||
case strings.TrimSpace(configured) == "":
|
||||
return GuestAgentScript, nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fmt.Fprintf(&buf, "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=%q\r\n\r\n", mw.Boundary())
|
||||
parts := []struct{ contentType, body string }{
|
||||
{"text/plain", configured},
|
||||
{"text/x-shellscript", GuestAgentScript},
|
||||
}
|
||||
for _, p := range parts {
|
||||
pw, err := mw.CreatePart(textproto.MIMEHeader{"Content-Type": {p.contentType}})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating vendor-data part %s: %w", p.contentType, err)
|
||||
}
|
||||
if _, err := io.WriteString(pw, p.body); err != nil {
|
||||
return "", fmt.Errorf("writing vendor-data part %s: %w", p.contentType, err)
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return "", fmt.Errorf("closing vendor-data multipart: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package nocloud
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/textproto"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kdomanski/iso9660"
|
||||
)
|
||||
|
||||
// readSeed builds a seed and returns its files by name plus the label.
|
||||
func readSeed(t *testing.T, name, userData, vendorData string) (map[string]string, string) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := BuildSeedISO(name, userData, vendorData, &buf); err != nil {
|
||||
t.Fatalf("BuildSeedISO: %v", err)
|
||||
}
|
||||
img, err := iso9660.OpenImage(bytes.NewReader(buf.Bytes()))
|
||||
if err != nil {
|
||||
t.Fatalf("OpenImage: %v", err)
|
||||
}
|
||||
root, err := img.RootDir()
|
||||
if err != nil {
|
||||
t.Fatalf("RootDir: %v", err)
|
||||
}
|
||||
children, err := root.GetChildren()
|
||||
if err != nil {
|
||||
t.Fatalf("GetChildren: %v", err)
|
||||
}
|
||||
files := make(map[string]string, len(children))
|
||||
for _, c := range children {
|
||||
if c.IsDir() {
|
||||
t.Errorf("unexpected directory %q in seed", c.Name())
|
||||
continue
|
||||
}
|
||||
data, err := io.ReadAll(c.Reader())
|
||||
if err != nil {
|
||||
t.Fatalf("reading %s: %v", c.Name(), err)
|
||||
}
|
||||
files[c.Name()] = string(data)
|
||||
}
|
||||
label, err := img.Label()
|
||||
if err != nil {
|
||||
t.Fatalf("Label: %v", err)
|
||||
}
|
||||
return files, label
|
||||
}
|
||||
|
||||
func TestSeedISO_filesAndLabel(t *testing.T) {
|
||||
t.Parallel()
|
||||
userData := "#cloud-config\npackages: [squid]\n"
|
||||
vendorData := "#!/bin/sh\necho hi\n"
|
||||
files, label := readSeed(t, "proxy-abc", userData, vendorData)
|
||||
if label != SeedLabel {
|
||||
t.Errorf("label = %q, want %q", label, SeedLabel)
|
||||
}
|
||||
want := map[string]string{
|
||||
"user-data": userData,
|
||||
"meta-data": "instance-id: proxy-abc\nlocal-hostname: proxy-abc\n",
|
||||
"vendor-data": vendorData,
|
||||
}
|
||||
if len(files) != len(want) {
|
||||
t.Errorf("seed has %d files %v, want exactly %d", len(files), keys(files), len(want))
|
||||
}
|
||||
for name, content := range want {
|
||||
if got, ok := files[name]; !ok {
|
||||
t.Errorf("seed lacks %s", name)
|
||||
} else if got != content {
|
||||
t.Errorf("%s = %q, want %q", name, got, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keys(m map[string]string) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestSeedISO_userDataByteForByte(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
userData string
|
||||
}{
|
||||
{name: "mime multipart with CRLF", userData: "MIME-Version: 1.0\r\nContent-Type: multipart/mixed; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/cloud-config\r\n\r\n#cloud-config\r\npackages: [squid]\r\n--b--\r\n"},
|
||||
{name: "jinja header", userData: "## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n"},
|
||||
{name: "no trailing newline", userData: "#cloud-config\nruncmd:\n - echo done"},
|
||||
{name: "bytes above 0x7f", userData: "#cloud-config\nwrite_files:\n - content: \"caf\xc3\xa9 \xff\xfe\x80\"\n"},
|
||||
{name: "empty", userData: ""},
|
||||
{name: "trailing whitespace preserved", userData: "#!/bin/sh\necho x \n\n\n"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
files, _ := readSeed(t, "proxy-abc", tc.userData, "")
|
||||
if got := files["user-data"]; got != tc.userData {
|
||||
t.Errorf("user-data changed:\n got %q\nwant %q", got, tc.userData)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Not parallel: it changes TMPDIR for the process.
|
||||
func TestSeedISO_tempDirRemoved(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("TMPDIR", tmp)
|
||||
if got := os.TempDir(); got != tmp {
|
||||
t.Fatalf("os.TempDir() = %q, want %q", got, tmp)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := BuildSeedISO("proxy-abc", "#cloud-config\n", GuestAgentScript, &buf); err != nil {
|
||||
t.Fatalf("BuildSeedISO: %v", err)
|
||||
}
|
||||
if buf.Len() == 0 {
|
||||
t.Fatal("seed ISO is empty")
|
||||
}
|
||||
entries, err := os.ReadDir(tmp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(entries) != 0 {
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
t.Errorf("staging left behind in TMPDIR: %v", names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedISO_emptyVendorDataStillHasFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
files, _ := readSeed(t, "proxy-abc", "#cloud-config\n", "")
|
||||
got, ok := files["vendor-data"]
|
||||
if !ok {
|
||||
t.Fatalf("seed lacks vendor-data; files: %v", keys(files))
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("vendor-data = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSeedISO_rejectsEmptyName(t *testing.T) {
|
||||
t.Parallel()
|
||||
var buf bytes.Buffer
|
||||
if err := BuildSeedISO("", "#cloud-config\n", "", &buf); !errors.Is(err, ErrEmptyName) {
|
||||
t.Fatalf("BuildSeedISO with empty name error = %v, want ErrEmptyName", err)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Errorf("wrote %d bytes alongside the error", buf.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorData_isShellScriptNotCloudConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := BuildVendorData("", true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(got, "#!/bin/sh\n") {
|
||||
t.Errorf("vendor-data starts with %q, want #!/bin/sh", firstLine(got))
|
||||
}
|
||||
if strings.HasPrefix(got, "#cloud-config") {
|
||||
t.Error("vendor-data is a cloud-config; cloud-init would merge it away")
|
||||
}
|
||||
for _, want := range []string{"qemu-guest-agent", "set -e", "apt-get", "dnf", "yum", "zypper", "apk", "systemctl enable --now qemu-guest-agent", "rc-update", "installGuestAgent: false", "DEBIAN_FRONTEND=noninteractive"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("script lacks %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "MIME-Version") {
|
||||
t.Error("script alone should not be wrapped in a multipart")
|
||||
}
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
line, _, _ := strings.Cut(s, "\n")
|
||||
return line
|
||||
}
|
||||
|
||||
func bufioReader(s string) *bufio.Reader {
|
||||
return bufio.NewReader(strings.NewReader(s))
|
||||
}
|
||||
|
||||
func TestVendorData_multipartWhenExtraProvided(t *testing.T) {
|
||||
t.Parallel()
|
||||
configured := "#cloud-config\nntp:\n enabled: true\n"
|
||||
got, err := BuildVendorData(configured, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(got, "MIME-Version: 1.0\r\n") {
|
||||
t.Errorf("vendor-data starts with %q, want MIME-Version: 1.0", firstLine(got))
|
||||
}
|
||||
r := textproto.NewReader(bufioReader(got))
|
||||
hdr, err := r.ReadMIMEHeader()
|
||||
if err != nil {
|
||||
t.Fatalf("reading top-level headers: %v", err)
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(hdr.Get("Content-Type"))
|
||||
if err != nil {
|
||||
t.Fatalf("parsing Content-Type %q: %v", hdr.Get("Content-Type"), err)
|
||||
}
|
||||
if mediaType != "multipart/mixed" {
|
||||
t.Errorf("media type = %q, want multipart/mixed", mediaType)
|
||||
}
|
||||
mr := multipart.NewReader(r.R, params["boundary"])
|
||||
type part struct{ contentType, body string }
|
||||
var parts []part
|
||||
for {
|
||||
p, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("NextPart: %v", err)
|
||||
}
|
||||
body, err := io.ReadAll(p)
|
||||
if err != nil {
|
||||
t.Fatalf("reading part: %v", err)
|
||||
}
|
||||
parts = append(parts, part{p.Header.Get("Content-Type"), string(body)})
|
||||
}
|
||||
want := []part{{"text/plain", configured}, {"text/x-shellscript", GuestAgentScript}}
|
||||
if len(parts) != len(want) {
|
||||
t.Fatalf("got %d parts, want %d", len(parts), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if parts[i] != want[i] {
|
||||
t.Errorf("part %d = {%q, %q}, want {%q, %q}", i, parts[i].contentType, firstLine(parts[i].body), want[i].contentType, firstLine(want[i].body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorData_offWithoutConfiguredIsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
got, err := BuildVendorData("", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("vendor-data = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVendorData_offWithConfiguredPassesThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
configured := "#cloud-config\nntp:\n enabled: true"
|
||||
got, err := BuildVendorData(configured, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != configured {
|
||||
t.Errorf("vendor-data = %q, want the configured document unchanged", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user