a73x

internal/agent/seed/seed_test.go

Ref:   Size: 20.9 KiB   History

package seed

import (
	"io"
	"mime"
	"mime/multipart"
	"net/mail"
	"os"
	"strings"
	"testing"

	"github.com/a73x/eitri/internal/guest"
	diskfs "github.com/diskfs/go-diskfs"
	"github.com/diskfs/go-diskfs/filesystem/iso9660"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestBuildProducesISOWithNoCloudFiles(t *testing.T) {
	out := t.TempDir() + "/seed.iso"
	err := Build(out, Params{
		Hostname:         "sandbox-7",
		SSHAuthorizedKey: "ssh-ed25519 AAAA test@example",
	})
	require.NoError(t, err)
	st, err := os.Stat(out)
	require.NoError(t, err)
	assert.Greater(t, st.Size(), int64(0))

	// Read the ISO back and verify the three NoCloud files are present.
	// Must specify the same 2048-byte sector size used at creation time.
	d, err := diskfs.Open(out, diskfs.WithSectorSize(2048))
	require.NoError(t, err)
	defer d.Close()

	fsi, err := d.GetFilesystem(0)
	require.NoError(t, err)
	fs, ok := fsi.(*iso9660.FileSystem)
	require.True(t, ok, "expected iso9660 filesystem")

	// iso9660.FileSystem follows fs.ValidPath rules: root is "." not "/".
	entries, err := fs.ReadDir(".")
	require.NoError(t, err)
	names := make(map[string]bool)
	for _, e := range entries {
		names[strings.ToLower(e.Name())] = true
	}
	assert.True(t, names["user-data"], "user-data must be present in ISO")
	assert.True(t, names["meta-data"], "meta-data must be present in ISO")
	assert.True(t, names["network-config"], "network-config must be present in ISO")
}

func TestUserDataDefaultInjectsKeyNotGrowpart(t *testing.T) {
	ud := userData(Params{Hostname: "h", SSHAuthorizedKey: "ssh-ed25519 KEY"})
	assert.True(t, strings.HasPrefix(ud, "#cloud-config\n"))
	assert.Contains(t, ud, "ssh-ed25519 KEY")
	assert.Contains(t, ud, "name: "+guest.LoginUser)
	// Growing the root to disk_gb is eitri's job and lives in eitri's own
	// cloud-config part, not here: a growpart in the tenant's document wins over
	// that part's `growpart: off` and re-arms the image growpart, which reverts
	// on this disk.
	assert.NotContains(t, ud, "growpart", "growpart belongs in eitri's own cloud-config part, not this document")
	assert.NotContains(t, ud, "disk_setup", "the no-op disk_setup block is gone")
}

func TestUserDataOmitsSSHAuthorizedKeysWhenNoKey(t *testing.T) {
	// No key ⇒ no ssh_authorized_keys mapping at all. A null list item
	// ("- " with no value) makes cloud-init log an error and run degraded.
	ud := userData(Params{Hostname: "h"})
	assert.NotContains(t, ud, "ssh_authorized_keys",
		"ssh_authorized_keys must be omitted entirely when no key is supplied")
	assert.NotContains(t, ud, "- \n", "no null list item may be rendered")
	// The rest of the default user-data is intact.
	assert.Contains(t, ud, "hostname: h")
	assert.Contains(t, ud, "name: "+guest.LoginUser)
}

func TestUserDataIncludesSSHAuthorizedKeysWhenKeyPresent(t *testing.T) {
	ud := userData(Params{Hostname: "h", SSHAuthorizedKey: "ssh-ed25519 KEY"})
	assert.Contains(t, ud, "ssh_authorized_keys:")
	assert.Contains(t, ud, "- ssh-ed25519 KEY")
}

func TestUserDataCustomPassthrough(t *testing.T) {
	ud := userData(Params{Hostname: "h", UserData: "#cloud-config\npackages: [htop]"})
	assert.Equal(t, "#cloud-config\npackages: [htop]", ud,
		"advanced users own their user-data verbatim")
}

func TestNetworkConfigUsesDHCP(t *testing.T) {
	got := networkConfig(Params{})
	if !strings.Contains(got, "dhcp4: true") {
		t.Fatalf("network-config should enable DHCP, got:\n%s", got)
	}
	if strings.Contains(got, "addresses:") {
		t.Fatalf("network-config must not pin a static address, got:\n%s", got)
	}
}

// TestNetworkConfigSingleNICIsTheGuestEveryHostRunsToday pins the shape a guest
// with no named network gets, whole: one stanza, matched by name, DHCP, and an
// explicit default-route metric so the two-NIC guest's 100/200 ordering is a
// widening of a stated number rather than a guess about netplan's default.
func TestNetworkConfigSingleNICIsTheGuestEveryHostRunsToday(t *testing.T) {
	assert.Equal(t, `network:
  version: 2
  ethernets:
    primary:
      match:
        name: "en*"
      dhcp4: true
      dhcp-identifier: mac
      dhcp4-overrides:
        route-metric: 100
`, networkConfig(Params{MAC: "52:54:00:aa:bb:cc"}),
		"a guest with no named network is matched by name, as it always was")
}

// TestNetworkConfigTwoNICsMatchByMACAndOrderTheDefaultRoute pins the widened
// shape: both NICs matched by their own hardware address (a name glob would
// claim both links and netplan refuses a link two definitions match), both on
// DHCP, and the NAT NIC carrying the default route at metric 100 against the
// named NIC's 200 — deterministic egress, with the LAN as fallback.
func TestNetworkConfigTwoNICsMatchByMACAndOrderTheDefaultRoute(t *testing.T) {
	assert.Equal(t, `network:
  version: 2
  ethernets:
    primary:
      match:
        macaddress: "52:54:00:aa:bb:cc"
      dhcp4: true
      dhcp-identifier: mac
      dhcp4-overrides:
        route-metric: 100
    net1:
      match:
        macaddress: "52:54:00:dd:ee:ff"
      dhcp4: true
      dhcp-identifier: mac
      dhcp4-overrides:
        route-metric: 200
`, networkConfig(Params{MAC: "52:54:00:aa:bb:cc", NetworkMAC: "52:54:00:dd:ee:ff"}))
}

// TestBuildRefusesASecondNICWithoutAFirst pins the refusal: a stanza matching
// nothing would leave the guest's management NIC unconfigured, which is worse
// than no seed at all.
func TestBuildRefusesASecondNICWithoutAFirst(t *testing.T) {
	err := Build(t.TempDir()+"/seed.iso", Params{Hostname: "h", NetworkMAC: "52:54:00:dd:ee:ff"})
	require.Error(t, err)
	assert.Contains(t, err.Error(), "NetworkMAC")
}

func TestNetworkConfigIdentifiesTheGuestByItsMAC(t *testing.T) {
	got := networkConfig(Params{})

	// Without this, systemd-networkd sends a DUID-based client identifier and
	// the DHCP server files the lease under that instead of the hardware
	// address. eitri chooses a VM's MAC deterministically and looks addresses
	// up by it; on a host whose DHCP server we do not run — macOS's bootpd —
	// that lookup is the only handle we have, and a DUID makes the live lease
	// unfindable while an earlier MAC-keyed offer lingers to be misread as
	// current. Observed for real: a guest held 192.168.64.8 under a DUID while
	// its stale MAC lease still read 192.168.64.7.
	if !strings.Contains(got, "dhcp-identifier: mac") {
		t.Fatalf("network-config must pin the DHCP client identifier to the MAC, got:\n%s", got)
	}
}

// --- C1b: seed injection-defense tests ---

func TestBuildRejectsNewlineInHostname(t *testing.T) {
	out := t.TempDir() + "/seed.iso"
	err := Build(out, Params{
		Hostname: "a\nb",
	})
	assert.Error(t, err, "Build must reject Hostname containing newline")
}

func TestBuildRejectsNewlineInSSHKey(t *testing.T) {
	out := t.TempDir() + "/seed.iso"
	err := Build(out, Params{
		Hostname:         "ok",
		SSHAuthorizedKey: "ssh-ed25519 AAAA\ninjected: yaml",
	})
	assert.Error(t, err, "Build must reject SSHAuthorizedKey containing newline")
}

func TestBuildDoesNotRejectMultilineUserData(t *testing.T) {
	out := t.TempDir() + "/seed.iso"
	err := Build(out, Params{
		Hostname: "ok",
		UserData: "#cloud-config\npackages: [htop]\n",
	})
	assert.NoError(t, err, "UserData is exempt from newline validation")
}

// --- M2: instance-id tests ---

func TestMetaDataUsesInstanceIDWhenSet(t *testing.T) {
	md := metaData(Params{Hostname: "myhostname", InstanceID: "vm-abc123"})
	assert.Contains(t, md, "instance-id: vm-abc123")
	assert.Contains(t, md, "local-hostname: myhostname")
}

func TestMetaDataFallsBackToHostnameWhenInstanceIDEmpty(t *testing.T) {
	md := metaData(Params{Hostname: "myhostname"})
	assert.Contains(t, md, "instance-id: myhostname")
	assert.Contains(t, md, "local-hostname: myhostname")
}

// readISOFile returns the content of one file from a finished seed ISO.
func readISOFile(t *testing.T, isoPath, name string) []byte {
	t.Helper()
	d, err := diskfs.Open(isoPath, diskfs.WithSectorSize(2048))
	require.NoError(t, err)
	defer d.Close()
	fsi, err := d.GetFilesystem(0)
	require.NoError(t, err)
	f, err := fsi.OpenFile(name, os.O_RDONLY)
	require.NoError(t, err)
	b, err := io.ReadAll(f)
	require.NoError(t, err)
	return b
}

// seedParts reads a finished seed's /user-data and returns the two documents it
// composes: the tenant's part first, eitri's own last. Splitting here keeps
// every assertion below about content rather than about MIME framing, and the
// length check is itself the contract — a seed is those two parts and no others.
func seedParts(t *testing.T, isoPath string) (tenant, eitri string) {
	t.Helper()
	raw := string(readISOFile(t, isoPath, "/user-data"))
	msg, err := mail.ReadMessage(strings.NewReader(raw))
	require.NoError(t, err, "user-data must be a MIME archive")
	mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
	require.NoError(t, err)
	require.Equal(t, "multipart/mixed", mediaType)
	mr := multipart.NewReader(msg.Body, params["boundary"])
	var bodies []string
	for {
		part, err := mr.NextPart()
		if err != nil {
			break
		}
		b, err := io.ReadAll(part)
		require.NoError(t, err)
		bodies = append(bodies, string(b))
	}
	require.Len(t, bodies, 2, "a seed composes the tenant's document and eitri's, in that order")
	return bodies[0], bodies[1]
}

func TestEverySeedCarriesTheDiskGrowAndWritesNoVendorData(t *testing.T) {
	// Even with no CA/host key, eitri's part holds the disk-grow that delivers
	// disk_gb to the guest — and it is a part of user-data, not a vendor-data
	// file. There is one mechanism, and a stray vendor-data would be a second
	// one whose mergers no document can influence.
	out := t.TempDir() + "/seed.iso"
	require.NoError(t, Build(out, Params{
		Hostname:         "plain",
		SSHAuthorizedKey: "ssh-ed25519 AAAA",
	}))
	d, err := diskfs.Open(out, diskfs.WithSectorSize(2048))
	require.NoError(t, err)
	defer d.Close()
	fsi, err := d.GetFilesystem(0)
	require.NoError(t, err)
	entries, err := fsi.ReadDir(".")
	require.NoError(t, err)
	names := map[string]bool{}
	for _, e := range entries {
		names[strings.ToLower(e.Name())] = true
	}
	assert.False(t, names["vendor-data"], "eitri's config rides in user-data; a vendor-data file is the mechanism that lost it")

	_, eitri := seedParts(t, out)
	assert.Contains(t, eitri, `mode: "off"`, "image growpart disabled")
	assert.Contains(t, eitri, "resize2fs /dev/vda1", "online grow runcmd present")
	assert.NotContains(t, eitri, "TrustedUserCAKeys", "no CA ⇒ no CA trust line")
	st, err := os.Stat(out)
	require.NoError(t, err)
	assert.Equal(t, int64(1*1024*1024), st.Size(), "ISO size unchanged (1 MiB)")
}

// --- eitri user-CA trust injection (sshd drop-in) ---

const testUserCAKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITESTCAKEYaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa eitri-user-ca\n"

func TestEitriPartInjectsUserCATrust(t *testing.T) {
	// A VM with a CA key gets an eitri part carrying the sshd drop-in.
	out := t.TempDir() + "/seed.iso"
	require.NoError(t, Build(out, Params{
		Hostname:               "plain",
		SSHAuthorizedKey:       "ssh-ed25519 AAAA user@host",
		SSHUserCAAuthorizedKey: testUserCAKey,
	}))

	tenant, vd := seedParts(t, out)
	assert.True(t, strings.HasPrefix(vd, "#cloud-config\n"))
	assert.Contains(t, vd, "/etc/ssh/eitri_user_ca.pub")
	assert.Contains(t, vd, "/etc/ssh/sshd_config.d/eitri-ca.conf")
	assert.Contains(t, vd, "TrustedUserCAKeys /etc/ssh/eitri_user_ca.pub")
	// The CA public key bytes must be embedded verbatim (not base64).
	assert.Contains(t, vd, strings.TrimSpace(testUserCAKey))
	// A bad config must NOT lock anyone out: -t gates the reload.
	assert.Contains(t, vd, "sshd -t && systemctl reload sshd")

	// The tenant's part is never touched by CA injection.
	assert.Contains(t, tenant, "hostname: plain")
	assert.NotContains(t, tenant, "eitri-ca.conf")
}

const (
	testHostKeyPEM = "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAtesthostkeyline1\nAAAAtesthostkeyline2\n-----END OPENSSH PRIVATE KEY-----\n"
	testHostCert   = "ssh-ed25519-cert-v01@openssh.com AAAATESTHOSTCERTdata with-hostcert\n"
)

func TestEitriPartInjectsHostKeyAndCert(t *testing.T) {
	// A VM with a host key + cert gets them installed and sshd pointed at them.
	out := t.TempDir() + "/seed.iso"
	require.NoError(t, Build(out, Params{
		Hostname:               "with-hostcert",
		SSHAuthorizedKey:       "ssh-ed25519 AAAA user@host",
		SSHUserCAAuthorizedKey: testUserCAKey,
		SSHHostKeyPEM:          testHostKeyPEM,
		SSHHostCert:            testHostCert,
	}))

	_, vd := seedParts(t, out)
	// The host key + cert are handed to cloud-init via its native ssh_keys map,
	// and sshd's HostCertificate directive is asserted via the drop-in.
	assert.Contains(t, vd, "ssh_keys:")
	assert.Contains(t, vd, "ed25519_private: |")
	assert.Contains(t, vd, "ed25519_certificate: |")
	assert.Contains(t, vd, "HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub")
	// The key + cert bytes are embedded verbatim (multi-line PEM in a block scalar).
	assert.Contains(t, vd, "-----BEGIN OPENSSH PRIVATE KEY-----")
	assert.Contains(t, vd, "AAAAtesthostkeyline2")
	assert.Contains(t, vd, strings.TrimSpace(testHostCert))
	// The user-CA trust still coexists in the same drop-in.
	assert.Contains(t, vd, "TrustedUserCAKeys /etc/ssh/eitri_user_ca.pub")
	assert.Contains(t, vd, "sshd -t && systemctl reload sshd")
}

func TestEitriPartInjectsHostKeyViaSSHKeys(t *testing.T) {
	// The per-VM ed25519 host key + cert are handed to cloud-init via its native
	// ssh_keys map — NOT write_files — and we do NOT set ssh_deletekeys: false.
	// So cloud-init (default ssh_deletekeys: true) deletes the image's baked host
	// keys, installs OUR ed25519 key+cert, and regenerates rsa/ecdsa fresh per VM.
	vd := eitriConfigDoc(Params{
		SSHHostKeyPEM: testHostKeyPEM,
		SSHHostCert:   testHostCert,
	})
	assert.Contains(t, vd, "ssh_keys:")
	assert.Contains(t, vd, "ed25519_private: |")
	assert.Contains(t, vd, "ed25519_certificate: |")
	// The PEM (multi-line) and cert bytes land verbatim in the block scalars.
	assert.Contains(t, vd, "-----BEGIN OPENSSH PRIVATE KEY-----")
	assert.Contains(t, vd, "AAAAtesthostkeyline2")
	assert.Contains(t, vd, strings.TrimSpace(testHostCert))
	// The ssh_deletekeys hack is gone, and the host key is no longer write_files'd.
	assert.NotContains(t, vd, "ssh_deletekeys")
	assert.NotContains(t, vd, "path: /etc/ssh/ssh_host_ed25519_key\n",
		"host key must not be injected via write_files")
}

func TestEitriPartOmitsSSHKeysWithoutHostCert(t *testing.T) {
	// No host-cert injection ⇒ no ssh_keys map (and never the ssh_deletekeys hack).
	vd := eitriConfigDoc(Params{SSHUserCAAuthorizedKey: testUserCAKey})
	assert.NotContains(t, vd, "ssh_keys:",
		"ssh_keys must only appear when injecting a host key")
	assert.NotContains(t, vd, "ssh_deletekeys")
}

func TestEitriPartHostCertWithoutCAKey(t *testing.T) {
	// Host cert injection is independent of user-CA trust: a seed with only a
	// host key still produces an eitri part with the host directives (and no
	// TrustedUserCAKeys line).
	vd := eitriConfigDoc(Params{
		SSHHostKeyPEM: testHostKeyPEM,
		SSHHostCert:   testHostCert,
	})
	assert.True(t, strings.HasPrefix(vd, "#cloud-config\n"))
	assert.Contains(t, vd, "HostCertificate /etc/ssh/ssh_host_ed25519_key-cert.pub")
	assert.NotContains(t, vd, "TrustedUserCAKeys")
}

func TestEitriPartDiskGrowAlwaysPresent(t *testing.T) {
	// Even with neither CA key nor host key, eitri's part carries the disk-grow:
	// growpart disabled + the proven online grow runcmd. No SSH material leaks in.
	vd := eitriConfigDoc(Params{})
	assert.True(t, strings.HasPrefix(vd, "#cloud-config\n"))
	assert.Contains(t, vd, "growpart:\n  mode: \"off\"\n",
		"the image growpart is disabled (it reverts on this disk)")
	// The exact proven runcmd: sfdisk grows partition 1, partx -u picks it up
	// online on the mounted root, resize2fs grows the fs. `\\n` is two literal
	// bytes here (Go), so the guest shell's printf emits one newline to sfdisk.
	assert.Contains(t, vd,
		`  - ["sh", "-c", "printf ',+\\n' | sfdisk --no-reread --no-tell-kernel -N 1 /dev/vda; partx -u /dev/vda; resize2fs /dev/vda1"]`+"\n")
	// Nothing SSH: no host key, no CA trust, no sshd reload. write_files is
	// present regardless — it carries the clock drop-in — so the assertion is
	// on the SSH paths themselves, not on the block that would hold them.
	assert.NotContains(t, vd, "ssh_keys:")
	assert.NotContains(t, vd, userCAPath)
	assert.NotContains(t, vd, dropInPath)
	assert.NotContains(t, vd, "TrustedUserCAKeys")
	assert.NotContains(t, vd, "reload sshd")
}

func TestEitriPartClockAlwaysPresent(t *testing.T) {
	// The clock drop-in is fleet infrastructure like the disk-grow, not SSH
	// material: it lands with no CA key, no host key, and no user-data.
	vd := eitriConfigDoc(Params{})
	assert.Contains(t, vd, "  - path: /etc/chrony/conf.d/eitri-clock.conf\n",
		"the drop-in path is the guest's, not eitri's: chronyd reads /etc/chrony/conf.d, and a path asserted against the "+
			"constant that wrote it lands anywhere at all while the guest's clock quietly keeps drifting")
	assert.Contains(t, vd, "      makestep 1 -1\n",
		"a negative limit disables the step limit, so a host suspend is stepped away rather than slewed")
	assert.Contains(t, vd, chronyRuncmd, "the drop-in reaches the running chronyd, not just the next boot")
}

func TestEitriPartClockSurvivesBYOUserData(t *testing.T) {
	// eitri's own part is the reason the clock config is not optional: a tenant
	// who brings their own user-data still gets it, exactly as they get the grow.
	vd := eitriConfigDoc(Params{UserData: "#cloud-config\npackages: [nginx]\n"})
	assert.Contains(t, vd, chronyDropInPath)
	assert.Contains(t, vd, "      makestep 1 -1\n")
}

func TestEitriPartDiskGrowCoexistsWithSSHMaterial(t *testing.T) {
	// The disk-grow sits alongside the CA trust + host cert, and its runcmd
	// entry is a sibling of the sshd-reload — both run.
	vd := eitriConfigDoc(Params{
		SSHUserCAAuthorizedKey: testUserCAKey,
		SSHHostKeyPEM:          testHostKeyPEM,
		SSHHostCert:            testHostCert,
	})
	assert.Contains(t, vd, `mode: "off"`)
	assert.Contains(t, vd, "resize2fs /dev/vda1")
	assert.Contains(t, vd, "TrustedUserCAKeys /etc/ssh/eitri_user_ca.pub")
	assert.Contains(t, vd, "ed25519_certificate: |")
	assert.Contains(t, vd, "sshd -t && systemctl reload sshd")
}

func TestBYOCloudInitCannotDropTheCATrustOrTheDiskGrow(t *testing.T) {
	// A BYO user-data rides byte-for-byte as its own part (eitri never edits it),
	// yet the seed's CA trust and disk-grow still reach the guest — even when the
	// tenant's document carries the two keys that used to replace them wholesale.
	// Without a merge directive cloud-init folds a later part in with
	// dict(replace)+list(), so eitri's `write_files` would take the tenant's with
	// it or the tenant's `runcmd` would take the grow.
	out := t.TempDir() + "/seed.iso"
	custom := "#cloud-config\npackages: [htop]\n" +
		"write_files:\n  - path: /etc/tenant\n    content: mine\n" +
		"runcmd:\n  - [\"sh\", \"-c\", \"echo tenant\"]\n"
	require.NoError(t, Build(out, Params{
		Hostname:               "byo",
		UserData:               custom,
		SSHUserCAAuthorizedKey: testUserCAKey,
	}))
	tenant, eitri := seedParts(t, out)
	assert.Equal(t, custom, tenant, "the tenant's document is passed through verbatim")
	assert.NotContains(t, tenant, "resize2fs", "eitri does not inject the grow into the tenant's document")
	assert.NotContains(t, tenant, "merge_how", "the merge directive rides in eitri's part, never in the tenant's")

	assert.Contains(t, eitri, `merge_how: "list(prepend)+dict(recurse_array,no_replace)+str()"`,
		"without this, the handler's dict(replace)+list() fallback drops whichever list was folded in first")
	assert.Contains(t, eitri, userCAPath, "the CA trust file must survive a tenant write_files")
	assert.Contains(t, eitri, "resize2fs /dev/vda1", "the online grow must survive a tenant runcmd")
	assert.Contains(t, eitri, `mode: "off"`)
}

func TestEitrisPartIsMergedInLastOrItsDirectiveGovernsNothing(t *testing.T) {
	// Part order is the whole mechanism: cloud-init folds each part into what the
	// earlier parts built, under the mergers the incoming part names. eitri's
	// document carries merge_how, so it has to be the one arriving last.
	out := t.TempDir() + "/seed.iso"
	custom := "#cloud-config\npackages: [htop]\n"
	require.NoError(t, Build(out, Params{Hostname: "byo", UserData: custom}))

	tenant, eitri := seedParts(t, out)
	assert.Equal(t, custom, tenant, "the tenant's document is the part folded in first")
	assert.Contains(t, eitri, "merge_how:", "the part folded in last is the one that names the mergers")
}

func TestBuildRefusesUserDataItCannotComposeWith(t *testing.T) {
	// A jinja template renders to a type nobody knows yet and a gzip blob is
	// opaque, so neither can carry an honest MIME part header and eitri's config
	// has nowhere to go. Refusing beats booting a guest whose CA trust file never
	// lands: that guest comes up, runs sshd, and is reachable by nobody.
	for _, ud := range []string{
		"## template: jinja\n#cloud-config\nhostname: {{ v1.local_hostname }}\n",
		"\x1f\x8b\x08 gzipped",
	} {
		err := Build(t.TempDir()+"/seed.iso", Params{Hostname: "h", UserData: ud})
		assert.Error(t, err, "Build must refuse user-data it cannot compose eitri's config with")
	}
}