internal/agent/state/state_test.go
Ref: Size: 11.2 KiB History
package state
import (
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func open(t *testing.T) *Store {
t.Helper()
s, err := Open(t.TempDir())
require.NoError(t, err)
return s
}
func TestVMRecordRoundTripSurvivesReopen(t *testing.T) {
dir := t.TempDir()
s, _ := Open(dir)
rec := Record{
Spec: VMSpec{VMID: "vm1", Name: "a", ImageURL: "u", ImageSHA256: "s",
VCPUs: 2, MemMB: 2048, DiskGB: 10},
IP: "10.77.1.2", BootID: "boot-1", CreatedAt: time.Now().UTC(),
}
require.NoError(t, s.SaveVM(rec))
s2, _ := Open(dir) // simulate agent restart
got, err := s2.LoadVMs()
require.NoError(t, err)
require.Contains(t, got, "vm1")
assert.Equal(t, "10.77.1.2", got["vm1"].IP)
assert.Equal(t, int64(2048), got["vm1"].Spec.MemMB)
}
func TestEpochPersists(t *testing.T) {
dir := t.TempDir()
s, _ := Open(dir)
assert.Equal(t, uint64(0), s.Epoch(), "fresh store starts at 0")
require.NoError(t, s.SaveEpoch(42))
s2, _ := Open(dir)
assert.Equal(t, uint64(42), s2.Epoch())
}
func TestDeleteVMRemovesRecordAndDir(t *testing.T) {
s := open(t)
rec := Record{Spec: VMSpec{VMID: "vm1"}}
require.NoError(t, s.SaveVM(rec))
require.NoError(t, os.WriteFile(s.DiskPath("vm1"), []byte("disk"), 0o644))
require.NoError(t, s.DeleteVM("vm1"))
got, _ := s.LoadVMs()
assert.NotContains(t, got, "vm1")
_, err := os.Stat(s.VMDir("vm1"))
assert.True(t, os.IsNotExist(err))
}
// TestUnreadableRecordDoesNotReadAsAbsent pins the distinction Get exists to
// make. A record that is there but unparseable reports an error; only a record
// that is genuinely not there reports plain absence. The agent's reconcile loop
// answers "no record" by creating the VM, so collapsing the two would rebuild a
// live VM's disk under its running guest.
func TestUnreadableRecordDoesNotReadAsAbsent(t *testing.T) {
s := open(t)
require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1"}, BootID: "boot-1"}))
recPath := filepath.Join(s.VMDir("vm1"), "record.json")
require.NoError(t, os.WriteFile(recPath, []byte("{not json"), 0o600))
rec, ok, err := s.Get("vm1")
assert.False(t, ok, "an unparseable record yields no usable record")
assert.Error(t, err, "...but it must not read as absent")
assert.Equal(t, Record{}, rec)
rec, ok, err = s.Get("no-such-vm")
assert.False(t, ok)
assert.NoError(t, err, "a record that was never written is plain absence")
assert.Equal(t, Record{}, rec)
}
func TestMACIsDeterministicAndLocallyAdministered(t *testing.T) {
got := MAC("vm-abc123")
if got != MAC("vm-abc123") {
t.Fatalf("MAC not deterministic: %q vs %q", got, MAC("vm-abc123"))
}
// QEMU/KVM OUI prefix, lower-case hex, 6 octets.
if len(got) != 17 || got[:9] != "52:54:00:" {
t.Fatalf("unexpected MAC shape: %q", got)
}
if MAC("vm-abc123") == MAC("vm-xyz789") {
t.Fatal("distinct vmIDs produced identical MACs")
}
}
// TestNetMACGoldenValue pins the exact byte layout for one known vmID: octet
// 0 is the fixed 0x56, the remaining five are SHA-256("net1:"+vmID)[:5]. A
// full revert to the old 3-byte QEMU-OUI form is already caught below by the
// ≠0x52 assertion; what only this golden catches is a narrowed hash width —
// 0x56 followed by just three hash bytes would pass every property assertion
// in this file and fail only here.
func TestNetMACGoldenValue(t *testing.T) {
assert.Equal(t, "56:78:a6:1f:4f:7e", NetMAC("vm-abc123"))
}
// TestNetMACIsTheOtherNICOfTheSameVM pins the second NIC's identity: same
// shape, same determinism, and never the first NIC's address — one guest with
// two NICs sharing a MAC is a guest whose bridge learns the wrong port, and a
// snoop keyed on it would report the NAT lease as the LAN one. It also pins
// the OUI split from MAC(): NetMAC lands on the operator's real LAN now, so
// its first octet must carry the locally-administered bit, leave the
// multicast bit clear, and differ from 0x52 — disjoint from both MAC's space
// and every conventional 52:54:00 QEMU/libvirt guest already on that LAN.
func TestNetMACIsTheOtherNICOfTheSameVM(t *testing.T) {
got := NetMAC("vm-abc123")
assert.Equal(t, got, NetMAC("vm-abc123"), "NetMAC must be deterministic")
assert.Len(t, got, 17)
first, err := strconv.ParseUint(got[:2], 16, 8)
require.NoError(t, err)
assert.NotZero(t, first&0x02, "locally-administered bit must be set")
assert.Zero(t, first&0x01, "multicast bit must be clear (valid unicast LAA)")
assert.NotEqual(t, byte(0x52), byte(first), "must not share MAC's OUI octet")
assert.NotEqual(t, MAC("vm-abc123"), got, "a VM's two NICs must never share a MAC")
assert.NotEqual(t, NetMAC("vm-xyz789"), got, "distinct VMs must not collide either")
// Not just for one id: every VM's two NICs must differ, or a guest whose id
// happened to hash into a collision would black-hole its own traffic.
for _, id := range []string{"a", "vm-1", "0ba1a1b6-6a71-4f6f-9e5f-2c0e3a3b1f42", ""} {
assert.NotEqual(t, MAC(id), NetMAC(id), "vmID %q", id)
}
}
// TestRecordCarriesBothAddresses pins that a networked guest's two addresses
// both survive an agent restart: the NAT one to re-pin its reservation, the
// named-network one to report while the next DHCP renewal is waited for.
func TestRecordCarriesBothAddresses(t *testing.T) {
dir := t.TempDir()
s, _ := Open(dir)
require.NoError(t, s.SaveVM(Record{
Spec: VMSpec{VMID: "vm1", Network: "lan"},
IP: "10.77.1.2",
NetworkIP: "192.168.0.42",
}))
s2, _ := Open(dir)
got, err := s2.LoadVMs()
require.NoError(t, err)
require.Contains(t, got, "vm1")
assert.Equal(t, "10.77.1.2", got["vm1"].IP)
assert.Equal(t, "192.168.0.42", got["vm1"].NetworkIP)
assert.Equal(t, "lan", got["vm1"].Spec.Network)
}
func TestSerialSocketPath(t *testing.T) {
s := open(t)
p := s.SerialSocketPath("vm1")
assert.Equal(t, filepath.Join(s.VMDir("vm1"), "serial.sock"), p)
}
func TestDiskPathLocatesDiskFile(t *testing.T) {
s := open(t)
require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1"}}))
_, err := os.Stat(s.DiskPath("vm1"))
assert.True(t, os.IsNotExist(err), "record without disk: DiskPath points at a file that does not yet exist")
require.NoError(t, os.WriteFile(s.DiskPath("vm1"), []byte("disk"), 0o644))
_, err = os.Stat(s.DiskPath("vm1"))
assert.NoError(t, err)
}
// TestSaveIdentityRefusesAnUnusableTrustRoot asserts the guard from the side
// that never reaches a server. The stored fingerprint is what the sync client's
// TLS VerifyConnection compares every peer against, so "" or a typo is not a
// weaker pin — it is a pin nothing can match, and the host enrols cleanly, says
// so, and then refuses every server it dials until someone reads a certificate
// mismatch at 3am. joinblob.validate enforces the same shape on the way in;
// this is the half that makes the mistake unrepresentable on the way down.
func TestSaveIdentityRefusesAnUnusableTrustRoot(t *testing.T) {
good := strings.Repeat("a", 64)
for _, bad := range []string{
"",
strings.Repeat("a", 63),
strings.Repeat("a", 65),
strings.ToUpper(good), // hex, but the pin is compared as a lowercase string
strings.Repeat("g", 64),
"sha256:" + good,
} {
s := open(t)
err := s.SaveIdentity(Identity{HostID: "h1", Credential: "c1",
ServerQUICAddr: "10.0.0.1:8443", ServerCertSHA256: bad})
require.Error(t, err, "fingerprint %q authenticates no control plane and must never be persisted", bad)
assert.Contains(t, err.Error(), "64 lowercase hex")
_, ok := s.Identity()
assert.False(t, ok, "a refused identity must leave the agent unenrolled, not half-enrolled with %q", bad)
}
s := open(t)
require.NoError(t, s.SaveIdentity(Identity{HostID: "h1", Credential: "c1",
ServerQUICAddr: "10.0.0.1:8443", ServerCertSHA256: good}))
id, ok := s.Identity()
require.True(t, ok)
assert.Equal(t, good, id.ServerCertSHA256)
}
// TestVMSpecEqual pins the replacement for ==: VolumeIDs made the struct
// uncomparable, and reconcile decides whether a user edited a VM from this
// answer. Order is part of the spec — the first volume is /dev/vdc — so a
// reorder is an edit.
func TestVMSpecEqual(t *testing.T) {
base := VMSpec{VMID: "vm1", Name: "a", ImageURL: "u", ImageSHA256: "s",
Network: "lan", VCPUs: 2, MemMB: 2048, DiskGB: 10, VolumeIDs: []string{"va", "vb"}}
same := base
same.VolumeIDs = []string{"va", "vb"}
assert.True(t, base.Equal(same), "an identical spec compares equal through a fresh slice")
reordered := base
reordered.VolumeIDs = []string{"vb", "va"}
assert.False(t, base.Equal(reordered), "attachment order is part of the spec")
fewer := base
fewer.VolumeIDs = []string{"va"}
assert.False(t, base.Equal(fewer))
none := base
none.VolumeIDs = nil
assert.False(t, base.Equal(none))
assert.True(t, VMSpec{VMID: "vm1"}.Equal(VMSpec{VMID: "vm1"}), "no volumes either side is still equal")
// Every field, found by reflection rather than listed by hand: a field
// added to VMSpec and missed by Equal would make edits to it invisible to
// the retry budget, and a hand-written list here would miss it in exactly
// the same way.
rt := reflect.TypeOf(base)
for i := range rt.NumField() {
other := base
other.VolumeIDs = []string{"va", "vb"}
fv := reflect.ValueOf(&other).Elem().Field(i)
switch fv.Kind() {
case reflect.String:
fv.SetString("edited")
case reflect.Int64:
fv.SetInt(99)
case reflect.Slice:
fv.Set(reflect.ValueOf([]string{"edited"}))
default:
t.Fatalf("field %s has kind %s: teach this test how to edit it, then check Equal notices",
rt.Field(i).Name, fv.Kind())
}
assert.False(t, base.Equal(other), "an edit to %s must not compare equal", rt.Field(i).Name)
}
}
// TestVolumePathsSitBesideTheVMs pins the layout the durability rests on: a
// volume's directory is not inside any VM's, so destroying a VM cannot take a
// volume with it.
func TestVolumePathsSitBesideTheVMs(t *testing.T) {
s := open(t)
assert.Equal(t, filepath.Join(s.dir, "volumes"), s.VolumesDir())
assert.Equal(t, filepath.Join(s.VolumesDir(), "v1"), s.VolumeDir("v1"))
assert.Equal(t, filepath.Join(s.VolumeDir("v1"), "disk.raw"), s.VolumePath("v1"))
assert.Equal(t, filepath.Join(s.VolumeDir("v1"), "tombstoned"), s.VolumeTombstonePath("v1"))
require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1", VolumeIDs: []string{"v1"}}}))
require.NoError(t, os.MkdirAll(s.VolumeDir("v1"), 0o700))
require.NoError(t, os.WriteFile(s.VolumePath("v1"), []byte("data"), 0o600))
require.NoError(t, s.DeleteVM("vm1"))
_, err := os.Stat(s.VolumePath("v1"))
assert.NoError(t, err, "destroying a VM must not take its volumes with it")
}
// TestOpenCreatesVolumesDir: the orphan scan reads this directory every tick,
// so a fresh agent must not have to create it first.
func TestOpenCreatesVolumesDir(t *testing.T) {
s := open(t)
fi, err := os.Stat(s.VolumesDir())
require.NoError(t, err)
assert.True(t, fi.IsDir())
}
// TestVolumeIDsSurviveAReopen: the bound volumes are desired state, so they
// must be on disk like Network — a restarted agent still knows which files to
// attach.
func TestVolumeIDsSurviveAReopen(t *testing.T) {
dir := t.TempDir()
s, _ := Open(dir)
require.NoError(t, s.SaveVM(Record{Spec: VMSpec{VMID: "vm1", VolumeIDs: []string{"vb", "va"}}}))
s2, _ := Open(dir)
got, _, err := s2.Get("vm1")
require.NoError(t, err)
assert.Equal(t, []string{"vb", "va"}, got.Spec.VolumeIDs, "in attachment order")
}