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