a73x

internal/agent/hostinfo/hostinfo_test.go

Ref:   Size: 11.4 KiB   History

package hostinfo

import (
	"context"
	"encoding/hex"
	"net"
	"os"
	"path/filepath"
	"slices"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func writeFixture(t *testing.T, name, content string) string {
	t.Helper()
	p := filepath.Join(t.TempDir(), name)
	require.NoError(t, os.WriteFile(p, []byte(content), 0o644))
	return p
}

func TestParseOSRelease(t *testing.T) {
	id, pretty, version := parseOSRelease(
		"PRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"\nID=debian\nVERSION_ID=\"12\"\nHOME_URL=\"x\"\n")
	assert.Equal(t, "debian", id)
	assert.Equal(t, "Debian GNU/Linux 12 (bookworm)", pretty)
	assert.Equal(t, "12", version)
}

func TestParseOSReleaseEmpty(t *testing.T) {
	id, pretty, version := parseOSRelease("")
	assert.Empty(t, id)
	assert.Empty(t, pretty)
	assert.Empty(t, version)
}

func TestParseCPUModel(t *testing.T) {
	assert.Equal(t, "AMD EPYC 7302P 16-Core Processor",
		parseCPUModel("processor\t: 0\nmodel name\t: AMD EPYC 7302P 16-Core Processor\nflags\t: fpu\n"))
	// arm-style cpuinfo has no "model name" line → empty, not a crash.
	assert.Empty(t, parseCPUModel("processor\t: 0\nCPU implementer\t: 0x41\n"))
}

func TestParseMemAvailableKB(t *testing.T) {
	kb, ok := parseMemAvailableKB("MemTotal:  8388608 kB\nMemAvailable:  4194304 kB\n")
	require.True(t, ok)
	assert.Equal(t, int64(4194304), kb)

	_, ok = parseMemAvailableKB("MemTotal: 8388608 kB\n")
	assert.False(t, ok)
}

// loadavgFixture is a real vm.loadavg reading captured from an Apple M1 on
// macOS 26.3.1. Keeping the raw bytes here is what makes a decoder for a
// platform CI never runs testable at all.
const loadavgFixture = "ae100000bf0c0000400b0000000000000008000000000000"

func TestDecodeLoadavg(t *testing.T) {
	raw, err := hex.DecodeString(loadavgFixture)
	require.NoError(t, err)

	loads, ok := decodeLoadavg(raw)
	require.True(t, ok)
	// 4270/2048, 3263/2048, 2880/2048 — the figures the host itself reported.
	assert.InDelta(t, 2.08, loads[0], 0.01)
	assert.InDelta(t, 1.59, loads[1], 0.01)
	assert.InDelta(t, 1.41, loads[2], 0.01)
}

// TestDecodeLoadavgRejectsUnknownShapes pins the best-effort contract: a
// struct that isn't the one we know yields no loads rather than nonsense ones,
// and a zero divisor never reaches a division.
func TestDecodeLoadavgRejectsUnknownShapes(t *testing.T) {
	raw, err := hex.DecodeString(loadavgFixture)
	require.NoError(t, err)

	_, ok := decodeLoadavg(raw[:16]) // truncated: no fscale
	assert.False(t, ok)

	_, ok = decodeLoadavg(append(slices.Clone(raw), 0)) // longer than we know
	assert.False(t, ok)

	zeroScale := slices.Clone(raw)
	copy(zeroScale[16:], make([]byte, 8))
	_, ok = decodeLoadavg(zeroScale)
	assert.False(t, ok)
}

// systemVersionFixture is /System/Library/CoreServices/SystemVersion.plist as
// captured from the target Mac.
const systemVersionFixture = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>BuildID</key>
	<string>E0AF3C06-11FB-11F1-A7CC-608CDE06C496</string>
	<key>ProductBuildVersion</key>
	<string>25D2128</string>
	<key>ProductCopyright</key>
	<string>1983-2026 Apple Inc.</string>
	<key>ProductName</key>
	<string>macOS</string>
	<key>ProductUserVisibleVersion</key>
	<string>26.3.1</string>
	<key>ProductVersion</key>
	<string>26.3.1</string>
	<key>iOSSupportVersion</key>
	<string>26.3</string>
</dict>
</plist>
`

func TestMacOSIdentity(t *testing.T) {
	id, pretty, version := macOSIdentity(systemVersionFixture)
	assert.Equal(t, "macos", id)
	assert.Equal(t, "macOS 26.3.1", pretty)
	assert.Equal(t, "26.3.1", version)
}

// TestMacOSIdentityBestEffort pins the package's contract on a plist that is
// missing, truncated mid-document, or from a future macOS that renamed keys:
// the OS is still identifiably a Mac, the version is simply absent.
func TestMacOSIdentityBestEffort(t *testing.T) {
	id, pretty, version := macOSIdentity("")
	assert.Equal(t, "macos", id)
	assert.Equal(t, "macOS", pretty)
	assert.Empty(t, version)

	_, _, version = macOSIdentity("<key>ProductVersion</key>\n\t<string>26.3.1")
	assert.Empty(t, version) // an unterminated value is not a value
}

// TestPlistStringMatchesWholeKeys guards the one way a substring scan can lie:
// ProductBuildVersion appears in the document BEFORE ProductVersion, and a
// looser match would report the build number as the OS version.
func TestPlistStringMatchesWholeKeys(t *testing.T) {
	assert.Equal(t, "25D2128", plistString(systemVersionFixture, "ProductBuildVersion"))
	assert.Equal(t, "26.3.1", plistString(systemVersionFixture, "ProductVersion"))
	assert.Equal(t, "26.3", plistString(systemVersionFixture, "iOSSupportVersion"))
	assert.Empty(t, plistString(systemVersionFixture, "NoSuchKey"))

	// A key whose value is not a string has no value here, and must not report
	// the next key's.
	const mixed = "<key>A</key><data>ZmY=</data><key>B</key><string>b</string>"
	assert.Empty(t, plistString(mixed, "A"))
	assert.Equal(t, "b", plistString(mixed, "B"))
}

func TestFactsReadsFixtures(t *testing.T) {
	oldOS, oldCPU, oldKern := osReleasePath, cpuInfoPath, kernelPath
	defer func() { osReleasePath, cpuInfoPath, kernelPath = oldOS, oldCPU, oldKern }()

	osReleasePath = writeFixture(t, "os-release",
		"ID=debian\nPRETTY_NAME=\"Debian GNU/Linux 12 (bookworm)\"\nVERSION_ID=\"12\"\n")
	cpuInfoPath = writeFixture(t, "cpuinfo", "model name\t: AMD EPYC 7302P 16-Core Processor\n")
	kernelPath = writeFixture(t, "osrelease", "6.1.0-18-amd64\n")

	f := Facts(context.Background(), func(_ context.Context, _ string, _ ...string) (string, error) { return "kvm", nil })
	assert.Equal(t, "debian", f.GetOsId())
	assert.Equal(t, "Debian GNU/Linux 12 (bookworm)", f.GetOsPretty())
	assert.Equal(t, "12", f.GetOsVersion())
	assert.Equal(t, "6.1.0-18-amd64", f.GetKernel())
	assert.Equal(t, "AMD EPYC 7302P 16-Core Processor", f.GetCpuModel())
	// Virt is per-platform, so its expectation lives with its platform:
	// TestFactsVirtComesFromTheRunner (Linux), TestVirtIsNoneOnDarwin (Darwin).
}

func TestFactsBestEffortOnMissingFiles(t *testing.T) {
	oldOS, oldCPU, oldKern := osReleasePath, cpuInfoPath, kernelPath
	defer func() { osReleasePath, cpuInfoPath, kernelPath = oldOS, oldCPU, oldKern }()

	osReleasePath = "/nonexistent/os-release"
	cpuInfoPath = "/nonexistent/cpuinfo"
	kernelPath = "/nonexistent/osrelease"

	f := Facts(context.Background(), nil) // must not panic; nil Runner → virt ""
	assert.Empty(t, f.GetOsId())
	assert.Empty(t, f.GetKernel())
	assert.Empty(t, f.GetCpuModel())
}

// TestBootIDIsStableWithinABoot guards the contract reconcile relies on: a
// single boot must always yield the same token, whatever the source.
func TestBootIDIsStableWithinABoot(t *testing.T) {
	a := BootID()
	b := BootID()
	if a == "" {
		t.Skip("no boot identity available in this environment")
	}
	if a != b {
		t.Errorf("BootID must be stable within a boot: %q != %q", a, b)
	}
}

// TestBootIDReadsSeam exercises the bootIDPath seam directly (rather than
// relying on the real kernel file being present/stable in CI) and asserts the
// trailing newline the kernel appends is trimmed.
func TestBootIDReadsSeam(t *testing.T) {
	old := bootIDPath
	defer func() { bootIDPath = old }()

	bootIDPath = writeFixture(t, "boot_id", "1b2c3d4e-0000-1111-2222-333344445555\n")
	assert.Equal(t, "1b2c3d4e-0000-1111-2222-333344445555", BootID())
}

// TestCapacityReportsTotals guards the contract the server relies on:
// Capacity must report host TOTALS (never free space), since the server
// derives available capacity by subtracting live VM specs from these numbers.
func TestCapacityReportsTotals(t *testing.T) {
	c := Capacity(t.TempDir())
	if c.GetVcpus() < 1 {
		t.Errorf("vcpus must be at least 1, got %d", c.GetVcpus())
	}
	if c.GetMemMb() < 1 {
		t.Errorf("mem_mb must be positive, got %d", c.GetMemMb())
	}
	if c.GetDiskGb() < 0 {
		t.Errorf("disk_gb must not be negative, got %d", c.GetDiskGb())
	}
}

// TestMetricsImplyTheCapacityTheSameProbeSees guards a coupling that reaches
// all the way to the control plane's placement refusal. The agent advertises
// this machine's totals clamped to its --max-* flags, and the clamp only lowers
// — so an advertisement BELOW the machine's real size is the server's only
// proof that an operator set a cap, and the metrics in the same report are
// where it reads that real size (see api.declaredLimit).
//
// Memory must therefore land on the nose: readMetrics computes used as
// total-minus-available, so used+available is exactly Capacity's mem_mb. Disk
// may only UNDERCOUNT — used+free omits the filesystem's reserved blocks — and
// the direction is the safe one: an undercount can fail to prove a cap, never
// invent one. Break either and the server starts refusing creates on hosts
// whose agents would have run them.
func TestMetricsImplyTheCapacityTheSameProbeSees(t *testing.T) {
	dir := t.TempDir()
	c, m := Capacity(dir), Metrics(dir)

	if m.GetMemAvailableMb() == 0 {
		t.Skip("memory probe unavailable on this machine")
	}
	assert.Equal(t, c.GetMemMb(), m.GetMemUsedMb()+m.GetMemAvailableMb(),
		"mem_used_mb + mem_available_mb must be exactly the advertised total")

	if m.GetDiskFreeGb() == 0 && m.GetDiskUsedGb() == 0 {
		t.Skip("disk probe unavailable on this machine")
	}
	assert.LessOrEqual(t, m.GetDiskUsedGb()+m.GetDiskFreeGb(), c.GetDiskGb(),
		"disk_used_gb + disk_free_gb may undercount the total, never overstate it")
}

// TestCapacityExactArithmetic and TestCapacityBestEffortOnSyscallFailure live
// in hostinfo_linux_test.go: they stub sysinfoFn/statfsFn with
// syscall.Sysinfo_t/Statfs_t literals, which only exist on Linux.

// TestBootIDMissingFile covers readBootID's error path directly (rather than
// only via the trimmed-happy-path seam test): an unreadable boot_id must
// yield "", per the package's best-effort contract, not a panic or error.
func TestBootIDMissingFile(t *testing.T) {
	old := bootIDPath
	defer func() { bootIDPath = old }()

	bootIDPath = filepath.Join(t.TempDir(), "does-not-exist")
	assert.Empty(t, BootID())
}

// TestMetricsComputes lives in hostinfo_linux_test.go: it stubs
// sysinfoFn/statfsFn with syscall.Sysinfo_t/Statfs_t literals, which only
// exist on Linux.

func TestUsableSourceAddr(t *testing.T) {
	for _, tc := range []struct {
		name string
		addr net.Addr
		want string
	}{
		{"routable v4", &net.UDPAddr{IP: net.ParseIP("192.168.0.190")}, "192.168.0.190"},
		{"routable v6", &net.UDPAddr{IP: net.ParseIP("2001:db8::1")}, "2001:db8::1"},
		{"v4-mapped v6 is unmapped", &net.UDPAddr{IP: net.ParseIP("::ffff:10.0.0.7")}, "10.0.0.7"},
		{"loopback reaches nobody", &net.UDPAddr{IP: net.ParseIP("127.0.0.1")}, ""},
		{"unspecified reaches nobody", &net.UDPAddr{IP: net.IPv4zero}, ""},
		{"link-local reaches nobody", &net.UDPAddr{IP: net.ParseIP("169.254.1.5")}, ""},
		{"not a udp address", &net.TCPAddr{IP: net.ParseIP("192.168.0.190")}, ""},
		{"no address at all", &net.UDPAddr{}, ""},
	} {
		t.Run(tc.name, func(t *testing.T) {
			if got := usableSourceAddr(tc.addr); got != tc.want {
				t.Errorf("usableSourceAddr(%v) = %q, want %q", tc.addr, got, tc.want)
			}
		})
	}
}

func TestUplinkAddr(t *testing.T) {
	// Nowhere to route to: no answer, never a guess.
	if got := UplinkAddr("not-an-address"); got != "" {
		t.Errorf("UplinkAddr(garbage) = %q, want empty", got)
	}
	// A route that resolves to loopback names an address nothing else can
	// dial, so the agent says nothing rather than sending it.
	if got := UplinkAddr("127.0.0.1:9"); got != "" {
		t.Errorf("UplinkAddr(loopback) = %q, want empty", got)
	}
}