feat(869-c3): lease-broker supervisor unit (Part of #869)
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 <[email protected]> Co-committed-by: jason.woltje <[email protected]>
This commit was merged in pull request #871.
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 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'
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user