/** * Supervisor-unit installer for the build-bus webhook receiver — * the `celilo subscribers serve` daemon. Linux gets a systemd unit; * macOS gets a launchd plist. Mirrors `events-daemon.ts` (the * dispatcher's installer) shape-for-shape so the two daemons read as * one family: render via `planReceiverInstall` (the CLI's `--print` * mode and the celilo-mgmt Ansible role), write via * `installReceiverDaemon`, remove via `uninstallReceiverDaemon`. * * Why this exists: celilo#1304. The receiver shipped as a hand-run * daemon — nothing in any deploy installed it, so on a management * host the dispatcher ran while the receiver did not, and * `celilo system doctor` could only say "start the receiver under a * supervisor unit". The dispatcher got an installer (ISS-0086); this * is the receiver's. * * Two differences from the dispatcher unit, both deliberate: * * 1. `--no-dispatch` is the DEFAULT. On the management plane the * standing dispatcher (`celilo-events.service`) owns dispatch; a * second dispatcher polling the same SQLite bus is the exact * two-daemons-one-bus shape that produced celilo#580/#610. Boxes * with no events daemon can render a combined unit with * `--dispatch` instead. * * 2. The unit file carries a secret (the shared HMAC secret the * publisher signs webhooks with), so it is written mode 0600 and * the secret goes in an `Environment=` line / EnvironmentVariables * dict rather than ExecStart — process args are readable in `ps`, * unit environments are not. */ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { getEventBusPath } from '../../config/paths'; import { type SupervisorPlatform, type SupervisorScope, detectPlatform, resolveCeliloPath, resolveRunAsUser, } from '../events-daemon'; export const RECEIVER_UNIT_NAME = 'celilo-build-bus-receiver.service'; export const RECEIVER_LAUNCHD_LABEL = 'com.celilo.build-bus-receiver'; export const DEFAULT_RECEIVER_PORT = 8123; export interface ReceiverInstallOptions { /** Shared HMAC secret publishers sign webhooks with. Required. */ secret?: string; /** TCP port the receiver listens on. Default 8123. */ port?: number; /** Unit scope. Defaults to `user`. */ scope?: SupervisorScope; /** * Render the COMBINED daemon (receiver + in-process dispatcher). * Default false — see the module comment for why the standing * dispatcher must own dispatch on the management plane. */ dispatch?: boolean; celiloPath?: string; /** Override platform detection. Mainly for tests. */ platform?: SupervisorPlatform; /** Override the home directory used to compute install paths. */ home?: string; /** Run-as user for system-scope units; defaults to the state-dir owner. */ runAsUser?: string; /** Prefix for system-scope paths. Test seam — see getReceiverUnitPath. */ systemRoot?: string; } export interface ReceiverInstallPlan { platform: SupervisorPlatform; scope: SupervisorScope; unitPath: string; unitContent: string; celiloPath: string; port: number; dispatch: boolean; /** Set for system scope: the user the unit runs as. */ runAsUser?: string; /** True when the OTHER scope already has a receiver unit installed. */ conflict?: { scope: SupervisorScope; unitPath: string }; nextSteps: string[]; } export interface ReceiverUninstallResult { platform: SupervisorPlatform; scope: SupervisorScope; unitPath: string; removed: boolean; nextSteps: string[]; } interface UnitInputs { celiloPath: string; port: number; secret: string; scope: SupervisorScope; dispatch: boolean; /** Optional: only the launchd user-scope log paths read it. */ home?: string; /** Required for system scope; ignored for user scope. */ runAsUser?: string; } export function getReceiverUnitPath( platform: SupervisorPlatform, home: string, scope: SupervisorScope = 'user', systemRoot = '/', ): string { if (platform === 'linux') { return scope === 'system' ? join(systemRoot, 'etc/systemd/system', RECEIVER_UNIT_NAME) : join(home, '.config', 'systemd', 'user', RECEIVER_UNIT_NAME); } return scope === 'system' ? join(systemRoot, 'Library/LaunchDaemons', `${RECEIVER_LAUNCHD_LABEL}.plist`) : join(home, 'Library', 'LaunchAgents', `${RECEIVER_LAUNCHD_LABEL}.plist`); } export function renderReceiverSystemdUnit(input: UnitInputs): string { const userLine = input.scope === 'system' && input.runAsUser ? `User=${input.runAsUser}\n` : ''; const dispatchFlag = input.dispatch ? '' : ' --no-dispatch'; const journalHint = input.scope === 'system' ? `journalctl -u ${RECEIVER_UNIT_NAME}` : `journalctl --user -u ${RECEIVER_UNIT_NAME}`; return `[Unit] Description=Celilo Build-Bus Webhook Receiver Documentation=https://github.com/psbanka/infra/blob/main/openspec/changes/build-bus-poll-cd/proposal.md After=network.target [Service] Type=simple ${userLine}ExecStart=${input.celiloPath} subscribers serve --port ${input.port}${dispatchFlag} Restart=on-failure RestartSec=10s Environment=CELILO_BUS_SECRET=${input.secret} # stdout/stderr are captured by ${journalHint}. StandardOutput=journal StandardError=journal [Install] WantedBy=${input.scope === 'system' ? 'multi-user.target' : 'default.target'} `; } export function renderReceiverLaunchdPlist(input: UnitInputs): string { const home = input.home ?? homedir(); const logDir = input.scope === 'system' ? '/Library/Logs' : join(home, 'Library', 'Logs'); const userNameBlock = input.scope === 'system' && input.runAsUser ? ` UserName\n ${input.runAsUser}\n` : ''; const dispatchFlag = input.dispatch ? '' : '\n --no-dispatch'; return ` Label ${RECEIVER_LAUNCHD_LABEL} ${userNameBlock} ProgramArguments ${input.celiloPath} subscribers serve --port ${input.port}${dispatchFlag} EnvironmentVariables CELILO_BUS_SECRET ${input.secret} RunAtLoad KeepAlive StandardOutPath ${join(logDir, 'celilo-build-bus-receiver.out.log')} StandardErrorPath ${join(logDir, 'celilo-build-bus-receiver.err.log')} `; } function otherScopeReceiverUnit( platform: SupervisorPlatform, home: string, scope: SupervisorScope, systemRoot?: string, ): { scope: SupervisorScope; unitPath: string } | undefined { const other = scope === 'user' ? 'system' : 'user'; const path = getReceiverUnitPath(platform, home, other, systemRoot); return existsSync(path) ? { scope: other, unitPath: path } : undefined; } function nextStepsFor( platform: SupervisorPlatform, scope: SupervisorScope, unitPath: string, ): string[] { if (platform === 'linux') { return scope === 'system' ? [ 'Reload systemd: sudo systemctl daemon-reload', `Enable + start: sudo systemctl enable --now ${RECEIVER_UNIT_NAME}`, `Tail logs: journalctl -u ${RECEIVER_UNIT_NAME} -f`, `Status: systemctl status ${RECEIVER_UNIT_NAME}`, ] : [ 'Reload systemd: systemctl --user daemon-reload', `Enable + start: systemctl --user enable --now ${RECEIVER_UNIT_NAME}`, `Tail logs: journalctl --user -u ${RECEIVER_UNIT_NAME} -f`, `Status: systemctl --user status ${RECEIVER_UNIT_NAME}`, ]; } return scope === 'system' ? [ `Load + start: sudo launchctl bootstrap system ${unitPath}`, 'Tail stdout: tail -f /Library/Logs/celilo-build-bus-receiver.out.log', 'Tail stderr: tail -f /Library/Logs/celilo-build-bus-receiver.err.log', `Status: sudo launchctl print system/${RECEIVER_LAUNCHD_LABEL}`, ] : [ `Load + start: launchctl load -w ${unitPath}`, 'Tail stdout: tail -f ~/Library/Logs/celilo-build-bus-receiver.out.log', 'Tail stderr: tail -f ~/Library/Logs/celilo-build-bus-receiver.err.log', `Status: launchctl list | grep ${RECEIVER_LAUNCHD_LABEL}`, ]; } /** * Resolve everything an install would do — platform, paths, run-as * user, rendered unit content — WITHOUT writing anything. This is the * single renderer behind the CLI's `--print` mode, which the * celilo-mgmt Ansible role uses (the deb wrapper runs celilo as the * unprivileged celilo user, so the role performs the root-owned write). * * The secret is REQUIRED and never defaulted: there is no safe * invented value for a shared HMAC secret, and a receiver that starts * with a secret the publisher does not know silently 401s every * delivery. Render-only calls (`--print`) refuse on a missing secret * rather than wedge the deploy that is trying to recover. */ export function planReceiverInstall(opts: ReceiverInstallOptions = {}): ReceiverInstallPlan { const platform = opts.platform ?? detectPlatform(); const scope = opts.scope ?? 'user'; const home = opts.home ?? homedir(); const secret = opts.secret; if (typeof secret !== 'string' || !secret) { throw new Error( 'celilo subscribers install-daemon: a shared secret is required (--secret or CELILO_BUS_SECRET). The publisher signs webhooks with this same value — there is no safe default.', ); } const celiloPath = resolveCeliloPath(opts.celiloPath); const port = opts.port ?? DEFAULT_RECEIVER_PORT; const dispatch = opts.dispatch ?? false; const runAsUser = scope === 'system' ? resolveRunAsUser(getEventBusPath(), opts.runAsUser) : undefined; const unitPath = getReceiverUnitPath(platform, home, scope, opts.systemRoot); const unitInputs: UnitInputs = { celiloPath, port, secret, scope, dispatch, home, runAsUser }; const unitContent = platform === 'linux' ? renderReceiverSystemdUnit(unitInputs) : renderReceiverLaunchdPlist(unitInputs); return { platform, scope, unitPath, unitContent, celiloPath, port, dispatch, runAsUser, conflict: otherScopeReceiverUnit(platform, home, scope, opts.systemRoot), nextSteps: nextStepsFor(platform, scope, unitPath), }; } /** * Write the supervisor unit file. Idempotent: rewrites if present. * Mode 0600 — the file carries the shared HMAC secret. * * System scope writes root-owned paths (/etc/systemd/system, * /Library/LaunchDaemons) — run as root or the write fails loudly. */ export function installReceiverDaemon(opts: ReceiverInstallOptions = {}): ReceiverInstallPlan { const plan = planReceiverInstall(opts); if (plan.conflict) { throw new Error( [ `celilo subscribers daemon: a ${plan.conflict.scope}-scope receiver unit is already installed at ${plan.conflict.unitPath}.`, 'Both scopes use the same unit name, so installing this one would create a SECOND receiver.', `Remove the other first: \`celilo subscribers uninstall-daemon${plan.conflict.scope === 'system' ? ' --system' : ''}\`,`, 'or keep the existing one.', ].join(' '), ); } mkdirSync(dirname(plan.unitPath), { recursive: true }); writeFileSync(plan.unitPath, plan.unitContent, { mode: 0o600 }); return plan; } export function uninstallReceiverDaemon( opts: { platform?: SupervisorPlatform; scope?: SupervisorScope; home?: string; systemRoot?: string; } = {}, ): ReceiverUninstallResult { const platform = opts.platform ?? detectPlatform(); const scope = opts.scope ?? 'user'; const home = opts.home ?? homedir(); const unitPath = getReceiverUnitPath(platform, home, scope, opts.systemRoot); let removed = false; if (existsSync(unitPath)) { unlinkSync(unitPath); removed = true; } let nextSteps: string[]; if (!removed) { nextSteps = platform === 'linux' ? ['No unit file present; nothing to clean up.'] : ['No plist present; nothing to clean up.']; } else if (platform === 'linux') { nextSteps = scope === 'system' ? [ `Disable + stop: sudo systemctl disable --now ${RECEIVER_UNIT_NAME}`, 'Reload systemd: sudo systemctl daemon-reload', ] : [ `Disable + stop: systemctl --user disable --now ${RECEIVER_UNIT_NAME}`, 'Reload systemd: systemctl --user daemon-reload', ]; } else { nextSteps = scope === 'system' ? [`Unload: sudo launchctl bootout system/${RECEIVER_LAUNCHD_LABEL}`] : [`Unload: launchctl unload ${unitPath}`]; } return { platform, scope, unitPath, removed, nextSteps }; }