Files
stack/packages/mosaic/src/commands/fleet-reconciler-command.ts
jason.woltje 499090508e
All checks were successful
ci/woodpecker/push/publish Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
feat(fleet): reconcile local roster state (#785)
2026-07-15 15:03:31 +00:00

147 lines
5.1 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import type { Command } from 'commander';
import type { CommandRunner } from './fleet.js';
import {
executeFleetReconcile,
FleetReconcileError,
type FleetReconcileCommand,
type FleetReconcileDeps,
} from '../fleet/fleet-reconciler.js';
import { parseRosterV2 } from '../fleet/roster-v2.js';
export interface FleetReconcilerCommandDeps {
readonly runner: CommandRunner;
readonly mosaicHome?: string;
readonly reconcileDeps?: Omit<FleetReconcileDeps, 'runner' | 'mosaicHome'>;
}
interface ReconcileOptions {
readonly expectedGeneration?: string;
readonly dryRun?: boolean;
}
/** Registers roster-v2 reconciliation commands on the canonical fleet control plane. */
export function registerFleetReconcilerCommands(
fleetCommand: Command,
deps: FleetReconcilerCommandDeps,
): void {
for (const operation of ['apply', 'reconcile'] as const) {
fleetCommand
.command(operation)
.description(`${operation} local roster-owned projections and desired lifecycle`)
.requiredOption('--expected-generation <number>', 'Authoritative roster generation')
.option('--dry-run', 'Plan and preflight without writing projections or lifecycle state')
.action(async (opts: ReconcileOptions): Promise<void> => {
await writeOutcome(async (): Promise<void> => {
await executeReconcilerCommand(fleetCommand, deps, operation, opts);
});
});
}
fleetCommand
.command('doctor')
.description('Classify local roster-owned drift without mutation')
.action(async (): Promise<void> => {
await writeOutcome(async (): Promise<void> => {
await executeReconcilerCommand(fleetCommand, deps, 'doctor', {});
});
});
}
export async function executeReconcilerCommandJson(
fleetCommand: Command,
deps: FleetReconcilerCommandDeps,
operation: FleetReconcileCommand,
opts: ReconcileOptions,
agentName?: string,
): Promise<void> {
await writeOutcome(async (): Promise<void> => {
await executeReconcilerCommand(fleetCommand, deps, operation, opts, agentName);
});
}
export async function executeReconcilerCommand(
fleetCommand: Command,
deps: FleetReconcilerCommandDeps,
operation: FleetReconcileCommand,
opts: ReconcileOptions,
agentName?: string,
): Promise<void> {
const mosaicHome = resolveMosaicHome(fleetCommand, deps);
const rosterPath = resolveRosterPath(fleetCommand, mosaicHome);
const roster = parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml');
const mutating = operation === 'apply' || operation === 'reconcile' || isLifecycle(operation);
const expectedGeneration = mutating
? parseExpectedGeneration(opts.expectedGeneration)
: undefined;
const result = await executeFleetReconcile({
roster,
command: opts.dryRun === true ? 'plan' : operation,
...(agentName === undefined ? {} : { agentName }),
...(expectedGeneration === undefined ? {} : { expectedGeneration }),
deps: {
runner: async (command: string, args: readonly string[]) => deps.runner(command, [...args]),
mosaicHome,
rolesDir: join(mosaicHome, 'fleet', 'roles'),
overrideDir: join(mosaicHome, 'fleet', 'roles.local'),
readRoster: async (): Promise<typeof roster> =>
parseRosterV2(await readFile(rosterPath, 'utf8'), 'yaml'),
...(deps.reconcileDeps ?? {}),
},
});
printJson(result);
process.exitCode = result.recovery === undefined && result.cleanup === undefined ? 0 : 1;
}
function isLifecycle(operation: FleetReconcileCommand): boolean {
return operation === 'start' || operation === 'stop' || operation === 'restart';
}
function parseExpectedGeneration(value: string | undefined): number {
const generation = Number(value);
if (!Number.isSafeInteger(generation) || generation < 1) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'--expected-generation must be a positive safe integer.',
);
}
return generation;
}
function resolveMosaicHome(fleetCommand: Command, deps: FleetReconcilerCommandDeps): string {
const options = fleetCommand.optsWithGlobals<{ mosaicHome?: string }>();
return (
options.mosaicHome ?? deps.mosaicHome ?? join(process.env['HOME'] ?? '', '.config', 'mosaic')
);
}
function resolveRosterPath(fleetCommand: Command, mosaicHome: string): string {
const options = fleetCommand.optsWithGlobals<{ roster?: string }>();
const canonical = join(mosaicHome, 'fleet', 'roster.yaml');
if (options.roster !== undefined && resolve(options.roster) !== resolve(canonical)) {
throw new FleetReconcileError(
'lifecycle-precondition-failed',
'Roster-v2 reconciliation requires the canonical roster path.',
);
}
return canonical;
}
async function writeOutcome(action: () => Promise<void>): Promise<void> {
try {
await action();
} catch (error: unknown) {
process.exitCode = 1;
printJson({
error: {
code: error instanceof FleetReconcileError ? error.code : 'reconcile-failed',
},
});
}
}
function printJson(value: object): void {
console.log(JSON.stringify(value));
}