/**
* 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 };
}