Part of #869 Mos (id-11) Gate-16 merge: independent APPROVE @75235ef8 (9/9, no live host mutation), author id2 != approver id11, CI green wp1971. Co-authored-by: jason.woltje <jason@diversecanvas.com> Co-committed-by: jason.woltje <jason@diversecanvas.com>
224 lines
8.5 KiB
TypeScript
224 lines
8.5 KiB
TypeScript
/**
|
|
* Activation-side supervisor for the Mosaic lease broker (issue #869, Point-1
|
|
* C3). #828 shipped fail-closed enforcement hooks (`mutator-gate.py`,
|
|
* `receipt-observer-client.py`) with nothing that guaranteed `daemon.py` was
|
|
* running or that its socket existed before a gated runtime started. This
|
|
* module:
|
|
*
|
|
* - resolves the broker socket/state paths and the on-disk locations of the
|
|
* supervisor artifacts, deterministically and consistently with
|
|
* `defaultLeaseBrokerSocket` in `../commands/launch.ts`;
|
|
* - idempotently applies (materializes) a systemd `--user` unit plus the
|
|
* wrapper script and daemon sources it execs, mirroring the tmux fleet
|
|
* unit convention in `framework/systemd/user/`;
|
|
* - exposes a health predicate other cards (e.g. the C1 activation probe)
|
|
* can call to learn whether a broker supervisor is present and healthy.
|
|
*
|
|
* `applyBrokerSupervisor` only writes files under the paths it is given. It
|
|
* never runs `systemctl`, never starts `daemon.py`, and never touches a real
|
|
* host's `~/.config` unless the caller explicitly resolves paths there.
|
|
* Enabling/starting the unit is a separate, later, out-of-scope step.
|
|
*/
|
|
import { chmod, copyFile, mkdir, stat } from 'node:fs/promises';
|
|
import { homedir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
|
|
const UNIT_NAME = 'mosaic-lease-broker.service';
|
|
const WRAPPER_SCRIPT_NAME = 'start-lease-broker.sh';
|
|
|
|
/** Co-located modules `daemon.py` imports at runtime; kept alongside it. */
|
|
const DAEMON_SOURCE_FILE_NAMES = [
|
|
'daemon.py',
|
|
'lease_generation.py',
|
|
'normative_fragments.py',
|
|
'receipt_challenge.py',
|
|
'receipt_observer.py',
|
|
] as const;
|
|
|
|
export interface ResolveBrokerSupervisorPathsOptions {
|
|
/** `~/.config/mosaic` (or an override) — where installed tool copies live. */
|
|
mosaicHome: string;
|
|
/** Root of the checked-out `framework/` directory (canonical file source). */
|
|
frameworkRoot: string;
|
|
/** Defaults to `process.env`; pass a fake for tests. */
|
|
env?: NodeJS.ProcessEnv;
|
|
/** Defaults to `os.homedir()`; pass a temp dir in tests. */
|
|
homeDir?: string;
|
|
/** Defaults to `process.getuid()` (or 0); pass a fake for tests. */
|
|
uid?: number;
|
|
}
|
|
|
|
export interface BrokerSupervisorPaths {
|
|
readonly mosaicHome: string;
|
|
readonly frameworkRoot: string;
|
|
readonly systemdUserDir: string;
|
|
readonly leaseBrokerToolsDir: string;
|
|
readonly unitSourcePath: string;
|
|
readonly unitTargetPath: string;
|
|
readonly wrapperSourcePath: string;
|
|
readonly wrapperTargetPath: string;
|
|
readonly daemonSourcePaths: readonly string[];
|
|
readonly daemonTargetPaths: readonly string[];
|
|
/**
|
|
* Resolved with the same precedence as `defaultLeaseBrokerSocket` in
|
|
* `../commands/launch.ts`: an explicit `MOSAIC_LEASE_BROKER_SOCKET`, else
|
|
* `$XDG_RUNTIME_DIR/mosaic-lease/broker.sock`, else
|
|
* `/run/user/<uid>/mosaic-lease/broker.sock`.
|
|
*/
|
|
readonly socketPath: string;
|
|
/** Colocated next to the socket, matching the broker's own generation-file convention. */
|
|
readonly statePath: string;
|
|
}
|
|
|
|
/**
|
|
* Resolve the lease broker socket path alone, with the same precedence as
|
|
* `defaultLeaseBrokerSocket` in `../commands/launch.ts`. Exported so callers
|
|
* (and tests) can assert the two stay in agreement without importing the CLI
|
|
* command module.
|
|
*/
|
|
export function resolveLeaseBrokerSocketPath(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
uid: number = typeof process.getuid === 'function' ? process.getuid() : 0,
|
|
): string {
|
|
const explicit = env['MOSAIC_LEASE_BROKER_SOCKET'];
|
|
if (explicit) return explicit;
|
|
const runtimeDir = env['XDG_RUNTIME_DIR'];
|
|
if (runtimeDir) return join(runtimeDir, 'mosaic-lease', 'broker.sock');
|
|
return join('/run/user', String(uid), 'mosaic-lease', 'broker.sock');
|
|
}
|
|
|
|
/** Resolve every path the supervisor apply/health functions need, deterministically. */
|
|
export function resolveBrokerSupervisorPaths(
|
|
options: ResolveBrokerSupervisorPathsOptions,
|
|
): BrokerSupervisorPaths {
|
|
const { mosaicHome, frameworkRoot } = options;
|
|
const env = options.env ?? process.env;
|
|
const homeDir = options.homeDir ?? homedir();
|
|
const systemdUserDir = join(homeDir, '.config', 'systemd', 'user');
|
|
const leaseBrokerToolsDir = join(mosaicHome, 'tools', 'lease-broker');
|
|
const frameworkLeaseBrokerDir = join(frameworkRoot, 'tools', 'lease-broker');
|
|
const socketPath = resolveLeaseBrokerSocketPath(env, options.uid);
|
|
const statePath = join(dirname(socketPath), 'state.json');
|
|
|
|
return {
|
|
mosaicHome,
|
|
frameworkRoot,
|
|
systemdUserDir,
|
|
leaseBrokerToolsDir,
|
|
unitSourcePath: join(frameworkRoot, 'systemd', 'user', UNIT_NAME),
|
|
unitTargetPath: join(systemdUserDir, UNIT_NAME),
|
|
wrapperSourcePath: join(frameworkLeaseBrokerDir, WRAPPER_SCRIPT_NAME),
|
|
wrapperTargetPath: join(leaseBrokerToolsDir, WRAPPER_SCRIPT_NAME),
|
|
daemonSourcePaths: DAEMON_SOURCE_FILE_NAMES.map((name) => join(frameworkLeaseBrokerDir, name)),
|
|
daemonTargetPaths: DAEMON_SOURCE_FILE_NAMES.map((name) => join(leaseBrokerToolsDir, name)),
|
|
socketPath,
|
|
statePath,
|
|
};
|
|
}
|
|
|
|
export interface ApplyBrokerSupervisorResult {
|
|
readonly installedFiles: readonly string[];
|
|
}
|
|
|
|
/**
|
|
* Idempotently materialize the supervisor unit, its wrapper script, and the
|
|
* daemon sources it execs. Safe to call on every reseed: every write is a
|
|
* deterministic overwrite of the same target path from the same source, so a
|
|
* second call reproduces identical bytes/modes and never errors.
|
|
*
|
|
* Never runs `systemctl`; the caller decides separately whether/when to
|
|
* `daemon-reload`/`enable`/`start` the installed unit.
|
|
*/
|
|
export async function applyBrokerSupervisor(
|
|
paths: BrokerSupervisorPaths,
|
|
): Promise<ApplyBrokerSupervisorResult> {
|
|
await mkdir(paths.leaseBrokerToolsDir, { recursive: true });
|
|
await mkdir(paths.systemdUserDir, { recursive: true });
|
|
|
|
const installedFiles: string[] = [];
|
|
|
|
for (let index = 0; index < paths.daemonSourcePaths.length; index += 1) {
|
|
const source = paths.daemonSourcePaths[index];
|
|
const target = paths.daemonTargetPaths[index];
|
|
if (source === undefined || target === undefined) continue;
|
|
await copyFile(source, target);
|
|
await chmod(target, 0o644);
|
|
installedFiles.push(target);
|
|
}
|
|
|
|
await copyFile(paths.wrapperSourcePath, paths.wrapperTargetPath);
|
|
await chmod(paths.wrapperTargetPath, 0o755);
|
|
installedFiles.push(paths.wrapperTargetPath);
|
|
|
|
await copyFile(paths.unitSourcePath, paths.unitTargetPath);
|
|
await chmod(paths.unitTargetPath, 0o644);
|
|
installedFiles.push(paths.unitTargetPath);
|
|
|
|
return { installedFiles };
|
|
}
|
|
|
|
export interface BrokerSupervisorHealth {
|
|
/** Whether the systemd unit file has been materialized at its target path. */
|
|
readonly unitInstalled: boolean;
|
|
/** Whether a Unix domain socket currently exists at the resolved socket path. */
|
|
readonly socketPresent: boolean;
|
|
/**
|
|
* The signal other cards (e.g. C1's activation probe) should treat as
|
|
* "a broker supervisor is present and healthy". Presence of a live socket
|
|
* is the authoritative signal: a gated runtime can only ever succeed by
|
|
* connecting to it, so this is what fail-closed callers must check.
|
|
*/
|
|
readonly healthy: boolean;
|
|
}
|
|
|
|
/**
|
|
* Report the supervisor's on-disk/health signals. Never throws for an
|
|
* absent unit or socket — both simply report `false`; unexpected filesystem
|
|
* errors (permission issues, etc.) still propagate.
|
|
*/
|
|
export async function checkBrokerSupervisorHealth(
|
|
paths: Pick<BrokerSupervisorPaths, 'unitTargetPath' | 'socketPath'>,
|
|
): Promise<BrokerSupervisorHealth> {
|
|
const [unitInstalled, socketPresent] = await Promise.all([
|
|
pathExists(paths.unitTargetPath),
|
|
isUnixSocket(paths.socketPath),
|
|
]);
|
|
return { unitInstalled, socketPresent, healthy: socketPresent };
|
|
}
|
|
|
|
/** Convenience boolean form of {@link checkBrokerSupervisorHealth} for simple call sites. */
|
|
export async function isBrokerSupervisorHealthy(
|
|
paths: Pick<BrokerSupervisorPaths, 'unitTargetPath' | 'socketPath'>,
|
|
): Promise<boolean> {
|
|
return (await checkBrokerSupervisorHealth(paths)).healthy;
|
|
}
|
|
|
|
async function pathExists(path: string): Promise<boolean> {
|
|
try {
|
|
await stat(path);
|
|
return true;
|
|
} catch (error) {
|
|
if (isEnoent(error)) return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function isUnixSocket(path: string): Promise<boolean> {
|
|
try {
|
|
const info = await stat(path);
|
|
return info.isSocket();
|
|
} catch (error) {
|
|
if (isEnoent(error)) return false;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function isEnoent(error: unknown): boolean {
|
|
return (
|
|
typeof error === 'object' &&
|
|
error !== null &&
|
|
'code' in error &&
|
|
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
|
);
|
|
}
|