a73x

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