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('');
});
});