feat(config): installation minimal-subset — schema, adapters, core, render (CONFIGIMPL) #1469
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* Read-only adapters for the installation config pipeline (§16).
|
||||||
|
*
|
||||||
|
* Every adapter is a bounded, side-effect-free read. The registry resolver
|
||||||
|
* is DEPENDENCY-BLOCKED (§2.2): its stub returns CONFIG_REGISTRY_INVALID
|
||||||
|
* with the blocked-interface marker. When the reviewed resolver ships, the
|
||||||
|
* stub binds to it without schema changes.
|
||||||
|
*/
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import { execSync } from 'node:child_process';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type ConfigDiagnostic,
|
||||||
|
type RegistryProvenance,
|
||||||
|
type InstallationBindings,
|
||||||
|
type FrameworkBindingDefaults,
|
||||||
|
FRAMEWORK_BINDING_DEFAULTS,
|
||||||
|
REGISTRY_RESOLVER_BLOCKED,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// ─── Digest helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** SHA-256 over canonical JSON with recursively sorted keys (§9). */
|
||||||
|
export function digestCanonical(value: unknown): string {
|
||||||
|
const canonical = JSON.stringify(sortKeysDeep(value));
|
||||||
|
return crypto.createHash('sha256').update(canonical).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortKeysDeep(value: unknown): unknown {
|
||||||
|
if (value === null || typeof value !== 'object') return value;
|
||||||
|
if (Array.isArray(value)) return value.map(sortKeysDeep);
|
||||||
|
const sorted: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||||
|
sorted[key] = sortKeysDeep((value as Record<string, unknown>)[key]);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function digestBytes(content: string | Buffer): string {
|
||||||
|
return crypto.createHash('sha256').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 1. Registry resolver adapter (§16, dependency-blocked) ──────────────────
|
||||||
|
|
||||||
|
export interface RegistryAdapter {
|
||||||
|
resolve(): { provenance: RegistryProvenance; diagnostics: ConfigDiagnostic[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEPENDENCY GATE (§2.2): the approved MosaicRegistryResolver does not exist
|
||||||
|
* at the pinned baseline. This stub returns the blocked marker. When the
|
||||||
|
* reviewed resolver ships (CFG-REQ-001..006), replace this stub's resolve()
|
||||||
|
* to delegate to it. The interface is stable.
|
||||||
|
*/
|
||||||
|
export class BlockedRegistryAdapter implements RegistryAdapter {
|
||||||
|
resolve(): { provenance: RegistryProvenance; diagnostics: ConfigDiagnostic[] } {
|
||||||
|
return {
|
||||||
|
provenance: {
|
||||||
|
resolved: false,
|
||||||
|
brainHome: null,
|
||||||
|
sourceKeys: [],
|
||||||
|
},
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
code: 'CONFIG_REGISTRY_INVALID',
|
||||||
|
message: REGISTRY_RESOLVER_BLOCKED,
|
||||||
|
retryable: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 2. Bounded file reader (§14.1, §14.2) ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface BoundedReadResult {
|
||||||
|
ok: boolean;
|
||||||
|
content?: string;
|
||||||
|
diagnostics: ConfigDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function boundedRead(filePath: string, context: string): BoundedReadResult {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stat = fs.lstatSync(filePath);
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||||
|
message: `${context}: symlink input rejected`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||||
|
message: `${context}: not a regular file`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
if (stat.size > 1024 * 1024) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||||
|
message: `${context}: file exceeds 1 MiB limit`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
return { ok: true, content, diagnostics: [] };
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && 'code' in e && e.code === 'ENOENT') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_MISSING',
|
||||||
|
message: `${context}: file not found at ${filePath}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_ADAPTER_UNAVAILABLE',
|
||||||
|
message: `${context}: read error: ${e instanceof Error ? e.message : String(e)}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 3. Bindings resolution (§5.2, §7, A5) ───────────────────────────────────
|
||||||
|
|
||||||
|
export interface ResolvedBindings {
|
||||||
|
runtime: string;
|
||||||
|
runtimeByClass: Record<string, string>;
|
||||||
|
workingDirectory: string;
|
||||||
|
source: 'file' | 'framework-default';
|
||||||
|
digest: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBindings(
|
||||||
|
bindings: InstallationBindings | null,
|
||||||
|
frameworkDefaults?: FrameworkBindingDefaults,
|
||||||
|
): ResolvedBindings {
|
||||||
|
const defaults = frameworkDefaults ?? FRAMEWORK_BINDING_DEFAULTS;
|
||||||
|
if (!bindings) {
|
||||||
|
return {
|
||||||
|
runtime: defaults.runtime,
|
||||||
|
runtimeByClass: {},
|
||||||
|
workingDirectory: defaults.workingDirectory,
|
||||||
|
source: 'framework-default',
|
||||||
|
digest: digestCanonical(defaults),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleet = bindings.spec.fleet;
|
||||||
|
return {
|
||||||
|
runtime: fleet.runtime?.default ?? defaults.runtime,
|
||||||
|
runtimeByClass: fleet.runtime?.byClass ?? {},
|
||||||
|
workingDirectory: fleet.workingDirectory ?? defaults.workingDirectory,
|
||||||
|
source: 'file',
|
||||||
|
digest: digestCanonical(bindings),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 4. Git tracking/ignore probe (§7.2) ────────────────────────────────────
|
||||||
|
|
||||||
|
export function isBindingsIgnored(bindingsPath: string, repoRoot: string): boolean {
|
||||||
|
try {
|
||||||
|
const relative = path.relative(repoRoot, bindingsPath);
|
||||||
|
if (relative.startsWith('..')) return true; // outside repo = not tracked
|
||||||
|
|
||||||
|
// Check if the file is tracked
|
||||||
|
try {
|
||||||
|
execSync(`git ls-files --error-unmatch "${relative}"`, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||||
|
});
|
||||||
|
return false; // tracked = NOT ignored
|
||||||
|
} catch {
|
||||||
|
// Not tracked; check if ignored
|
||||||
|
try {
|
||||||
|
execSync(`git check-ignore "${relative}"`, {
|
||||||
|
cwd: repoRoot,
|
||||||
|
stdio: 'pipe',
|
||||||
|
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
|
||||||
|
});
|
||||||
|
return true; // check-ignore succeeded = ignored
|
||||||
|
} catch {
|
||||||
|
return false; // not tracked but not ignored either = fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return true; // no git evidence = treat as ignored (valid absence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 5. Blueprint path resolver ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function resolveBlueprintPath(brainHome: string): string {
|
||||||
|
return path.join(brainHome, 'fleet', 'configuration', 'installation.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveBindingsPath(brainHome: string): string {
|
||||||
|
return path.join(brainHome, 'config', 'installation.local.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRosterPath(brainHome: string): string {
|
||||||
|
return path.join(brainHome, 'fleet', 'roster.yaml');
|
||||||
|
}
|
||||||
@@ -0,0 +1,483 @@
|
|||||||
|
/**
|
||||||
|
* InstallationConfigCore — shared pure pipeline for validate and plan (§9).
|
||||||
|
*
|
||||||
|
* Both commands call this core in the same order. No mutation, no network,
|
||||||
|
* no subprocess. The core is deterministic: identical inputs → identical
|
||||||
|
* outputs (except the caller-supplied or generated correlation ID).
|
||||||
|
*/
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
type InstallationBlueprint,
|
||||||
|
type InstallationBindings,
|
||||||
|
type ConfigDiagnostic,
|
||||||
|
type ConfigValidationDataV1,
|
||||||
|
type ConfigPlanDataV1,
|
||||||
|
type ConfigPlanActionV1,
|
||||||
|
type ConfigPlanFieldDiff,
|
||||||
|
EXIT_OK,
|
||||||
|
EXIT_INVALID,
|
||||||
|
EXIT_NONCONFORMANT,
|
||||||
|
EXIT_UNAVAILABLE,
|
||||||
|
BOOTSTRAP_MINIMAL_V1,
|
||||||
|
} from './types.js';
|
||||||
|
import { loadStrictYaml, validateBlueprint, validateBindings } from './schema.js';
|
||||||
|
import {
|
||||||
|
digestCanonical,
|
||||||
|
digestBytes,
|
||||||
|
boundedRead,
|
||||||
|
resolveBindings,
|
||||||
|
resolveBlueprintPath,
|
||||||
|
resolveBindingsPath,
|
||||||
|
resolveRosterPath,
|
||||||
|
isBindingsIgnored,
|
||||||
|
type RegistryAdapter,
|
||||||
|
} from './adapters.js';
|
||||||
|
|
||||||
|
// ─── Pipeline input/output ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CoreInput {
|
||||||
|
registryAdapter: RegistryAdapter;
|
||||||
|
/** Explicit blueprint file path, or null to use --preset. */
|
||||||
|
filePath: string | null;
|
||||||
|
/** Preset ID, or null to use --file. */
|
||||||
|
presetId: string | null;
|
||||||
|
/** Resolved brainHome (from registry). */
|
||||||
|
brainHome: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CoreResult {
|
||||||
|
exitCode: number;
|
||||||
|
diagnostics: ConfigDiagnostic[];
|
||||||
|
validationData?: ConfigValidationDataV1;
|
||||||
|
planData?: ConfigPlanDataV1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── The pipeline (§9, steps 1-11) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export function runPipeline(input: CoreInput, mode: 'validate' | 'plan'): CoreResult {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
|
||||||
|
// Step 1: resolve central registry
|
||||||
|
const registryResult = input.registryAdapter.resolve();
|
||||||
|
diagnostics.push(...registryResult.diagnostics);
|
||||||
|
if (diagnostics.some((d) => d.code === 'CONFIG_REGISTRY_INVALID')) {
|
||||||
|
return { exitCode: EXIT_UNAVAILABLE, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const brainHome = input.brainHome;
|
||||||
|
|
||||||
|
// Step 2: select and bounded-read blueprint or preset
|
||||||
|
let blueprintContent: string;
|
||||||
|
let blueprintSource: 'file' | 'preset';
|
||||||
|
let blueprintId: string;
|
||||||
|
|
||||||
|
if (input.presetId) {
|
||||||
|
if (input.presetId !== BOOTSTRAP_MINIMAL_V1.id) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_PRESET_UNKNOWN' as const,
|
||||||
|
message: `Unknown preset '${input.presetId}'. Available: ${BOOTSTRAP_MINIMAL_V1.id}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
blueprintContent = JSON.stringify(BOOTSTRAP_MINIMAL_V1.blueprint, null, 2);
|
||||||
|
blueprintSource = 'preset';
|
||||||
|
blueprintId = BOOTSTRAP_MINIMAL_V1.id;
|
||||||
|
} else {
|
||||||
|
const bpPath = input.filePath ?? resolveBlueprintPath(brainHome);
|
||||||
|
const readResult = boundedRead(bpPath, 'blueprint');
|
||||||
|
if (!readResult.ok) {
|
||||||
|
diagnostics.push(...readResult.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
blueprintContent = readResult.content!;
|
||||||
|
blueprintSource = 'file';
|
||||||
|
blueprintId = bpPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blueprintDigest = digestBytes(blueprintContent);
|
||||||
|
|
||||||
|
// Step 3: bounded-read optional bindings
|
||||||
|
const bindingsPath = resolveBindingsPath(brainHome);
|
||||||
|
const bindingsRead = boundedRead(bindingsPath, 'bindings');
|
||||||
|
let bindings: InstallationBindings | null = null;
|
||||||
|
let bindingsDigest: string;
|
||||||
|
|
||||||
|
if (bindingsRead.ok && bindingsRead.content) {
|
||||||
|
// Step 4 (bindings): parse strict YAML + validate schema
|
||||||
|
const bYaml = loadStrictYaml(bindingsRead.content, 'bindings');
|
||||||
|
if (!bYaml.ok) {
|
||||||
|
diagnostics.push(...bYaml.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
const bValid = validateBindings(bYaml.value, 'bindings');
|
||||||
|
if (!bValid.ok) {
|
||||||
|
diagnostics.push(...bValid.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
bindings = bValid.bindings!;
|
||||||
|
|
||||||
|
// §7.2: bindings must be ignored/untracked
|
||||||
|
if (!isBindingsIgnored(bindingsPath, brainHome)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_NOT_IGNORED',
|
||||||
|
message: `Host bindings at ${bindingsPath} are tracked or not ignored`,
|
||||||
|
path: bindingsPath,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
bindingsDigest = digestCanonical(bindings);
|
||||||
|
} else if (bindingsRead.diagnostics.some((d) => d.code === 'CONFIG_BLUEPRINT_MISSING')) {
|
||||||
|
// Absent bindings = valid (§7.1)
|
||||||
|
bindingsDigest = digestCanonical(null);
|
||||||
|
} else {
|
||||||
|
diagnostics.push(...bindingsRead.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4 (blueprint): parse strict YAML + validate schema
|
||||||
|
const yamlResult = loadStrictYaml(blueprintContent, 'blueprint');
|
||||||
|
if (!yamlResult.ok) {
|
||||||
|
diagnostics.push(...yamlResult.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
const bpValid = validateBlueprint(yamlResult.value, 'blueprint');
|
||||||
|
if (!bpValid.ok) {
|
||||||
|
diagnostics.push(...bpValid.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
const blueprint = bpValid.blueprint!;
|
||||||
|
|
||||||
|
// Step 5: load and validate the selected profile
|
||||||
|
// (delegates to the existing profile loader — this is the resolution step
|
||||||
|
// that would call the profile adapter; for the dependency-gated v1 we
|
||||||
|
// accept the profile reference as structurally valid and mark semantic
|
||||||
|
// resolution as notChecked)
|
||||||
|
const profileId = blueprint.spec.fleet.profile;
|
||||||
|
const profileDigest = digestCanonical({ profile: profileId });
|
||||||
|
|
||||||
|
// Step 6: resolve permitted host bindings
|
||||||
|
const resolved = resolveBindings(bindings);
|
||||||
|
|
||||||
|
// Step 7: generate the desired roster in memory
|
||||||
|
// (pure profile-to-roster generation — delegates to the adapter; for the
|
||||||
|
// dependency-gated v1 we mark this as notChecked since the generator
|
||||||
|
// depends on the full profile catalog)
|
||||||
|
const desiredRoster = {
|
||||||
|
version: 1,
|
||||||
|
transport: 'tmux',
|
||||||
|
tmux: { socket_name: 'mosaic-fleet' },
|
||||||
|
defaults: { working_directory: resolved.workingDirectory },
|
||||||
|
agents: [] as Array<Record<string, unknown>>,
|
||||||
|
};
|
||||||
|
const desiredRosterDigest = digestCanonical(desiredRoster);
|
||||||
|
|
||||||
|
// Step 8: bounded-read and validate observed roster
|
||||||
|
const rosterPath = resolveRosterPath(brainHome);
|
||||||
|
const rosterRead = boundedRead(rosterPath, 'roster');
|
||||||
|
let observedRoster: Record<string, unknown> | null = null;
|
||||||
|
let observedRosterDigest: string | null = null;
|
||||||
|
|
||||||
|
if (rosterRead.ok && rosterRead.content) {
|
||||||
|
const rYaml = loadStrictYaml(rosterRead.content, 'roster');
|
||||||
|
if (!rYaml.ok) {
|
||||||
|
diagnostics.push(...rYaml.diagnostics);
|
||||||
|
return { exitCode: EXIT_INVALID, diagnostics };
|
||||||
|
}
|
||||||
|
observedRoster = rYaml.value as Record<string, unknown>;
|
||||||
|
observedRosterDigest = digestCanonical(observedRoster);
|
||||||
|
} else if (rosterRead.diagnostics.some((d) => d.code === 'CONFIG_BLUEPRINT_MISSING')) {
|
||||||
|
// Missing roster = valid observed absence (§10.1)
|
||||||
|
observedRoster = null;
|
||||||
|
observedRosterDigest = null;
|
||||||
|
} else {
|
||||||
|
diagnostics.push(...rosterRead.diagnostics);
|
||||||
|
return { exitCode: EXIT_UNAVAILABLE, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 9-10: normalize and compare semantically
|
||||||
|
const conformant = observedRoster !== null && observedRosterDigest === desiredRosterDigest;
|
||||||
|
|
||||||
|
// Step 11: render
|
||||||
|
const checks = [
|
||||||
|
{ id: 'registry-resolution', status: 'passed' as const },
|
||||||
|
{ id: 'blueprint-schema', status: 'passed' as const },
|
||||||
|
{ id: 'bindings-schema', status: 'passed' as const },
|
||||||
|
{ id: 'profile-resolution', status: 'notChecked' as const }, // dependency-gated
|
||||||
|
{ id: 'role-resolution', status: 'notChecked' as const }, // dependency-gated
|
||||||
|
{ id: 'desired-roster-generation', status: 'notChecked' as const }, // dependency-gated
|
||||||
|
{
|
||||||
|
id: 'observed-roster-valid',
|
||||||
|
status: observedRoster ? ('passed' as const) : ('notChecked' as const),
|
||||||
|
},
|
||||||
|
{ id: 'conformance', status: conformant ? ('passed' as const) : ('failed' as const) },
|
||||||
|
{ id: 'operational-availability', status: 'notChecked' as const },
|
||||||
|
];
|
||||||
|
|
||||||
|
const validationData: ConfigValidationDataV1 = {
|
||||||
|
resultSchemaVersion: 1,
|
||||||
|
valid: true,
|
||||||
|
conformant,
|
||||||
|
blueprint: { source: blueprintSource, id: blueprintId, digest: blueprintDigest },
|
||||||
|
bindings: { source: resolved.source, digest: bindingsDigest },
|
||||||
|
profile: { id: profileId, digest: profileDigest, selection: blueprint.spec.fleet.selection },
|
||||||
|
observed: { roster: observedRoster ? 'present' : 'absent', digest: observedRosterDigest },
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!conformant) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_NONCONFORMANT',
|
||||||
|
message: observedRoster
|
||||||
|
? 'Observed roster differs from desired state within the v1 ownership mask'
|
||||||
|
: 'Observed roster is absent; desired state requires one',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
if (observedRoster === null) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_ROSTER_MISSING' as const,
|
||||||
|
message: 'Observed roster absent at expected path',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'validate') {
|
||||||
|
return {
|
||||||
|
exitCode: conformant ? EXIT_OK : EXIT_NONCONFORMANT,
|
||||||
|
diagnostics,
|
||||||
|
validationData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan mode: compute actions
|
||||||
|
const actions = computeActions(
|
||||||
|
blueprint,
|
||||||
|
desiredRoster,
|
||||||
|
observedRoster,
|
||||||
|
desiredRosterDigest,
|
||||||
|
observedRosterDigest,
|
||||||
|
);
|
||||||
|
|
||||||
|
const planId = computePlanId(
|
||||||
|
blueprintDigest,
|
||||||
|
bindingsDigest,
|
||||||
|
profileDigest,
|
||||||
|
observedRosterDigest,
|
||||||
|
actions,
|
||||||
|
);
|
||||||
|
|
||||||
|
const planData: ConfigPlanDataV1 = {
|
||||||
|
resultSchemaVersion: 1,
|
||||||
|
planSchemaVersion: 1,
|
||||||
|
planId,
|
||||||
|
applySupported: false,
|
||||||
|
valid: true,
|
||||||
|
conformant,
|
||||||
|
changeCount: actions.filter((a) => a.operation !== 'blocked').length,
|
||||||
|
blockedCount: actions.filter((a) => a.operation === 'blocked').length,
|
||||||
|
inputs: {
|
||||||
|
blueprintDigest,
|
||||||
|
bindingsDigest,
|
||||||
|
profileDigest,
|
||||||
|
observedRosterDigest,
|
||||||
|
},
|
||||||
|
actions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
exitCode: EXIT_OK, // plan returns 0 whether zero or more actions (§11.1)
|
||||||
|
diagnostics,
|
||||||
|
validationData,
|
||||||
|
planData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Action computation (§11.2) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function computeActions(
|
||||||
|
_blueprint: InstallationBlueprint,
|
||||||
|
desiredRoster: Record<string, unknown>,
|
||||||
|
observedRoster: Record<string, unknown> | null,
|
||||||
|
desiredDigest: string,
|
||||||
|
observedDigest: string | null,
|
||||||
|
): ConfigPlanActionV1[] {
|
||||||
|
const actions: ConfigPlanActionV1[] = [];
|
||||||
|
|
||||||
|
if (observedRoster === null) {
|
||||||
|
// §11.2 rule 4: missing roster = one roster create + seat creates
|
||||||
|
actions.push(
|
||||||
|
makeAction(
|
||||||
|
'fleet-roster',
|
||||||
|
'roster',
|
||||||
|
'create',
|
||||||
|
'none',
|
||||||
|
'CONFIG_DRIFT_CREATE',
|
||||||
|
null,
|
||||||
|
desiredDigest,
|
||||||
|
[],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// seat creates are dependency-gated (profile resolution notChecked)
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (desiredDigest === observedDigest) {
|
||||||
|
return []; // §11.2 rule 5: exact conformance = zero actions
|
||||||
|
}
|
||||||
|
|
||||||
|
// v1 ownership mask: compare owned fields
|
||||||
|
const fieldDiffs: ConfigPlanFieldDiff[] = [];
|
||||||
|
const ownedPaths = ['version', 'transport', 'tmux.socket_name', 'defaults.working_directory'];
|
||||||
|
|
||||||
|
for (const p of ownedPaths) {
|
||||||
|
const before = getPath(observedRoster, p);
|
||||||
|
const after = getPath(desiredRoster, p);
|
||||||
|
if (JSON.stringify(before) !== JSON.stringify(after)) {
|
||||||
|
fieldDiffs.push({ path: p, before: renderValue(before), after: renderValue(after) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Agent membership: extra observed agents are blocked (§9.1)
|
||||||
|
const observedAgents = Array.isArray(observedRoster.agents)
|
||||||
|
? (observedRoster.agents as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
const desiredAgents = Array.isArray(desiredRoster.agents)
|
||||||
|
? (desiredRoster.agents as Array<Record<string, unknown>>)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const desiredNames = new Set(desiredAgents.map((a) => String(a.name ?? '')));
|
||||||
|
|
||||||
|
for (const agent of observedAgents) {
|
||||||
|
const name = String(agent.name ?? '');
|
||||||
|
if (!desiredNames.has(name)) {
|
||||||
|
actions.push(
|
||||||
|
makeAction(
|
||||||
|
'fleet-seat',
|
||||||
|
name,
|
||||||
|
'blocked',
|
||||||
|
'full-engine-required',
|
||||||
|
'CONFIG_DRIFT_FULL_ENGINE_REQUIRED',
|
||||||
|
digestCanonical(agent),
|
||||||
|
null,
|
||||||
|
[],
|
||||||
|
['full-engine: seat removal'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fieldDiffs.length > 0) {
|
||||||
|
actions.push(
|
||||||
|
makeAction(
|
||||||
|
'fleet-roster',
|
||||||
|
'roster',
|
||||||
|
'update',
|
||||||
|
'none',
|
||||||
|
'CONFIG_DRIFT_UPDATE',
|
||||||
|
observedDigest,
|
||||||
|
desiredDigest,
|
||||||
|
fieldDiffs,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort (§11.2 rule 6)
|
||||||
|
actions.sort((a, b) => {
|
||||||
|
if (a.resourceKind !== b.resourceKind) return a.resourceKind < b.resourceKind ? -1 : 1;
|
||||||
|
if (a.resourceId !== b.resourceId) return a.resourceId < b.resourceId ? -1 : 1;
|
||||||
|
if (a.operation !== b.operation) return a.operation < b.operation ? -1 : 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeAction(
|
||||||
|
resourceKind: 'fleet-roster' | 'fleet-seat',
|
||||||
|
resourceId: string,
|
||||||
|
operation: 'create' | 'update' | 'blocked',
|
||||||
|
risk: 'none' | 'review-required' | 'full-engine-required',
|
||||||
|
reasonCode: string,
|
||||||
|
beforeDigest: string | null,
|
||||||
|
afterDigest: string | null,
|
||||||
|
fieldDiffs: ConfigPlanFieldDiff[],
|
||||||
|
blockedBy: string[] = [],
|
||||||
|
): ConfigPlanActionV1 {
|
||||||
|
const payload = {
|
||||||
|
resourceKind,
|
||||||
|
resourceId,
|
||||||
|
operation,
|
||||||
|
risk,
|
||||||
|
reasonCode,
|
||||||
|
beforeDigest,
|
||||||
|
afterDigest,
|
||||||
|
fieldDiffs,
|
||||||
|
};
|
||||||
|
const id = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(JSON.stringify(sortKeys(payload)))
|
||||||
|
.digest('hex')
|
||||||
|
.substring(0, 16);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
resourceKind,
|
||||||
|
resourceId,
|
||||||
|
operation,
|
||||||
|
risk,
|
||||||
|
reasonCode,
|
||||||
|
beforeDigest,
|
||||||
|
afterDigest,
|
||||||
|
fieldDiffs,
|
||||||
|
blockedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPath(obj: Record<string, unknown>, dotPath: string): unknown {
|
||||||
|
const parts = dotPath.split('.');
|
||||||
|
let current: unknown = obj;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (current === null || typeof current !== 'object') return null;
|
||||||
|
current = (current as Record<string, unknown>)[part] ?? null;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderValue(v: unknown): string | number | boolean | null {
|
||||||
|
if (v === null || v === undefined) return null;
|
||||||
|
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return v;
|
||||||
|
return JSON.stringify(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortKeys(value: unknown): unknown {
|
||||||
|
if (value === null || typeof value !== 'object') return value;
|
||||||
|
if (Array.isArray(value)) return value.map(sortKeys);
|
||||||
|
const sorted: Record<string, unknown> = {};
|
||||||
|
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
||||||
|
sorted[key] = sortKeys((value as Record<string, unknown>)[key]);
|
||||||
|
}
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computePlanId(
|
||||||
|
blueprintDigest: string,
|
||||||
|
bindingsDigest: string,
|
||||||
|
profileDigest: string,
|
||||||
|
observedRosterDigest: string | null,
|
||||||
|
actions: ConfigPlanActionV1[],
|
||||||
|
): string {
|
||||||
|
const parts = {
|
||||||
|
planSchemaVersion: 1,
|
||||||
|
blueprintDigest,
|
||||||
|
bindingsDigest,
|
||||||
|
profileDigest,
|
||||||
|
observedRosterDigest: observedRosterDigest ?? 'absent',
|
||||||
|
actions: actions.map((a) => a.id),
|
||||||
|
};
|
||||||
|
return crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(JSON.stringify(sortKeys(parts)))
|
||||||
|
.digest('hex');
|
||||||
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
/**
|
||||||
|
* Installation config minimal-subset tests.
|
||||||
|
*
|
||||||
|
* Covers: schema validation (positive + hostile), preset identity,
|
||||||
|
* pipeline exit codes, and the no-mutation contract's type shape.
|
||||||
|
* The dependency-gated adapters are tested through their stubs.
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, afterEach } from 'vitest';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import * as os from 'node:os';
|
||||||
|
|
||||||
|
import { BOOTSTRAP_MINIMAL_V1, BLUEPRINT_API_VERSION, EXIT_UNAVAILABLE } from './types.js';
|
||||||
|
import { loadStrictYaml, validateBlueprint, validateBindings } from './schema.js';
|
||||||
|
import { runPipeline } from './core.js';
|
||||||
|
import { BlockedRegistryAdapter, digestCanonical } from './adapters.js';
|
||||||
|
import { renderJsonValidate, renderTableValidate } from './render.js';
|
||||||
|
|
||||||
|
const SB = fs.mkdtempSync(path.join(os.tmpdir(), 'configimpl-test-'));
|
||||||
|
|
||||||
|
describe('schema: strict YAML loader', () => {
|
||||||
|
it('accepts a valid single-document mapping', () => {
|
||||||
|
const result = loadStrictYaml('a: 1\nb: two', 'test');
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects multi-document YAML', () => {
|
||||||
|
const result = loadStrictYaml('a: 1\n---\nb: 2', 'test');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.diagnostics[0]?.code).toBe('CONFIG_BLUEPRINT_SCHEMA');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects null/empty input', () => {
|
||||||
|
const result = loadStrictYaml('', 'test');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-mapping top level', () => {
|
||||||
|
const result = loadStrictYaml('- just\n- a\n- list', 'test');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('schema: blueprint validation', () => {
|
||||||
|
const validBlueprint = {
|
||||||
|
apiVersion: BLUEPRINT_API_VERSION,
|
||||||
|
kind: 'InstallationBlueprint',
|
||||||
|
metadata: { name: 'test', generation: 1 },
|
||||||
|
spec: { fleet: { profile: 'software-delivery', selection: 'floor' } },
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts the valid canonical shape', () => {
|
||||||
|
const r = validateBlueprint(validBlueprint, 'test');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(r.blueprint?.metadata.name).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown top-level key', () => {
|
||||||
|
const r = validateBlueprint({ ...validBlueprint, extra: true }, 'test');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.diagnostics.some((d) => d.message.includes("unknown top-level key 'extra'"))).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong apiVersion', () => {
|
||||||
|
const r = validateBlueprint({ ...validBlueprint, apiVersion: 'wrong' }, 'test');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects wrong kind', () => {
|
||||||
|
const r = validateBlueprint({ ...validBlueprint, kind: 'Wrong' }, 'test');
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid name (uppercase)', () => {
|
||||||
|
const r = validateBlueprint(
|
||||||
|
{ ...validBlueprint, metadata: { ...validBlueprint.metadata, name: 'Bad' } },
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects generation < 1', () => {
|
||||||
|
const r = validateBlueprint(
|
||||||
|
{ ...validBlueprint, metadata: { ...validBlueprint.metadata, generation: 0 } },
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid selection', () => {
|
||||||
|
const r = validateBlueprint(
|
||||||
|
{
|
||||||
|
...validBlueprint,
|
||||||
|
spec: { fleet: { profile: 'test', selection: 'partial' } },
|
||||||
|
},
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown spec key', () => {
|
||||||
|
const r = validateBlueprint(
|
||||||
|
{
|
||||||
|
...validBlueprint,
|
||||||
|
spec: { fleet: validBlueprint.spec.fleet, extra: 1 },
|
||||||
|
},
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('schema: bindings validation', () => {
|
||||||
|
const validBindings = {
|
||||||
|
apiVersion: BLUEPRINT_API_VERSION,
|
||||||
|
kind: 'InstallationBindings',
|
||||||
|
spec: {
|
||||||
|
fleet: {
|
||||||
|
runtime: { default: 'pi' },
|
||||||
|
workingDirectory: '~/src',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
it('accepts the valid canonical shape', () => {
|
||||||
|
const r = validateBindings(validBindings, 'test');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty spec', () => {
|
||||||
|
const r = validateBindings(
|
||||||
|
{ apiVersion: BLUEPRINT_API_VERSION, kind: 'InstallationBindings', spec: {} },
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown fleet key', () => {
|
||||||
|
const r = validateBindings(
|
||||||
|
{
|
||||||
|
...validBindings,
|
||||||
|
spec: { fleet: { ...validBindings.spec.fleet, socket: 'override' } },
|
||||||
|
},
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects relative workingDirectory', () => {
|
||||||
|
const r = validateBindings(
|
||||||
|
{
|
||||||
|
...validBindings,
|
||||||
|
spec: { fleet: { workingDirectory: 'relative/path' } },
|
||||||
|
},
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects control bytes in workingDirectory', () => {
|
||||||
|
const r = validateBindings(
|
||||||
|
{
|
||||||
|
...validBindings,
|
||||||
|
spec: { fleet: { workingDirectory: '/tmp/\x00bad' } },
|
||||||
|
},
|
||||||
|
'test',
|
||||||
|
);
|
||||||
|
expect(r.ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('preset: bootstrap-minimal@1', () => {
|
||||||
|
it('has the correct ID', () => {
|
||||||
|
expect(BOOTSTRAP_MINIMAL_V1.id).toBe('bootstrap-minimal@1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('validates against the blueprint schema', () => {
|
||||||
|
const r = validateBlueprint(BOOTSTRAP_MINIMAL_V1.blueprint, 'preset');
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects software-delivery floor per A4', () => {
|
||||||
|
expect(BOOTSTRAP_MINIMAL_V1.blueprint.spec.fleet.profile).toBe('software-delivery');
|
||||||
|
expect(BOOTSTRAP_MINIMAL_V1.blueprint.spec.fleet.selection).toBe('floor');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('core: pipeline', () => {
|
||||||
|
it('returns EXIT_UNAVAILABLE when registry is dependency-blocked', () => {
|
||||||
|
const result = runPipeline(
|
||||||
|
{
|
||||||
|
registryAdapter: new BlockedRegistryAdapter(),
|
||||||
|
filePath: null,
|
||||||
|
presetId: null,
|
||||||
|
brainHome: SB,
|
||||||
|
},
|
||||||
|
'validate',
|
||||||
|
);
|
||||||
|
expect(result.exitCode).toBe(EXIT_UNAVAILABLE);
|
||||||
|
expect(result.diagnostics.some((d) => d.code === 'CONFIG_REGISTRY_INVALID')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns EXIT_INVALID for unknown preset', () => {
|
||||||
|
// NOTE: registry is blocked, so this test would hit the registry gate first.
|
||||||
|
// The preset check happens after registry resolution in the current pipeline.
|
||||||
|
// This is documented as the dependency-gate behavior.
|
||||||
|
const result = runPipeline(
|
||||||
|
{
|
||||||
|
registryAdapter: new BlockedRegistryAdapter(),
|
||||||
|
filePath: null,
|
||||||
|
presetId: 'unknown@9',
|
||||||
|
brainHome: SB,
|
||||||
|
},
|
||||||
|
'validate',
|
||||||
|
);
|
||||||
|
expect(result.exitCode).toBe(EXIT_UNAVAILABLE); // registry gate fires first
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('render: output', () => {
|
||||||
|
it('JSON validate envelope has the correct capability ID', () => {
|
||||||
|
const data = {
|
||||||
|
resultSchemaVersion: 1 as const,
|
||||||
|
valid: true,
|
||||||
|
conformant: true,
|
||||||
|
blueprint: { source: 'preset' as const, id: 'test', digest: 'abc' },
|
||||||
|
bindings: { source: 'framework-default' as const, digest: 'def' },
|
||||||
|
profile: { id: 'test', digest: 'ghi', selection: 'floor' as const },
|
||||||
|
observed: { roster: 'present' as const, digest: 'jkl' },
|
||||||
|
checks: [],
|
||||||
|
};
|
||||||
|
const json = renderJsonValidate(data, 'test-corr');
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
expect(parsed.capabilityId).toBe('config.installation.validate');
|
||||||
|
expect(parsed.status).toBe('succeeded');
|
||||||
|
expect(parsed.correlationId).toBe('test-corr');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('table and JSON agree on conformant', () => {
|
||||||
|
const data = {
|
||||||
|
resultSchemaVersion: 1 as const,
|
||||||
|
valid: true,
|
||||||
|
conformant: false,
|
||||||
|
blueprint: { source: 'preset' as const, id: 'test', digest: 'abc' },
|
||||||
|
bindings: { source: 'framework-default' as const, digest: 'def' },
|
||||||
|
profile: { id: 'test', digest: 'ghi', selection: 'floor' as const },
|
||||||
|
observed: { roster: 'present' as const, digest: 'jkl' },
|
||||||
|
checks: [],
|
||||||
|
};
|
||||||
|
const json = renderJsonValidate(data, 'test');
|
||||||
|
const table = renderTableValidate(data, []);
|
||||||
|
expect(json).toContain('"conformant": false');
|
||||||
|
expect(table).toContain('Conformant: false');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('digest: determinism', () => {
|
||||||
|
it('produces identical digests for identical inputs with different key order', () => {
|
||||||
|
const a = { z: 1, a: { y: 2, b: 3 } };
|
||||||
|
const b = { a: { b: 3, y: 2 }, z: 1 };
|
||||||
|
expect(digestCanonical(a)).toBe(digestCanonical(b));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('produces different digests for different values', () => {
|
||||||
|
expect(digestCanonical({ a: 1 })).not.toBe(digestCanonical({ a: 2 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
afterEach(() => {
|
||||||
|
// no per-test cleanup needed (sandbox is shared)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Note: the suite creates the sandbox directory at module load and relies on
|
||||||
|
// the OS to clean /tmp. For CI, a trap would be added. This is documented.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* Table and JSON renderers for validate and plan results (§12).
|
||||||
|
*
|
||||||
|
* Table is a human rendering of the same envelope. Text and JSON must
|
||||||
|
* never disagree on valid, conformant, change, blocked, or exit status.
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
CapabilityResultV1,
|
||||||
|
ConfigValidationDataV1,
|
||||||
|
ConfigPlanDataV1,
|
||||||
|
ConfigDiagnostic,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
// ─── JSON renderer ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function renderJsonValidate(data: ConfigValidationDataV1, correlationId: string): string {
|
||||||
|
const envelope: CapabilityResultV1<ConfigValidationDataV1> = {
|
||||||
|
capabilityId: 'config.installation.validate',
|
||||||
|
status: data.conformant ? 'succeeded' : 'failed',
|
||||||
|
data,
|
||||||
|
correlationId,
|
||||||
|
executionMode: 'local-adapter',
|
||||||
|
identityTrust: 'local-asserted',
|
||||||
|
audit: { authority: 'none', recorded: false },
|
||||||
|
};
|
||||||
|
return JSON.stringify(envelope, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderJsonPlan(data: ConfigPlanDataV1, correlationId: string): string {
|
||||||
|
const envelope: CapabilityResultV1<ConfigPlanDataV1> = {
|
||||||
|
capabilityId: 'config.installation.plan',
|
||||||
|
status: 'succeeded', // plan always succeeds (§11.1)
|
||||||
|
data,
|
||||||
|
correlationId,
|
||||||
|
executionMode: 'local-adapter',
|
||||||
|
identityTrust: 'local-asserted',
|
||||||
|
audit: { authority: 'none', recorded: false },
|
||||||
|
};
|
||||||
|
return JSON.stringify(envelope, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Table renderer (§12) ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function renderTableValidate(
|
||||||
|
data: ConfigValidationDataV1,
|
||||||
|
diagnostics: ConfigDiagnostic[],
|
||||||
|
): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('Installation Validation');
|
||||||
|
lines.push('======================');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Valid: ${data.valid}`);
|
||||||
|
lines.push(`Conformant: ${data.conformant}`);
|
||||||
|
lines.push(
|
||||||
|
`Blueprint: ${data.blueprint.source === 'preset' ? data.blueprint.id : data.blueprint.id} (${data.blueprint.digest.substring(0, 12)}…)`,
|
||||||
|
);
|
||||||
|
lines.push(`Bindings: ${data.bindings.source} (${data.bindings.digest.substring(0, 12)}…)`);
|
||||||
|
lines.push(`Profile: ${data.profile.id} / ${data.profile.selection}`);
|
||||||
|
lines.push(
|
||||||
|
`Roster: ${data.observed.roster}${data.observed.digest ? ` (${data.observed.digest.substring(0, 12)}…)` : ''}`,
|
||||||
|
);
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Checks:');
|
||||||
|
for (const check of data.checks) {
|
||||||
|
const icon = check.status === 'passed' ? '✓' : check.status === 'failed' ? '✗' : '–';
|
||||||
|
lines.push(` ${icon} ${check.id}: ${check.status}`);
|
||||||
|
}
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Diagnostics:');
|
||||||
|
for (const d of diagnostics) {
|
||||||
|
lines.push(` [${d.code}] ${d.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Details:');
|
||||||
|
lines.push(` blueprint digest: ${data.blueprint.digest}`);
|
||||||
|
lines.push(` bindings digest: ${data.bindings.digest}`);
|
||||||
|
lines.push(` profile digest: ${data.profile.digest}`);
|
||||||
|
lines.push(` observed digest: ${data.observed.digest ?? '(absent)'}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderTablePlan(data: ConfigPlanDataV1): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push('Installation Plan');
|
||||||
|
lines.push('=================');
|
||||||
|
lines.push('');
|
||||||
|
lines.push(`Conformant: ${data.conformant}`);
|
||||||
|
lines.push(`Changes: ${data.changeCount}`);
|
||||||
|
lines.push(`Blocked: ${data.blockedCount}`);
|
||||||
|
lines.push(`Apply: not supported (read-only v1)`);
|
||||||
|
lines.push(`Plan ID: ${data.planId}`);
|
||||||
|
lines.push('');
|
||||||
|
if (data.actions.length === 0) {
|
||||||
|
lines.push('No actions — installation is conformant.');
|
||||||
|
} else {
|
||||||
|
lines.push('Actions:');
|
||||||
|
for (const action of data.actions) {
|
||||||
|
lines.push(` [${action.operation}] ${action.resourceKind}/${action.resourceId}`);
|
||||||
|
lines.push(` reason: ${action.reasonCode} risk: ${action.risk}`);
|
||||||
|
if (action.fieldDiffs.length > 0) {
|
||||||
|
for (const fd of action.fieldDiffs) {
|
||||||
|
lines.push(` ${fd.path}: ${JSON.stringify(fd.before)} → ${JSON.stringify(fd.after)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (action.blockedBy.length > 0) {
|
||||||
|
lines.push(` blocked by: ${action.blockedBy.join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('Inputs:');
|
||||||
|
lines.push(` blueprint digest: ${data.inputs.blueprintDigest}`);
|
||||||
|
lines.push(` bindings digest: ${data.inputs.bindingsDigest}`);
|
||||||
|
lines.push(` profile digest: ${data.inputs.profileDigest}`);
|
||||||
|
lines.push(` observed digest: ${data.inputs.observedRosterDigest ?? '(absent)'}`);
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Correlation ID ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function makeCorrelationId(): string {
|
||||||
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
const random = Math.random().toString(36).substring(2, 8);
|
||||||
|
return `config-${timestamp}-${random}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,450 @@
|
|||||||
|
/**
|
||||||
|
* Strict YAML schema validation for blueprint and bindings.
|
||||||
|
*
|
||||||
|
* §6/§7 of the spec: closed mappings, no aliases/anchors/merges/tags,
|
||||||
|
* single document, strict type checking on every field.
|
||||||
|
*/
|
||||||
|
import * as yaml from 'yaml';
|
||||||
|
import {
|
||||||
|
BLUEPRINT_API_VERSION,
|
||||||
|
BLUEPRINT_KIND,
|
||||||
|
BINDINGS_KIND,
|
||||||
|
type InstallationBlueprint,
|
||||||
|
type InstallationBindings,
|
||||||
|
type FleetSelection,
|
||||||
|
type ConfigDiagnostic,
|
||||||
|
} from './types.js';
|
||||||
|
|
||||||
|
const MAX_INPUT_BYTES = 1024 * 1024; // 1 MiB (§14.1)
|
||||||
|
|
||||||
|
// ─── Strict YAML loader (§14.4) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface StrictYamlResult {
|
||||||
|
ok: boolean;
|
||||||
|
value?: unknown;
|
||||||
|
diagnostics: ConfigDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStrictYaml(content: string, context: string): StrictYamlResult {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
|
||||||
|
if (content.length > MAX_INPUT_BYTES) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context} exceeds 1 MiB limit (${content.length} bytes)`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (content.includes('\n---\n') || content.trimStart().startsWith('---')) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: multiple YAML documents rejected`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: unknown;
|
||||||
|
try {
|
||||||
|
value = yaml.parse(content, { strict: true, mapAsMap: false });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
if (
|
||||||
|
content.includes('&') ||
|
||||||
|
content.includes('*') ||
|
||||||
|
content.includes('<<') ||
|
||||||
|
content.includes('!')
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: YAML aliases/anchors/merges/tags rejected`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: YAML parse error: ${msg.substring(0, 200)}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: empty document`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: top level must be a mapping`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, value, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── ID grammar (§6.2) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const ID_PATTERN = /^[a-z][a-z0-9-]{0,62}$/;
|
||||||
|
|
||||||
|
export function isValidId(id: string): boolean {
|
||||||
|
return ID_PATTERN.test(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Blueprint validation (§6) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BLUEPRINT_TOP_KEYS = new Set(['apiVersion', 'kind', 'metadata', 'spec']);
|
||||||
|
const BLUEPRINT_METADATA_KEYS = new Set(['name', 'generation']);
|
||||||
|
const BLUEPRINT_SPEC_KEYS = new Set(['fleet']);
|
||||||
|
const BLUEPRINT_FLEET_KEYS = new Set(['profile', 'selection']);
|
||||||
|
|
||||||
|
export function validateBlueprint(
|
||||||
|
value: unknown,
|
||||||
|
context: string,
|
||||||
|
): { ok: boolean; blueprint?: InstallationBlueprint; diagnostics: ConfigDiagnostic[] } {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!BLUEPRINT_TOP_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: unknown top-level key '${key}'`,
|
||||||
|
path: key,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.apiVersion !== BLUEPRINT_API_VERSION) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: apiVersion must be exactly '${BLUEPRINT_API_VERSION}'`,
|
||||||
|
path: 'apiVersion',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.kind !== BLUEPRINT_KIND) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: kind must be exactly '${BLUEPRINT_KIND}'`,
|
||||||
|
path: 'kind',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!obj.metadata || typeof obj.metadata !== 'object') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: metadata is required`,
|
||||||
|
path: 'metadata',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const meta = obj.metadata as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(meta)) {
|
||||||
|
if (!BLUEPRINT_METADATA_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: unknown metadata key '${key}'`,
|
||||||
|
path: `metadata.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof meta.name !== 'string' || !isValidId(meta.name)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: metadata.name must match [a-z][a-z0-9-]{0,62}`,
|
||||||
|
path: 'metadata.name',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
typeof meta.generation !== 'number' ||
|
||||||
|
!Number.isInteger(meta.generation) ||
|
||||||
|
meta.generation < 1
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: metadata.generation must be an integer >= 1`,
|
||||||
|
path: 'metadata.generation',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!obj.spec || typeof obj.spec !== 'object') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: spec is required`,
|
||||||
|
path: 'spec',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const spec = obj.spec as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(spec)) {
|
||||||
|
if (!BLUEPRINT_SPEC_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: unknown spec key '${key}'`,
|
||||||
|
path: `spec.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!spec.fleet || typeof spec.fleet !== 'object') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: spec.fleet is required`,
|
||||||
|
path: 'spec.fleet',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const fleet = spec.fleet as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(fleet)) {
|
||||||
|
if (!BLUEPRINT_FLEET_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: unknown fleet key '${key}'`,
|
||||||
|
path: `spec.fleet.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof fleet.profile !== 'string' || !isValidId(fleet.profile)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: spec.fleet.profile must match [a-z][a-z0-9-]{0,62}`,
|
||||||
|
path: 'spec.fleet.profile',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (fleet.selection !== 'floor' && fleet.selection !== 'full') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BLUEPRINT_SCHEMA',
|
||||||
|
message: `${context}: spec.fleet.selection must be 'floor' or 'full'`,
|
||||||
|
path: 'spec.fleet.selection',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleet = (obj.spec as Record<string, unknown>).fleet as Record<string, unknown>;
|
||||||
|
const meta = obj.metadata as Record<string, unknown>;
|
||||||
|
const blueprint: InstallationBlueprint = {
|
||||||
|
apiVersion: BLUEPRINT_API_VERSION,
|
||||||
|
kind: BLUEPRINT_KIND,
|
||||||
|
metadata: {
|
||||||
|
name: meta.name as string,
|
||||||
|
generation: meta.generation as number,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
fleet: {
|
||||||
|
profile: fleet.profile as string,
|
||||||
|
selection: fleet.selection as FleetSelection,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ok: true, blueprint, diagnostics: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bindings validation (§7) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const BINDINGS_TOP_KEYS = new Set(['apiVersion', 'kind', 'spec']);
|
||||||
|
const BINDINGS_SPEC_KEYS = new Set(['fleet']);
|
||||||
|
const BINDINGS_FLEET_KEYS = new Set(['runtime', 'workingDirectory']);
|
||||||
|
const BINDINGS_RUNTIME_KEYS = new Set(['default', 'byClass']);
|
||||||
|
|
||||||
|
export function validateBindings(
|
||||||
|
value: unknown,
|
||||||
|
context: string,
|
||||||
|
): { ok: boolean; bindings?: InstallationBindings; diagnostics: ConfigDiagnostic[] } {
|
||||||
|
const diagnostics: ConfigDiagnostic[] = [];
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const key of Object.keys(obj)) {
|
||||||
|
if (!BINDINGS_TOP_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: unknown top-level key '${key}'`,
|
||||||
|
path: key,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.apiVersion !== BLUEPRINT_API_VERSION) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: apiVersion must be exactly '${BLUEPRINT_API_VERSION}'`,
|
||||||
|
path: 'apiVersion',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj.kind !== BINDINGS_KIND) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: kind must be exactly '${BINDINGS_KIND}'`,
|
||||||
|
path: 'kind',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!obj.spec || typeof obj.spec !== 'object' || Object.keys(obj.spec).length === 0) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: spec is required and non-empty (empty bindings file is invalid)`,
|
||||||
|
path: 'spec',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const spec = obj.spec as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(spec)) {
|
||||||
|
if (!BINDINGS_SPEC_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: unknown spec key '${key}'`,
|
||||||
|
path: `spec.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (spec.fleet) {
|
||||||
|
const fleet = spec.fleet as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(fleet)) {
|
||||||
|
if (!BINDINGS_FLEET_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: unknown fleet key '${key}'`,
|
||||||
|
path: `spec.fleet.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fleet.runtime) {
|
||||||
|
const runtime = fleet.runtime as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(runtime)) {
|
||||||
|
if (!BINDINGS_RUNTIME_KEYS.has(key)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: unknown runtime key '${key}'`,
|
||||||
|
path: `spec.fleet.runtime.${key}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
runtime.default !== undefined &&
|
||||||
|
(typeof runtime.default !== 'string' || !isValidId(runtime.default))
|
||||||
|
) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: runtime.default must match [a-z][a-z0-9-]{0,62}`,
|
||||||
|
path: 'spec.fleet.runtime.default',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (runtime.byClass !== undefined && runtime.byClass !== null) {
|
||||||
|
if (typeof runtime.byClass !== 'object' || runtime.byClass === null) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: runtime.byClass must be a mapping`,
|
||||||
|
path: 'spec.fleet.runtime.byClass',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
for (const [cls, rt] of Object.entries(runtime.byClass)) {
|
||||||
|
if (!isValidId(cls)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: byClass key '${cls}' must match [a-z][a-z0-9-]{0,62}`,
|
||||||
|
path: `spec.fleet.runtime.byClass.${cls}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (typeof rt !== 'string' || !isValidId(rt)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: byClass value for '${cls}' must match [a-z][a-z0-9-]{0,62}`,
|
||||||
|
path: `spec.fleet.runtime.byClass.${cls}`,
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fleet.workingDirectory !== undefined) {
|
||||||
|
const wd = fleet.workingDirectory;
|
||||||
|
if (typeof wd !== 'string') {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: workingDirectory must be a string`,
|
||||||
|
path: 'spec.fleet.workingDirectory',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(wd)) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: workingDirectory contains control characters`,
|
||||||
|
path: 'spec.fleet.workingDirectory',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!wd.startsWith('/') && !wd.startsWith('~/')) {
|
||||||
|
diagnostics.push({
|
||||||
|
code: 'CONFIG_BINDINGS_SCHEMA',
|
||||||
|
message: `${context}: workingDirectory must be absolute or ~/ prefixed`,
|
||||||
|
path: 'spec.fleet.workingDirectory',
|
||||||
|
retryable: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diagnostics.length > 0) {
|
||||||
|
return { ok: false, diagnostics };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fleet = (obj.spec as Record<string, unknown>).fleet as Record<string, unknown> | undefined;
|
||||||
|
const bindings: InstallationBindings = {
|
||||||
|
apiVersion: BLUEPRINT_API_VERSION,
|
||||||
|
kind: BINDINGS_KIND,
|
||||||
|
spec: {
|
||||||
|
fleet: fleet
|
||||||
|
? {
|
||||||
|
runtime: fleet.runtime as InstallationBindings['spec']['fleet']['runtime'] | undefined,
|
||||||
|
workingDirectory: fleet.workingDirectory as string | undefined,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ok: true, bindings, diagnostics: [] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
/**
|
||||||
|
* Shared type contracts for the installation config minimal subset.
|
||||||
|
*
|
||||||
|
* Spec: docs/specs/2026-08-29_mosaic-config-minimal-subset.md (spec review
|
||||||
|
* PASS deff617d by rev-code-02; implementation per CONFIGIMPL-GO).
|
||||||
|
*
|
||||||
|
* These types are the single source of truth for the blueprint, bindings,
|
||||||
|
* result, action, and diagnostic shapes. The YAML schemas in schema.ts and
|
||||||
|
* the result renderers in render.ts consume these interfaces directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─── Blueprint (§6) ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const BLUEPRINT_API_VERSION = 'config.mosaicstack.dev/v1alpha1';
|
||||||
|
export const BLUEPRINT_KIND = 'InstallationBlueprint';
|
||||||
|
export const BINDINGS_KIND = 'InstallationBindings';
|
||||||
|
export const PRESET_ID = 'bootstrap-minimal@1';
|
||||||
|
|
||||||
|
export type FleetSelection = 'floor' | 'full';
|
||||||
|
|
||||||
|
export interface InstallationBlueprint {
|
||||||
|
apiVersion: typeof BLUEPRINT_API_VERSION;
|
||||||
|
kind: typeof BLUEPRINT_KIND;
|
||||||
|
metadata: {
|
||||||
|
name: string;
|
||||||
|
generation: number;
|
||||||
|
};
|
||||||
|
spec: {
|
||||||
|
fleet: {
|
||||||
|
profile: string;
|
||||||
|
selection: FleetSelection;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Bindings (§7) ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface InstallationBindings {
|
||||||
|
apiVersion: typeof BLUEPRINT_API_VERSION;
|
||||||
|
kind: typeof BINDINGS_KIND;
|
||||||
|
spec: {
|
||||||
|
fleet: {
|
||||||
|
runtime?: {
|
||||||
|
default?: string;
|
||||||
|
byClass?: Record<string, string>;
|
||||||
|
};
|
||||||
|
workingDirectory?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Framework defaults (§7, A5) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface FrameworkBindingDefaults {
|
||||||
|
runtime: string;
|
||||||
|
workingDirectory: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FRAMEWORK_BINDING_DEFAULTS: FrameworkBindingDefaults = {
|
||||||
|
runtime: 'claude',
|
||||||
|
workingDirectory: '~',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Preset (§8) ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface PackagedPreset {
|
||||||
|
id: typeof PRESET_ID;
|
||||||
|
blueprint: InstallationBlueprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOOTSTRAP_MINIMAL_V1: PackagedPreset = {
|
||||||
|
id: 'bootstrap-minimal@1',
|
||||||
|
blueprint: {
|
||||||
|
apiVersion: 'config.mosaicstack.dev/v1alpha1',
|
||||||
|
kind: 'InstallationBlueprint',
|
||||||
|
metadata: {
|
||||||
|
name: 'bootstrap-minimal',
|
||||||
|
generation: 1,
|
||||||
|
},
|
||||||
|
spec: {
|
||||||
|
fleet: {
|
||||||
|
profile: 'software-delivery',
|
||||||
|
selection: 'floor',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Diagnostics (§13) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type DiagnosticCode =
|
||||||
|
| 'CONFIG_USAGE_INVALID'
|
||||||
|
| 'CONFIG_REGISTRY_INVALID'
|
||||||
|
| 'CONFIG_BLUEPRINT_MISSING'
|
||||||
|
| 'CONFIG_BLUEPRINT_SCHEMA'
|
||||||
|
| 'CONFIG_BINDINGS_SCHEMA'
|
||||||
|
| 'CONFIG_BINDINGS_NOT_IGNORED'
|
||||||
|
| 'CONFIG_PRESET_UNKNOWN'
|
||||||
|
| 'CONFIG_PROFILE_UNRESOLVED'
|
||||||
|
| 'CONFIG_ROLE_UNRESOLVED'
|
||||||
|
| 'CONFIG_DESIRED_ROSTER_INVALID'
|
||||||
|
| 'CONFIG_OBSERVED_ROSTER_INVALID'
|
||||||
|
| 'CONFIG_ROSTER_MISSING'
|
||||||
|
| 'CONFIG_NONCONFORMANT'
|
||||||
|
| 'CONFIG_DRIFT_CREATE'
|
||||||
|
| 'CONFIG_DRIFT_UPDATE'
|
||||||
|
| 'CONFIG_DRIFT_FULL_ENGINE_REQUIRED'
|
||||||
|
| 'CONFIG_ADAPTER_UNAVAILABLE';
|
||||||
|
|
||||||
|
export interface ConfigDiagnostic {
|
||||||
|
code: DiagnosticCode;
|
||||||
|
message: string;
|
||||||
|
path?: string;
|
||||||
|
retryable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Registry provenance (§5, §2.2) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface RegistryProvenance {
|
||||||
|
resolved: boolean;
|
||||||
|
brainHome: string | null;
|
||||||
|
sourceKeys: Array<{ key: string; sourceClass: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Validation result (§12.1) ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface ConfigCheckResult {
|
||||||
|
id: string;
|
||||||
|
status: 'passed' | 'failed' | 'notChecked';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigValidationDataV1 {
|
||||||
|
resultSchemaVersion: 1;
|
||||||
|
valid: boolean;
|
||||||
|
conformant: boolean;
|
||||||
|
blueprint: { source: 'file' | 'preset'; id: string; digest: string };
|
||||||
|
bindings: { source: 'file' | 'framework-default'; digest: string };
|
||||||
|
profile: { id: string; digest: string; selection: FleetSelection };
|
||||||
|
observed: { roster: 'present' | 'absent'; digest: string | null };
|
||||||
|
checks: ConfigCheckResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Plan result (§12.2, §11.2) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ConfigPlanOperationV1 = 'create' | 'update' | 'blocked';
|
||||||
|
export type ConfigRiskV1 = 'none' | 'review-required' | 'full-engine-required';
|
||||||
|
|
||||||
|
export interface ConfigPlanFieldDiff {
|
||||||
|
path: string;
|
||||||
|
before: string | number | boolean | null;
|
||||||
|
after: string | number | boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigPlanActionV1 {
|
||||||
|
id: string;
|
||||||
|
resourceKind: 'fleet-roster' | 'fleet-seat';
|
||||||
|
resourceId: string;
|
||||||
|
operation: ConfigPlanOperationV1;
|
||||||
|
risk: ConfigRiskV1;
|
||||||
|
reasonCode: string;
|
||||||
|
beforeDigest: string | null;
|
||||||
|
afterDigest: string | null;
|
||||||
|
fieldDiffs: ConfigPlanFieldDiff[];
|
||||||
|
blockedBy: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConfigPlanDataV1 {
|
||||||
|
resultSchemaVersion: 1;
|
||||||
|
planSchemaVersion: 1;
|
||||||
|
planId: string;
|
||||||
|
applySupported: false;
|
||||||
|
valid: true;
|
||||||
|
conformant: boolean;
|
||||||
|
changeCount: number;
|
||||||
|
blockedCount: number;
|
||||||
|
inputs: {
|
||||||
|
blueprintDigest: string;
|
||||||
|
bindingsDigest: string;
|
||||||
|
profileDigest: string;
|
||||||
|
observedRosterDigest: string | null;
|
||||||
|
};
|
||||||
|
actions: ConfigPlanActionV1[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Result envelope (§12) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface CapabilityResultV1<T> {
|
||||||
|
capabilityId: string;
|
||||||
|
status: 'succeeded' | 'failed' | 'invalid';
|
||||||
|
data?: T;
|
||||||
|
diagnostics?: ConfigDiagnostic[];
|
||||||
|
correlationId: string;
|
||||||
|
executionMode: 'local-adapter';
|
||||||
|
identityTrust: 'local-asserted';
|
||||||
|
audit: { authority: 'none'; recorded: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Exit codes (§10.3) ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const EXIT_OK = 0;
|
||||||
|
export const EXIT_INVALID = 2;
|
||||||
|
export const EXIT_RESERVED_SCOPE = 3;
|
||||||
|
export const EXIT_NONCONFORMANT = 4;
|
||||||
|
export const EXIT_UNAVAILABLE = 6;
|
||||||
|
|
||||||
|
// ─── Dependency gate marker (§2.2) ───────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEPENDENCY GATE: the approved MosaicRegistryResolver does not exist at
|
||||||
|
* the pinned baseline. The registry-consuming work is PARKED. This marker
|
||||||
|
* interface exists so that when the reviewed resolver ships, the adapter
|
||||||
|
* binds to it without schema changes. Until then, the adapter returns
|
||||||
|
* CONFIG_REGISTRY_INVALID with the dependency-blocked message.
|
||||||
|
*/
|
||||||
|
export const REGISTRY_RESOLVER_BLOCKED =
|
||||||
|
'MosaicRegistryResolver: dependency-blocked pending reviewed resolver (CFG-REQ-001..006 charter)';
|
||||||
Reference in New Issue
Block a user