import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getDaemonUnitPath, installDaemon, orphanDispatcherPids, planDaemonInstall, readInstalledUnit, renderLaunchdPlist, renderSystemdUnit, resolveDaemonPathEnv, resolveRestartScope, resolveRunAsUser, restartDaemon, supervisorCommands, uninstallDaemon, unitInstalledInAnyScope, } from './events-daemon'; describe('renderSystemdUnit', () => { it('produces a unit with the expected ExecStart and Environment lines', () => { const out = renderSystemdUnit({ celiloPath: '/usr/local/bin/celilo', busDbPath: '/var/lib/celilo/events.db', pollMs: 1000, concurrency: 4, home: '/home/op', scope: 'user', pathEnv: '/bun/bin:/usr/bin:/bin', }); expect(out).toContain( 'ExecStart=/usr/local/bin/celilo events run --poll-ms 1000 --concurrency 4', ); expect(out).toContain('Environment=EVENT_BUS_DB=/var/lib/celilo/events.db'); // celilo#1373 — the unit's environment must resolve bun; the global celilo // wrapper is a bash script whose first act is `command -v bun`. expect(out).toContain('Environment=PATH=/bun/bin:/usr/bin:/bin'); expect(out).toContain('Restart=on-failure'); expect(out).toContain('WantedBy=default.target'); expect(out).not.toContain('User='); }); it('honors --poll-ms and --concurrency overrides', () => { const out = renderSystemdUnit({ celiloPath: '/c', busDbPath: '/db', pollMs: 250, concurrency: 8, home: '/h', scope: 'user', pathEnv: '/bun/bin:/usr/bin', }); expect(out).toContain('--poll-ms 250 --concurrency 8'); }); it('system scope sets explicit User= and multi-user.target', () => { const out = renderSystemdUnit({ celiloPath: '/usr/local/bin/celilo', busDbPath: '/var/celilo/events.db', pollMs: 1000, concurrency: 4, home: '/root', scope: 'system', pathEnv: '/bun/bin:/usr/bin', runAsUser: 'celilo', }); expect(out).toContain('User=celilo'); expect(out).toContain('WantedBy=multi-user.target'); expect(out).toContain('journalctl -u celilo-events.service'); expect(out).not.toContain('journalctl --user'); }); }); describe('renderLaunchdPlist', () => { it('puts log files under the user Library/Logs', () => { const out = renderLaunchdPlist({ celiloPath: '/c', busDbPath: '/db', pollMs: 1000, concurrency: 4, home: '/Users/op', scope: 'user', pathEnv: '/bun/bin:/usr/bin:/bin', }); expect(out).toContain('/Users/op/Library/Logs/celilo-events.out.log'); expect(out).toContain('/Users/op/Library/Logs/celilo-events.err.log'); expect(out).toContain('Label'); expect(out).toContain('com.celilo.events'); expect(out).toContain('RunAtLoad'); expect(out).toContain('KeepAlive'); expect(out).not.toContain('UserName'); }); // celilo#1373 — launchd starts jobs with its own default PATH (no bun), // and the global celilo wrapper is a bash script whose first act is // `command -v bun`. The rendered plist MUST carry a PATH that resolves // bun, inside EnvironmentVariables, or the unit crash-loops forever. it('carries a PATH in EnvironmentVariables that resolves bun', () => { const out = renderLaunchdPlist({ celiloPath: '/Users/op/.bun/bin/celilo', busDbPath: '/db', pollMs: 1000, concurrency: 4, home: '/Users/op', scope: 'user', pathEnv: '/bun/bin:/usr/local/bin:/usr/bin:/bin', }); expect(out).toContain('PATH'); expect(out).toContain('/bun/bin:/usr/local/bin:/usr/bin:/bin'); // PATH lives INSIDE the EnvironmentVariables dict, after EVENT_BUS_DB. const envIdx = out.indexOf('EnvironmentVariables'); const pathIdx = out.indexOf('PATH'); expect(envIdx).toBeGreaterThan(-1); expect(pathIdx).toBeGreaterThan(envIdx); }); it('system scope sets UserName and logs under /Library/Logs', () => { const out = renderLaunchdPlist({ celiloPath: '/c', busDbPath: '/db', pollMs: 1000, concurrency: 4, home: '/Users/op', scope: 'system', pathEnv: '/bun/bin:/usr/bin', runAsUser: 'celilo', }); expect(out).toContain('UserName'); expect(out).toContain('celilo'); expect(out).toContain('/Library/Logs/celilo-events.out.log'); expect(out).toContain('/Library/Logs/celilo-events.err.log'); expect(out).not.toContain('/Users/op/Library/Logs'); }); }); describe('resolveDaemonPathEnv', () => { // celilo#1373 — the whole point of the PATH key is that the bun the // wrapper needs is actually in the directory we wrote. First colon // segment, and a bun executable must exist in it. it('starts with a directory that contains a bun executable', () => { const firstDir = resolveDaemonPathEnv().split(':')[0] ?? ''; expect(firstDir).not.toBe(''); expect(existsSync(join(firstDir, 'bun'))).toBe(true); }); it('planDaemonInstall pins the PATH into the rendered unit', () => { const dir = mkdtempSync(join(tmpdir(), 'celilo-pathenv-')); const celiloPath = join(dir, 'fake-celilo'); writeFileSync(celiloPath, '#!/bin/sh\n', { mode: 0o755 }); const plan = planDaemonInstall({ platform: 'darwin', scope: 'user', home: join(dir, 'home'), celiloPath, pathEnv: '/bun/bin:/usr/bin:/bin', }); expect(plan.unitContent).toContain('/bun/bin:/usr/bin:/bin'); }); }); describe('getDaemonUnitPath', () => { it('returns the systemd user path on linux', () => { expect(getDaemonUnitPath('linux', '/home/op')).toBe( '/home/op/.config/systemd/user/celilo-events.service', ); }); it('returns the LaunchAgents path on darwin', () => { expect(getDaemonUnitPath('darwin', '/Users/op')).toBe( '/Users/op/Library/LaunchAgents/com.celilo.events.plist', ); }); it('returns the system paths for system scope', () => { expect(getDaemonUnitPath('linux', '/home/op', 'system')).toBe( '/etc/systemd/system/celilo-events.service', ); expect(getDaemonUnitPath('darwin', '/Users/op', 'system')).toBe( '/Library/LaunchDaemons/com.celilo.events.plist', ); }); }); describe('resolveRunAsUser', () => { it('honors an explicit override', () => { expect(resolveRunAsUser('/var/celilo/events.db', 'celilo')).toBe('celilo'); }); it('falls back to the current user when the state dir is missing', () => { const me = resolveRunAsUser('/no/such/dir/events.db'); expect(me.length).toBeGreaterThan(0); }); }); describe('installDaemon / uninstallDaemon roundtrip', () => { let dir: string; let celiloPath: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'daemon-test-')); celiloPath = join(dir, 'fake-celilo'); // Touch a fake celilo executable so resolveCeliloPath via override is happy. writeFileSync(celiloPath, '#!/bin/sh\nexit 0\n'); chmodSync(celiloPath, 0o755); }); afterEach(() => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ } }); it('writes a systemd unit and uninstall removes it', () => { const home = join(dir, 'home'); const installed = installDaemon({ platform: 'linux', home, celiloPath, busDbPath: '/var/lib/celilo/events.db', }); expect(existsSync(installed.unitPath)).toBe(true); expect(installed.scope).toBe('user'); expect(installed.runAsUser).toBeUndefined(); expect(installed.unitPath).toBe(join(home, '.config/systemd/user/celilo-events.service')); expect(installed.nextSteps[0]).toContain('systemctl --user daemon-reload'); const removed = uninstallDaemon({ platform: 'linux', home }); expect(removed.removed).toBe(true); expect(existsSync(installed.unitPath)).toBe(false); }); // #610 — install-daemon defaults to USER scope, so running it on a box whose // system unit Ansible already installed silently produced a second daemon of // the same name. That is how celilo-mgr ended up with two dispatchers. it('refuses to install when the other scope already has a unit', () => { const home = join(dir, 'home'); const systemRoot = join(dir, 'root'); installDaemon({ platform: 'linux', scope: 'system', home, systemRoot, celiloPath, busDbPath: '/var/lib/celilo/events.db', }); expect(() => installDaemon({ platform: 'linux', scope: 'user', home, systemRoot, celiloPath, busDbPath: '/var/lib/celilo/events.db', }), ).toThrow(/system-scope unit is already installed/); // Nothing written: refusing must not leave the second unit behind. expect(existsSync(getDaemonUnitPath('linux', home, 'user'))).toBe(false); }); // --print creates nothing, and the celilo-mgmt Ansible role captures it — // throwing there would wedge the deploy of the tool used to fix the conflict. it('reports the conflict from planDaemonInstall without throwing', () => { const home = join(dir, 'home'); const systemRoot = join(dir, 'root'); installDaemon({ platform: 'linux', scope: 'system', home, systemRoot, celiloPath, busDbPath: '/var/lib/celilo/events.db', }); const plan = planDaemonInstall({ platform: 'linux', scope: 'user', home, systemRoot, celiloPath, busDbPath: '/var/lib/celilo/events.db', }); expect(plan.conflict?.scope).toBe('system'); expect(plan.unitContent).toContain('ExecStart='); }); it('writes a launchd plist and uninstall removes it', () => { const home = join(dir, 'home'); const installed = installDaemon({ platform: 'darwin', home, celiloPath, busDbPath: '/Users/op/celilo/events.db', }); expect(existsSync(installed.unitPath)).toBe(true); expect(installed.unitPath).toBe(join(home, 'Library/LaunchAgents/com.celilo.events.plist')); expect(readFileSync(installed.unitPath, 'utf-8')).toContain('com.celilo.events'); expect(installed.nextSteps[0]).toContain('launchctl load'); const removed = uninstallDaemon({ platform: 'darwin', home }); expect(removed.removed).toBe(true); }); it('install is idempotent — second call overwrites with new content', () => { const home = join(dir, 'home'); installDaemon({ platform: 'linux', home, celiloPath, busDbPath: '/db1', pollMs: 1000, }); const second = installDaemon({ platform: 'linux', home, celiloPath, busDbPath: '/db2', pollMs: 500, }); const written = readFileSync(second.unitPath, 'utf-8'); expect(written).toContain('EVENT_BUS_DB=/db2'); expect(written).toContain('--poll-ms 500'); expect(written).not.toContain('EVENT_BUS_DB=/db1'); }); it('uninstall on a missing unit reports not-removed', () => { const home = join(dir, 'home'); const result = uninstallDaemon({ platform: 'linux', home }); expect(result.removed).toBe(false); expect(result.nextSteps[0]).toContain('nothing to clean up'); }); it('readInstalledUnit returns content when present, exists:false when not', () => { const home = join(dir, 'home'); const before = readInstalledUnit({ platform: 'linux', home }); expect(before.exists).toBe(false); installDaemon({ platform: 'linux', home, celiloPath, busDbPath: '/db', }); const after = readInstalledUnit({ platform: 'linux', home }); expect(after.exists).toBe(true); if (after.exists) { expect(after.content).toContain('events run'); } }); it('rejects --celilo-path overrides that do not exist', () => { expect(() => installDaemon({ platform: 'linux', home: join(dir, 'home'), celiloPath: '/no/such/celilo', busDbPath: '/db', }), ).toThrow(/does not exist/); }); }); // --- restart (celilo#604) --------------------------------------------- /** * A fake bus + supervisor. `restartUnit()` only produces a new dispatcher if no * live one is left — that IS the one-dispatcher-per-bus guard (#584), and it's * what makes the orphan case fatal rather than merely untidy. */ function fakeFleet(opts: { initial: Array<{ pid: number; version: string | null; supervised?: boolean }>; newVersion: string; }) { let live = opts.initial.map((d) => ({ ...d })); const supervised = opts.initial.find((d) => d.supervised); const killed: number[] = []; let nextPid = 9000; let restarts = 0; return { killed, restarts: () => restarts, deps: { liveDispatchers: () => live.map((d) => ({ pid: d.pid, version: d.version })), supervisorPid: () => supervised?.pid ?? null, kill: (pid: number) => { killed.push(pid); live = live.filter((d) => d.pid !== pid); }, restartUnit: () => { restarts++; live = live.filter((d) => d.pid !== supervised?.pid); if (live.length > 0) return; // guard refuses: another dispatcher is live live = [{ pid: nextPid++, version: opts.newVersion }]; }, sleep: async () => {}, }, }; } describe('orphanDispatcherPids', () => { it('names every live dispatcher the supervisor does not own', () => { expect(orphanDispatcherPids([{ pid: 100 }, { pid: 200 }], 200)).toEqual([100]); }); it('treats all of them as orphans when the supervisor owns none', () => { expect(orphanDispatcherPids([{ pid: 100 }, { pid: 200 }], null)).toEqual([100, 200]); }); }); describe('restartDaemon', () => { let dir: string; let home: string; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'celilo-restart-')); home = join(dir, 'home'); installDaemon({ platform: 'linux', home, celiloPath: '/bin/sh', busDbPath: '/db' }); }); afterEach(() => rmSync(dir, { recursive: true, force: true })); it('stops an orphan the supervisor does not own, then brings up new code', async () => { // celilo-mgr exactly: PPID 1, stale v0.1.8, systemd owns nothing. const fleet = fakeFleet({ initial: [{ pid: 3639051, version: '0.1.8' }], newVersion: '0.2.0', }); const result = await restartDaemon( { platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0 }, fleet.deps, ); expect(fleet.killed).toContain(3639051); expect(result.orphansKilled).toEqual([3639051]); expect(result.dispatcher.version).toBe('0.2.0'); expect(result.dispatcher.pid).not.toBe(3639051); }); it('does not kill the dispatcher the supervisor already owns', async () => { const fleet = fakeFleet({ initial: [{ pid: 4242, version: '0.1.8', supervised: true }], newVersion: '0.2.0', }); const result = await restartDaemon( { platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0 }, fleet.deps, ); expect(fleet.killed).toEqual([]); expect(result.orphansKilled).toEqual([]); expect(result.dispatcher.version).toBe('0.2.0'); }); it('fails when the restarted dispatcher still reports the OLD version', async () => { // systemctl returned 0, a dispatcher is live — and it is the stale code. // The whole point: never report success off the supervisor's exit code. const fleet = fakeFleet({ initial: [{ pid: 3639051, version: '0.1.8' }], newVersion: '0.1.8', }); await expect( restartDaemon( { platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0, timeoutMs: 5 }, fleet.deps, ), ).rejects.toThrow(/no dispatcher on v0\.2\.0 came up/); }); it('fails rather than reporting success when nothing comes back at all', async () => { const fleet = fakeFleet({ initial: [], newVersion: '0.2.0' }); fleet.deps.restartUnit = () => {}; // unit crash-loops; bus stays empty await expect( restartDaemon( { platform: 'linux', home, expectedVersion: '0.2.0', pollMs: 0, timeoutMs: 5 }, fleet.deps, ), ).rejects.toThrow(/no dispatcher is live on the bus/); }); it('refuses when no supervisor unit is installed', () => { expect(() => resolveRestartScope({ platform: 'linux', home: join(dir, 'empty') })).toThrow( /no supervisor unit installed/, ); }); // apt-upgrade runs restart-daemon on every box, including ones that never // installed the daemon. The CLI branches on this so an upgrade with nothing // stale to fix is not failed by a missing unit. it('unitInstalledInAnyScope sees an installed unit, and its absence', () => { expect(unitInstalledInAnyScope('linux', home)).toBe(true); expect(unitInstalledInAnyScope('linux', join(dir, 'empty'))).toBe(false); }); }); describe('supervisorCommands', () => { it('uses systemctl --user for user scope', () => { expect(supervisorCommands('linux', 'user').restart).toEqual([ 'systemctl', '--user', 'restart', 'celilo-events.service', ]); }); // The system unit is root-owned and celilo is unprivileged. Drop the sudo // and every apt-upgrade on celilo-mgr reports "dispatcher still on old // code" — honest, and never able to do its job. celilo-bootstrap ships the // scoped grant for exactly these two argvs, so they must match it verbatim. it('goes through sudo for system scope, matching the shipped sudoers grant', () => { expect(supervisorCommands('linux', 'system').restart).toEqual([ 'sudo', 'systemctl', 'restart', 'celilo-events.service', ]); expect(supervisorCommands('linux', 'system').mainPid).toEqual([ 'sudo', 'systemctl', 'show', 'celilo-events.service', '-p', 'MainPID', '--value', ]); }); it('the shipped sudoers grant covers exactly the argvs used', () => { const grant = readFileSync( join( import.meta.dir, '../../../../packaging/celilo-bootstrap/conffiles/sudoers.d-celilo-events-restart', ), 'utf-8', ); const cmds = supervisorCommands('linux', 'system'); for (const argv of [cmds.restart, cmds.mainPid as string[]]) { // `sudo` itself is the invoker, not part of the granted command. expect(grant).toContain(`/usr/bin/${argv.slice(1).join(' ')}`); } }); });