147 lines
5.1 KiB
TypeScript
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));
|
|
}
|