a73x

web/src/lib/fleet.test.ts

Ref:   Size: 20.0 KiB   History

import { beforeEach, describe, expect, test } from 'vitest';
import {
	capacityReading,
	fleet,
	formatUptime,
	hostBundle,
	hostNetworkOptions,
	hostStatusLabel,
	joinCommands,
	sessionSummary,
	upgradeAge,
	upgradeStuck,
	upgradeStuckNote,
	vmDetail,
	vmPower,
	vmPowerAction,
	vmNetworkAddr,
	vmNetworkAddrHint,
	vmNetworkValue,
	vmTrustStale,
	UPGRADE_STUCK_S,
	type Exposure,
	type Host,
	type UserCA,
	type VM
} from './fleet.svelte';
import type { components } from './api-types';

type TrustedCA = components['schemas']['TrustedCA'];

const alpha = 'SHA256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
const beta = 'SHA256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';

function userCA(fingerprint: string, label = 'ca'): UserCA {
	return { fingerprint, label, pubkey: `ssh-ed25519 AAAA ${label}` };
}

/** vmTrusting is a VM as the fleet page sees one, carrying the CA set recorded
 *  at its create. Pass null for a VM created before the set was recorded. */
function vmTrusting(trusted_cas: TrustedCA[] | null): VM {
	return {
		actual_power: 'running',
		assigned_ip: '10.0.0.2',
		created_at: '2026-08-11T09:00:00Z',
		deleted: false,
		destroy_at: 0,
		disk_gb: 10,
		host_id: 'host-1',
		id: 'vm-1',
		image_url: 'https://example.invalid/img.raw',
		last_error: '',
		lifecycle: 'ready',
		mem_mb: 1024,
		name: 'guest',
		network: '',
		network_ip: '',
		phase: 'ready',
		power_state: 'running',
		status: 'ready',
		status_detail: '',
		trusted_cas,
		vcpus: 1
	};
}

/** vmCreating is a VM mid-create, carrying whatever its host last said it was
 *  doing. Pass '' for a host with nothing to add — or one whose agent predates
 *  the field and never sends it. */
function vmCreating(status_detail: string): VM {
	return { ...vmTrusting(null), lifecycle: 'creating', phase: 'creating', status_detail };
}

/** vmUnreachable is a VM whose host has stopped reporting. The durable columns
 *  still hold the last thing that host said — a settled, running guest — which
 *  is exactly why the server sends lifecycle `unreachable` over the top of
 *  them, and why nothing here may read them. */
function vmUnreachable(): VM {
	return { ...vmTrusting(null), lifecycle: 'unreachable', phase: '', actual_power: '' };
}

/** exposure is one published port as its row sees it. sessions is null for a
 *  port nobody has counted: unreported, or served by an older agent. */
function exposure(sessions: Exposure['sessions']): Exposure {
	return {
		created_at: '2026-08-11T09:00:00Z',
		guest_port: 8080,
		host_addr: '192.168.0.190',
		host_id: 'host-1',
		host_port: 30080,
		id: 'x-1',
		protocol: 'tcp',
		reason: '',
		scope: 'lan',
		sessions,
		state: 'active',
		vm_id: 'vm-1'
	};
}

beforeEach(() => {
	fleet.userCAs = [];
	fleet.userCAsLoaded = false;
});

describe('vmTrustStale', () => {
	test('a guest is stale when the tenant holds a CA the guest does not', () => {
		fleet.userCAs = [userCA(alpha), userCA(beta)];
		fleet.userCAsLoaded = true;
		expect(vmTrustStale(vmTrusting([{ fingerprint: alpha, label: 'ca' }]))).toBe(true);
	});

	test('a guest holding every CA the tenant holds is current', () => {
		fleet.userCAs = [userCA(alpha), userCA(beta)];
		fleet.userCAsLoaded = true;
		expect(
			vmTrustStale(
				vmTrusting([
					{ fingerprint: alpha, label: 'ca' },
					{ fingerprint: beta, label: 'ca' }
				])
			)
		).toBe(false);
	});

	test('no guest is stale before the tenant CAs have been fetched', () => {
		fleet.userCAs = [userCA(alpha)];
		fleet.userCAsLoaded = false;
		expect(vmTrustStale(vmTrusting([]))).toBe(false);
	});

	test('a guest that recorded no set is never stale, only silent', () => {
		fleet.userCAs = [userCA(alpha)];
		fleet.userCAsLoaded = true;
		expect(vmTrustStale(vmTrusting(null))).toBe(false);
	});

	test('an unreadable fingerprint on either side is not counted as missing', () => {
		fleet.userCAs = [userCA(alpha), userCA('')];
		fleet.userCAsLoaded = true;
		expect(vmTrustStale(vmTrusting([{ fingerprint: alpha, label: 'ca' }]))).toBe(false);
		expect(
			vmTrustStale(
				vmTrusting([
					{ fingerprint: alpha, label: 'ca' },
					{ fingerprint: '', label: 'ca' }
				])
			)
		).toBe(false);
	});
});

/** hostUpgrading is a host as the console sees one mid-upgrade: online unless
 *  told otherwise, running `agent_version`, and holding an offer of `offered`
 *  made `age_s` seconds ago. Pass offered null for a host with no offer, and an
 *  os other than linux for a host that keeps its agent's log somewhere else. */
function hostUpgrading(
	agent_version: string,
	offered: { version: string; age_s: number } | null,
	online = true,
	os = 'linux'
): Host {
	return {
		agent_update_available: true,
		agent_version,
		allocated: { vcpus: 1, mem_mb: 1024, disk_gb: 10 },
		arch: 'amd64',
		bridge_cidr: '10.77.1.0/24',
		capacity: { vcpus: 8, mem_mb: 16384, disk_gb: 256 },
		cpu_model: 'AMD Ryzen 9 7950X',
		enrolled_at: '2026-08-01T09:00:00Z',
		host_networks: [],
		id: 'host-1',
		kernel: '6.15.4-arch1-1',
		last_seen: '2026-08-11T09:00:00Z',
		metrics: null,
		name: 'onyx',
		online,
		os,
		os_id: 'arch',
		os_pretty: 'Arch Linux',
		os_version: 'rolling',
		pending_upgrade: offered,
		provisioner: 'cloudhypervisor',
		seconds_since_last_seen: 2,
		sessions: 1,
		stale: false,
		status: 'active',
		uplink_addr: '192.168.0.190',
		virt: 'kvm'
	};
}

describe('upgradeStuck', () => {
	test('a host with no offer outstanding is not stuck', () => {
		expect(upgradeStuck(hostUpgrading('v0.0.5', null))).toBe(false);
	});

	test('a fresh offer is in flight, not stuck', () => {
		expect(upgradeStuck(hostUpgrading('v0.0.5', { version: 'v0.0.6', age_s: 12 }))).toBe(false);
	});

	test('an offer still standing at the threshold, on a reporting host, is stuck', () => {
		expect(
			upgradeStuck(hostUpgrading('v0.0.5', { version: 'v0.0.6', age_s: UPGRADE_STUCK_S }))
		).toBe(true);
	});

	test('an offline host is never stuck—it has not been handed the offer yet', () => {
		expect(upgradeStuck(hostUpgrading('v0.0.5', { version: 'v0.0.6', age_s: 3600 }, false))).toBe(
			false
		);
	});

	test('a host already reporting the offered version has taken it, however old the offer', () => {
		expect(upgradeStuck(hostUpgrading('v0.0.6', { version: 'v0.0.6', age_s: 3600 }))).toBe(false);
	});
});

describe('upgradeStuckNote', () => {
	test('a stuck upgrade names both versions, the wait, and the log to read', () => {
		const note = upgradeStuckNote(hostUpgrading('v0.0.5', { version: 'v0.0.6', age_s: 300 }));
		expect(note).toContain('v0.0.6');
		expect(note).toContain('v0.0.5');
		expect(note).toContain('5m');
		expect(note).toContain('journalctl -u eitri-agent');
	});

	test('an upgrade still in flight has nothing to complain about', () => {
		expect(upgradeStuckNote(hostUpgrading('v0.0.5', { version: 'v0.0.6', age_s: 12 }))).toBe('');
	});

	test('a Mac host is sent to its log file—it runs no systemd to have a journal', () => {
		const note = upgradeStuckNote(
			hostUpgrading('v0.0.5', { version: 'v0.0.6', age_s: 300 }, true, 'darwin')
		);
		expect(note).toContain('~/Library/Logs/eitri-agent.log');
		expect(note).not.toContain('journalctl');
	});

	test('a host of unnamed OS is sent to its log, without a command it may not have', () => {
		const note = upgradeStuckNote(
			hostUpgrading('v0.0.5', { version: 'v0.0.6', age_s: 300 }, true, '')
		);
		expect(note).toContain("Read the agent's log on the host for the reason");
		expect(note).not.toContain('(');
	});

	test('a host that never named its version is described, not blanked', () => {
		expect(upgradeStuckNote(hostUpgrading('', { version: 'v0.0.6', age_s: 300 }))).toContain(
			'no version'
		);
	});
});

describe('upgradeAge', () => {
	test('under a minute counts seconds', () => {
		expect(upgradeAge(0)).toBe('0s');
		expect(upgradeAge(59)).toBe('59s');
	});

	test('past a minute rounds down to whole minutes', () => {
		expect(upgradeAge(60)).toBe('1m');
		expect(upgradeAge(299)).toBe('4m');
	});

	test('past an hour says hours and minutes', () => {
		expect(upgradeAge(3600)).toBe('1h 0m');
		expect(upgradeAge(3900)).toBe('1h 5m');
	});
});

describe('formatUptime', () => {
	// The second ladder over the shared formatter: days/hours, hours/minutes,
	// then whole minutes—coarser than upgradeAge, with no seconds tier, so a
	// host up thirty seconds reads "0m" rather than counting up from a stopwatch.
	test('under an hour is whole minutes, and never seconds', () => {
		expect(formatUptime(30)).toBe('0m');
		expect(formatUptime(3599)).toBe('59m');
	});

	test('past an hour says hours and minutes', () => {
		expect(formatUptime(3600)).toBe('1h 0m');
		expect(formatUptime(7199)).toBe('1h 59m');
	});

	test('past a day says days and hours', () => {
		expect(formatUptime(86400)).toBe('1d 0h');
		expect(formatUptime(90000)).toBe('1d 1h');
	});
});

describe('hostStatusLabel', () => {
	test('an online host reads as just its status', () => {
		expect(hostStatusLabel(hostUpgrading('v0.0.5', null, true))).toBe('active');
	});

	test('an offline host carries the offline tail', () => {
		expect(hostStatusLabel(hostUpgrading('v0.0.5', null, false))).toBe('active · offline');
	});
});

describe('hostNetworkOptions', () => {
	/** advertising is one host in the fleet, named and serving `networks`. */
	function advertising(id: string, networks: string[], online = true): Host {
		return { ...hostUpgrading('v0.0.6', null, online), id, host_networks: networks };
	}

	test('the selected host offers the networks it advertised', () => {
		const hosts = [advertising('host-1', ['lan', 'lab']), advertising('host-2', ['dmz'])];
		expect(hostNetworkOptions(hosts, 'host-2')).toEqual(['dmz']);
	});

	test('a host that has gone dark still offers them', () => {
		// The regression this guards: a host flapping offline for one snapshot
		// used to empty the list, which cleared the operator's choice and sent a
		// create for a named network as a create for NAT.
		expect(hostNetworkOptions([advertising('host-1', ['lan'], false)], 'host-1')).toEqual(['lan']);
	});

	test('a host advertising nothing offers nothing', () => {
		expect(hostNetworkOptions([advertising('host-1', [])], 'host-1')).toEqual([]);
	});

	test('a host not in the fleet offers nothing', () => {
		expect(hostNetworkOptions([advertising('host-1', ['lan'])], 'host-2')).toEqual([]);
		expect(hostNetworkOptions([advertising('host-1', ['lan'])], undefined)).toEqual([]);
	});
});

describe('capacityReading', () => {
	test('a half-full host has room and a calm level', () => {
		expect(capacityReading(2, 4)).toEqual({ pct: 50, free: 2, over: 0, level: 'ok' });
	});

	test('past 90% the level turns, the same wall the load meter marks', () => {
		expect(capacityReading(7, 10).level).toBe('warm');
		expect(capacityReading(9, 10).level).toBe('hot');
	});

	test('a full host is full, not over', () => {
		expect(capacityReading(6, 6)).toEqual({ pct: 100, free: 0, over: 0, level: 'hot' });
	});

	test('an over-allocated host says by how much, and never a negative free', () => {
		expect(capacityReading(8, 6)).toEqual({ pct: 100, free: 0, over: 2, level: 'hot' });
	});

	test('the meter cannot overflow its track', () => {
		expect(capacityReading(600, 6).pct).toBe(100);
	});

	test('a host that has reported no capacity reads empty, not full', () => {
		expect(capacityReading(4, 0)).toEqual({ pct: 0, free: 0, over: 0, level: 'ok' });
	});
});

describe('hostBundle', () => {
	test('a Linux host takes the tarball named for the server it also carries', () => {
		expect(hostBundle('linux/amd64', 'v0.0.6')).toEqual({
			file: 'eitri-server_v0.0.6_linux_amd64.tar.gz',
			dir: 'eitri-server_v0.0.6_linux_amd64',
			stem: 'eitri-server',
			suffix: 'linux_amd64'
		});
		expect(hostBundle('linux/arm64', 'v0.0.6').file).toBe(
			'eitri-server_v0.0.6_linux_arm64.tar.gz'
		);
	});

	test('a Mac takes the agent bundle—no release carries a server for it', () => {
		expect(hostBundle('darwin/arm64', 'v0.0.6')).toEqual({
			file: 'eitri-agent_v0.0.6_darwin_arm64.tar.gz',
			dir: 'eitri-agent_v0.0.6_darwin_arm64',
			stem: 'eitri-agent',
			suffix: 'darwin_arm64'
		});
	});

	test('an unknown release leaves the version to the shell, never the word latest', () => {
		const b = hostBundle('linux/amd64', '');
		expect(b.file).toBe('eitri-server_${V}_linux_amd64.tar.gz');
		expect(b.file).not.toContain('latest');
	});
});

describe('joinCommands', () => {
	const blob = 'eitri_join_abc123';

	test('a Linux recipe fetches its bundle, verifies it, and starts the unit', () => {
		const cmds = joinCommands('linux/amd64', 'v0.0.6', blob);
		expect(cmds).toContain(
			'curl -fsSLO "https://eitri.sh/dl/v0.0.6/eitri-server_v0.0.6_linux_amd64.tar.gz"'
		);
		expect(cmds).toContain('sha256sum -c SHA256SUMS --ignore-missing');
		expect(cmds).toContain(`sudo eitri-agent --state-dir /var/lib/eitri-agent join ${blob}`);
		expect(cmds).toContain('sudo systemctl enable --now eitri-agent');
		expect(cmds.some((c) => c.includes('SHA256SUMS'))).toBe(true);
	});

	test('SHA256SUMS is fetched before the bundle it verifies', () => {
		const cmds = joinCommands('linux/amd64', 'v0.0.6', blob);
		expect(cmds.findIndex((c) => c.endsWith('SHA256SUMS'))).toBeLessThan(
			cmds.findIndex((c) => c.includes('.tar.gz"'))
		);
	});

	test('a Mac recipe takes the darwin agent bundle and asks for sudo nowhere', () => {
		const cmds = joinCommands('darwin/arm64', 'v0.0.6', blob);
		expect(cmds.join('\n')).toContain('eitri-agent_v0.0.6_darwin_arm64.tar.gz');
		expect(cmds.join('\n')).not.toContain('eitri-server');
		expect(cmds.join('\n')).not.toContain('sudo');
		expect(cmds).toContain('brew install vfkit');
		expect(cmds).toContain('./eitri-agent-launchagent.sh install ~/.local/bin/eitri-agent');
	});

	test('a Mac verifies with shasum—macOS ships no sha256sum', () => {
		const cmds = joinCommands('darwin/arm64', 'v0.0.6', blob);
		expect(cmds).toContain(
			'grep " eitri-agent_v0.0.6_darwin_arm64.tar.gz$" SHA256SUMS | shasum -a 256 -c -'
		);
		expect(cmds.join('\n')).not.toContain('sha256sum');
	});

	test('no systemd on a Mac, and no LaunchAgent on Linux', () => {
		expect(joinCommands('darwin/arm64', 'v0.0.6', blob).join('\n')).not.toContain('systemctl');
		expect(joinCommands('linux/amd64', 'v0.0.6', blob).join('\n')).not.toContain('launchagent');
	});

	test('an unknown release reads the version out of SHA256SUMS, per platform', () => {
		const linux = joinCommands('linux/arm64', '', blob);
		expect(linux).toContain(
			"V=$(sed -n 's/.*eitri-server_\\(v[^_]*\\)_linux_arm64\\.tar\\.gz$/\\1/p' SHA256SUMS)"
		);
		expect(linux).toContain('curl -fsSLO https://eitri.sh/dl/latest/SHA256SUMS');
		const mac = joinCommands('darwin/arm64', '', blob);
		expect(mac).toContain(
			"V=$(sed -n 's/.*eitri-agent_\\(v[^_]*\\)_darwin_arm64\\.tar\\.gz$/\\1/p' SHA256SUMS)"
		);
	});

	test('a known release is fetched from its own immutable directory', () => {
		const cmds = joinCommands('linux/amd64', 'v0.0.6', blob).join('\n');
		expect(cmds).toContain('https://eitri.sh/dl/v0.0.6/');
		expect(cmds).not.toContain('/dl/latest/');
		expect(cmds).not.toContain('${V}');
	});

	// The regress this refactor closes: the sed that reads $V out of SHA256SUMS
	// must key off the very stem hostBundle names the tarball for. Derived
	// separately, a rename in hostBundle would leave the regex matching nothing,
	// $V empty, and the recipe fetching a bundle that does not exist.
	test('the SHA256SUMS regex keys off the same stem the bundle is named for', () => {
		for (const platform of ['linux/amd64', 'linux/arm64', 'darwin/arm64'] as const) {
			const { stem, file } = hostBundle(platform, '');
			const cmds = joinCommands(platform, '', blob);
			const sed = cmds.find((c) => c.startsWith('V='));
			expect(sed).toBeDefined();
			expect(sed).toContain(`${stem}_\\(v[^_]*\\)`);
			// and the tarball the recipe then fetches carries that same stem.
			expect(file.startsWith(`${stem}_`)).toBe(true);
			expect(cmds.some((c) => c.includes(`"https://eitri.sh/dl/latest/${file}"`))).toBe(true);
		}
	});
});

describe('vmDetail', () => {
	test('a creating VM shows what its host is doing', () => {
		expect(vmDetail(vmCreating('downloading image 1.2/3.7 GiB'))).toBe(
			'downloading image 1.2/3.7 GiB'
		);
	});

	test('a host with nothing to say gets no row', () => {
		expect(vmDetail(vmCreating(''))).toBe('');
	});

	test('a settled VM says nothing, even carrying a detail', () => {
		// The sentence describes work in flight. On a ready VM it would be the
		// past presented as the present.
		expect(vmDetail({ ...vmTrusting(null), status_detail: 'booting' })).toBe('');
	});

	test('an unreachable VM explains itself', () => {
		// `unreachable` is a word about the plane, not the guest, so on its own
		// it reads as a fault of the VM. The sentence says whose silence it is.
		expect(vmDetail(vmUnreachable())).toBe("eitri can't reach this VM's host");
	});

	test('an unreachable VM does not quote what its host last said', () => {
		// The detail is the host's account of work in flight. A host that has
		// gone silent is not doing that work, and may not have been for days.
		expect(vmDetail({ ...vmUnreachable(), status_detail: 'downloading image' })).toBe(
			"eitri can't reach this VM's host"
		);
	});
});

describe('a VM whose host has gone dark', () => {
	test('its power is not reported as the last thing its host saw', () => {
		// power_state is desired state and survives the host; actual_power is an
		// observation and is absent. Printing either beside `unreachable` would
		// answer the one question the row just said it cannot.
		expect(vmPower(vmUnreachable())).toBe('—');
	});

	test('it offers no power button', () => {
		// A start/stop the plane cannot deliver is a lie in a button.
		expect(vmPowerAction(vmUnreachable())).toBe(null);
	});

	test('a reachable VM still reports its power normally', () => {
		expect(vmPower(vmTrusting(null))).toBe('running');
	});
});

describe('the two addresses a guest can have', () => {
	test('a guest with no named network has no discovered address', () => {
		expect(vmNetworkAddr(vmTrusting(null))).toBe('');
	});

	test('a guest whose network has not answered yet has no discovered address', () => {
		// The NIC exists; the address does not. A placeholder would present a
		// fact nobody has.
		expect(vmNetworkAddr({ ...vmTrusting(null), network: 'lan' })).toBe('');
	});

	test('a leftover network_ip with no network to hang it on has no discovered address', () => {
		// A stale or pre-freeze row can carry an address with an empty
		// network — that pairing is not a fact worth showing.
		expect(vmNetworkAddr({ ...vmTrusting(null), network_ip: '192.168.0.42' })).toBe('');
	});

	test('a discovered address is shown with the network that granted it', () => {
		const vm = { ...vmTrusting(null), network: 'lan', network_ip: '192.168.0.42' };
		expect(vmNetworkAddr(vm)).toBe('192.168.0.42');
		expect(vmNetworkAddrHint(vm)).toBe("(on lan, addressed by that network's DHCP)");
	});
});

describe('vmNetworkValue', () => {
	test('a guest with no named network has no value', () => {
		expect(vmNetworkValue(vmTrusting(null))).toBe('');
	});

	test('a guest whose network has not answered yet shows the name alone', () => {
		expect(vmNetworkValue({ ...vmTrusting(null), network: 'lan' })).toBe('lan');
	});

	test('a discovered address joins onto the name', () => {
		const vm = { ...vmTrusting(null), network: 'lan', network_ip: '192.168.0.42' };
		expect(vmNetworkValue(vm)).toBe('lan · 192.168.0.42');
	});
});

describe('sessionSummary', () => {
	test('a counted port says what it holds and what it has turned away', () => {
		expect(sessionSummary(exposure({ active: 7, refused: 12, dropped: 3 }))).toBe(
			'7 open · 12 refused, 3 dropped since agent start'
		);
	});

	test('a counted port with nothing to report still says so', () => {
		// Reported zeros are a fact: this port has turned nobody away.
		expect(sessionSummary(exposure({ active: 0, refused: 0, dropped: 0 }))).toBe(
			'0 open · 0 refused, 0 dropped since agent start'
		);
	});

	test('a port nobody counted says nothing rather than zero', () => {
		// An older agent, or an exposure not yet reported on. Zeros here would
		// claim the port has refused nobody, which nothing on the wire supports.
		expect(sessionSummary(exposure(null))).toBe('');
		expect(sessionSummary(exposure(undefined))).toBe('');
	});
});