fix(#792): make fleet roster errors actionable
This commit is contained in:
34
docs/scratchpads/ms-792-fleet-enoent-installer.md
Normal file
34
docs/scratchpads/ms-792-fleet-enoent-installer.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# ms-792 — Fleet roster error handling and installer heading
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Make expected missing or malformed fleet roster configuration fail with an actionable message and nonzero exit instead of a raw Node stack trace. Ensure the installer preserves the `@mosaicstack/mosaic` heading.
|
||||||
|
|
||||||
|
## Plan
|
||||||
|
|
||||||
|
1. Add failing coverage for missing and malformed roster input.
|
||||||
|
2. Centralize roster-file read and parse error translation; add the CLI async error boundary.
|
||||||
|
3. Sweep fleet command read paths that bypass the roster loader.
|
||||||
|
4. Replace the installer heading output with format-safe rendering and test it.
|
||||||
|
5. Run focused and repository quality checks; request independent review.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- 2026-07-16: Confirmed issue #792 and branch base `9745bc3f`.
|
||||||
|
- 2026-07-16: Installed locked workspace dependencies using a worktree-local pnpm store; no `.mosaic/` files were changed intentionally.
|
||||||
|
- 2026-07-16: Added a shared roster read/parse guard and routed v1 fleet commands plus v1/v2 selection through Commander’s actionable nonzero error path. V2 command modules already return structured nonzero JSON errors for their guarded reads.
|
||||||
|
- 2026-07-16: Replaced installer heading `echo` with format-safe `printf`; added a regression check for the scoped package heading.
|
||||||
|
- 2026-07-16: Rebuilt CLI and manually verified `fleet ps` with no roster prints the initialization hint, exits 1, and has no stack trace.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic test` — PASS (61 files, 1,046 tests; executed outside sandbox because CLI smoke tests spawn Node)
|
||||||
|
- `pnpm typecheck` — PASS
|
||||||
|
- `pnpm lint` — PASS
|
||||||
|
- `pnpm format:check` — PASS
|
||||||
|
- `pnpm --filter @mosaicstack/mosaic exec vitest run src/commands/fleet.spec.ts src/commands/install-heading.spec.ts` — PASS (209 tests)
|
||||||
|
- Instrumented Vitest coverage is unavailable because `@vitest/coverage-v8` is not declared in this repository. Each branch added in the roster guard has direct unit coverage.
|
||||||
|
|
||||||
|
## Risks / blockers
|
||||||
|
|
||||||
|
- Dependency installation is required before executing Vitest, TypeScript, lint, and formatting gates.
|
||||||
@@ -19,8 +19,11 @@ import * as readline from 'node:readline';
|
|||||||
import type { Command } from 'commander';
|
import type { Command } from 'commander';
|
||||||
import YAML from 'yaml';
|
import YAML from 'yaml';
|
||||||
import {
|
import {
|
||||||
|
FleetRosterConfigurationError,
|
||||||
getRosterAgent,
|
getRosterAgent,
|
||||||
loadFleetRoster,
|
loadFleetRoster,
|
||||||
|
parseFleetRosterDocument,
|
||||||
|
readFleetRosterText,
|
||||||
resolveInstalledFleetRosterPath,
|
resolveInstalledFleetRosterPath,
|
||||||
type FleetAgent,
|
type FleetAgent,
|
||||||
type FleetRoster,
|
type FleetRoster,
|
||||||
@@ -1913,7 +1916,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
const roster = await loadRosterAtPath(cmd, rosterPath);
|
||||||
|
|
||||||
const newAgent: FleetAgent = {
|
const newAgent: FleetAgent = {
|
||||||
name,
|
name,
|
||||||
@@ -1973,7 +1976,7 @@ export function registerFleetCommand(program: Command, deps: FleetCommandDeps =
|
|||||||
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const commandOpts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
const activePaths = resolveFleetPaths(commandOpts.mosaicHome);
|
||||||
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
const rosterPath = await resolveRosterPath(commandOpts.mosaicHome, commandOpts.roster);
|
||||||
const roster = await loadFleetRoster(rosterPath);
|
const roster = await loadRosterAtPath(cmd, rosterPath);
|
||||||
|
|
||||||
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
|
// Guard: throws if removing leaves 0 orchestrators or agent not in roster
|
||||||
const updatedRoster = removeAgentFromRoster(roster, name);
|
const updatedRoster = removeAgentFromRoster(roster, name);
|
||||||
@@ -2402,14 +2405,19 @@ async function installFleet(cmd: Command, frameworkRoot: string): Promise<void>
|
|||||||
|
|
||||||
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
async function loadRosterForCommand(cmd: Command): Promise<FleetRoster> {
|
||||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
return loadFleetRoster(await resolveRosterPath(opts.mosaicHome, opts.roster));
|
return loadRosterAtPath(cmd, await resolveRosterPath(opts.mosaicHome, opts.roster));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
/** Routes only a v2 roster to the M3 desired-state control plane; v1 aliases stay compatible. */
|
||||||
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
async function usesRosterV2ControlPlane(cmd: Command): Promise<boolean> {
|
||||||
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
const opts = cmd.opts<{ mosaicHome: string; roster?: string }>();
|
||||||
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
const path = await resolveRosterPath(opts.mosaicHome, opts.roster);
|
||||||
const parsed: unknown = YAML.parse(await readFile(path, 'utf8'));
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = parseFleetRosterDocument(await readFleetRosterText(path), path);
|
||||||
|
} catch (error) {
|
||||||
|
reportFleetRosterConfigurationError(cmd, error);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
typeof parsed === 'object' &&
|
typeof parsed === 'object' &&
|
||||||
parsed !== null &&
|
parsed !== null &&
|
||||||
@@ -2425,7 +2433,22 @@ async function loadRosterFromAgentCommand(
|
|||||||
): Promise<FleetRoster> {
|
): Promise<FleetRoster> {
|
||||||
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
const opts = command.optsWithGlobals<{ mosaicHome?: string; roster?: string }>();
|
||||||
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
const mosaicHome = opts.mosaicHome ?? mosaicHomeOverride ?? defaultMosaicHome();
|
||||||
return loadFleetRoster(await resolveRosterPath(mosaicHome, opts.roster));
|
return loadRosterAtPath(command, await resolveRosterPath(mosaicHome, opts.roster));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRosterAtPath(command: Command, path: string): Promise<FleetRoster> {
|
||||||
|
try {
|
||||||
|
return await loadFleetRoster(path);
|
||||||
|
} catch (error) {
|
||||||
|
reportFleetRosterConfigurationError(command, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reportFleetRosterConfigurationError(command: Command, error: unknown): never {
|
||||||
|
if (error instanceof FleetRosterConfigurationError) {
|
||||||
|
command.error(error.message, { code: 'fleet.roster', exitCode: 1 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
function resolveMosaicHomeFromCommand(command: Command, override?: string): string {
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ export interface FleetRoster {
|
|||||||
|
|
||||||
export type FleetRosterInputFormat = 'yaml' | 'json';
|
export type FleetRosterInputFormat = 'yaml' | 'json';
|
||||||
|
|
||||||
|
export class FleetRosterConfigurationError extends Error {
|
||||||
|
override name = 'FleetRosterConfigurationError';
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
export function resolveInstalledFleetRosterPath(mosaicHome: string): string {
|
||||||
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
const yamlPath = join(mosaicHome, 'fleet', 'roster.yaml');
|
||||||
try {
|
try {
|
||||||
@@ -138,8 +142,52 @@ export function parseFleetRosterV1(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
export async function loadFleetRoster(path: string): Promise<FleetRoster> {
|
||||||
const source = await readFile(path, 'utf8');
|
const source = await readFleetRosterText(path);
|
||||||
|
try {
|
||||||
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
return parseFleetRosterV1(source, path.endsWith('.json') ? 'json' : 'yaml');
|
||||||
|
} catch (error) {
|
||||||
|
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read an operator-owned roster with errors that say how to recover. */
|
||||||
|
export async function readFleetRosterText(path: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await readFile(path, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (isNodeErrorCode(error, 'ENOENT')) {
|
||||||
|
throw new FleetRosterConfigurationError(
|
||||||
|
`No fleet roster found at ${path}. Run \`mosaic fleet init\` to create one.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new FleetRosterConfigurationError(
|
||||||
|
`Could not read fleet roster at ${path}. Check the file exists and is readable.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a roster document needed only to select the v1/v2 command path. */
|
||||||
|
export function parseFleetRosterDocument(source: string, path: string): unknown {
|
||||||
|
try {
|
||||||
|
return YAML.parse(source);
|
||||||
|
} catch (error) {
|
||||||
|
if (isRosterParserError(error)) throw invalidFleetRosterError(path);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidFleetRosterError(path: string): FleetRosterConfigurationError {
|
||||||
|
return new FleetRosterConfigurationError(
|
||||||
|
`Fleet roster at ${path} is invalid. Fix the file or run \`mosaic fleet init --force\`.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRosterParserError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof SyntaxError ||
|
||||||
|
(error instanceof Error && (error.name === 'YAMLParseError' || error.name === 'YAMLWarning'))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
export function getRosterAgent(roster: FleetRoster, name: string): FleetAgent {
|
||||||
|
|||||||
@@ -212,7 +212,7 @@ ok() { echo "${G}✔${RESET} $*"; }
|
|||||||
warn() { echo "${Y}⚠${RESET} $*"; }
|
warn() { echo "${Y}⚠${RESET} $*"; }
|
||||||
fail() { echo "${R}✖${RESET} $*" >&2; }
|
fail() { echo "${R}✖${RESET} $*" >&2; }
|
||||||
dim() { echo "${DIM}$*${RESET}"; }
|
dim() { echo "${DIM}$*${RESET}"; }
|
||||||
step() { echo ""; echo "${BOLD}$*${RESET}"; }
|
step() { printf '\n%s%s%s\n' "$BOLD" "$*" "$RESET"; }
|
||||||
|
|
||||||
# ─── helpers ──────────────────────────────────────────────────────────────────
|
# ─── helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user