fix: harden factory capability probes
This commit is contained in:
parent
0c98e82b72
commit
5e14554bf9
|
|
@ -39,7 +39,8 @@
|
||||||
"SKILLS/",
|
"SKILLS/",
|
||||||
"deploy/",
|
"deploy/",
|
||||||
"Dockerfile",
|
"Dockerfile",
|
||||||
".env.example"
|
".env.example",
|
||||||
|
"factory-capabilities.yaml"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|
|
||||||
|
|
@ -28,4 +28,16 @@ describe("factory capabilities", () => {
|
||||||
expect(report.missing).toContain("factory-status");
|
expect(report.missing).toContain("factory-status");
|
||||||
expect(formatCapabilityReport(report)).toContain("FAIL git-proxy provides factory-status, discovery: HTTP 404");
|
expect(formatCapabilityReport(report)).toContain("FAIL git-proxy provides factory-status, discovery: HTTP 404");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("fails closed for malformed registry entries", async () => {
|
||||||
|
const services = parseFactoryCapabilities(`services:
|
||||||
|
broken:
|
||||||
|
provides: [factory-status]
|
||||||
|
`);
|
||||||
|
const report = await probeFactoryCapabilities(services, async () => ({ ok: true, status: 200 }));
|
||||||
|
|
||||||
|
expect(report.requiredOk).toBe(false);
|
||||||
|
expect(report.missing).toContain("factory-status");
|
||||||
|
expect(formatCapabilityReport(report)).toContain("FAIL broken provides factory-status: missing url");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,29 +20,34 @@ export interface CapabilityReport {
|
||||||
requiredOk: boolean;
|
requiredOk: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchLike = (url: string) => Promise<{ ok: boolean; status: number }>;
|
type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; status: number }>;
|
||||||
|
|
||||||
export function parseFactoryCapabilities(text: string): FactoryCapabilityService[] {
|
export function parseFactoryCapabilities(text: string): FactoryCapabilityService[] {
|
||||||
|
// ponytail: tiny YAML subset for this checked-in registry; add a parser only when the format grows.
|
||||||
const services: FactoryCapabilityService[] = [];
|
const services: FactoryCapabilityService[] = [];
|
||||||
let current: Partial<FactoryCapabilityService> | null = null;
|
let current: Partial<FactoryCapabilityService> | null = null;
|
||||||
|
|
||||||
for (const raw of text.split(/\r?\n/)) {
|
for (const raw of text.split(/\r?\n/)) {
|
||||||
const line = raw.trim();
|
const line = raw.trim();
|
||||||
if (!line || line === "services:" || line.startsWith("#")) continue;
|
if (!line || line === "services:" || line.startsWith("#")) continue;
|
||||||
|
|
||||||
const service = raw.match(/^ ([A-Za-z0-9_-]+):\s*$/);
|
const service = raw.match(/^ ([A-Za-z0-9_-]+):\s*$/);
|
||||||
if (service) {
|
if (service) {
|
||||||
if (current?.name) services.push(normalizeService(current));
|
if (current?.name) services.push(normalizeService(current));
|
||||||
current = { name: service[1] };
|
current = { name: service[1] };
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!current) continue;
|
if (!current) continue;
|
||||||
const prop = line.match(/^(url|provides|required):\s*(.*)$/);
|
const prop = line.match(/^(url|provides|required):\s*(.*)$/);
|
||||||
if (!prop) continue;
|
if (!prop) continue;
|
||||||
|
|
||||||
const [, key, value] = prop;
|
const [, key, value] = prop;
|
||||||
if (key === "url") current.url = value;
|
if (key === "url") current.url = value;
|
||||||
if (key === "required") current.required = value === "true";
|
if (key === "required") current.required = value === "true";
|
||||||
if (key === "provides") current.provides = value.replace(/^\[/, "").replace(/\]$/, "").split(",").map((s) => s.trim()).filter(Boolean);
|
if (key === "provides") current.provides = parseInlineList(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current?.name) services.push(normalizeService(current));
|
if (current?.name) services.push(normalizeService(current));
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|
@ -51,15 +56,12 @@ export function loadFactoryCapabilities(file: string): FactoryCapabilityService[
|
||||||
return parseFactoryCapabilities(fs.readFileSync(file, "utf-8"));
|
return parseFactoryCapabilities(fs.readFileSync(file, "utf-8"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function probeFactoryCapabilities(services: FactoryCapabilityService[], fetcher: FetchLike = fetch): Promise<CapabilityReport> {
|
export async function probeFactoryCapabilities(
|
||||||
const results = await Promise.all(services.map(async (svc): Promise<CapabilityProbeResult> => {
|
services: FactoryCapabilityService[],
|
||||||
try {
|
fetcher: FetchLike = fetch,
|
||||||
const res = await fetcher(svc.url);
|
timeoutMs = 5_000,
|
||||||
return { ...svc, ok: res.ok, status: res.status, error: res.ok ? undefined : `HTTP ${res.status}` };
|
): Promise<CapabilityReport> {
|
||||||
} catch (error) {
|
const results = await Promise.all(services.map((svc) => probeCapability(svc, fetcher, timeoutMs)));
|
||||||
return { ...svc, ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
const available = [...new Set(results.filter((r) => r.ok).flatMap((r) => r.provides))];
|
const available = [...new Set(results.filter((r) => r.ok).flatMap((r) => r.provides))];
|
||||||
const missing = [...new Set(results.filter((r) => !r.ok).flatMap((r) => r.provides))];
|
const missing = [...new Set(results.filter((r) => !r.ok).flatMap((r) => r.provides))];
|
||||||
return { results, available, missing, requiredOk: results.every((r) => !r.required || r.ok) };
|
return { results, available, missing, requiredOk: results.every((r) => !r.required || r.ok) };
|
||||||
|
|
@ -70,7 +72,7 @@ export function formatCapabilityReport(report: CapabilityReport): string {
|
||||||
"",
|
"",
|
||||||
" Factory Capabilities",
|
" Factory Capabilities",
|
||||||
" ─────────────────────────────",
|
" ─────────────────────────────",
|
||||||
...report.results.map((r) => ` ${r.ok ? "OK " : "FAIL"} ${r.name} provides ${r.provides.join(", ")}${r.ok ? "" : `: ${r.error ?? `HTTP ${r.status ?? "unknown"}`}`}`),
|
...report.results.map((r) => ` ${r.ok ? "OK " : "FAIL"} ${r.name} provides ${r.provides.join(", ") || "nothing"}${r.ok ? "" : `: ${r.error ?? `HTTP ${r.status ?? "unknown"}`}`}`),
|
||||||
"",
|
"",
|
||||||
` available: ${report.available.join(", ") || "none"}`,
|
` available: ${report.available.join(", ") || "none"}`,
|
||||||
` missing: ${report.missing.join(", ") || "none"}`,
|
` missing: ${report.missing.join(", ") || "none"}`,
|
||||||
|
|
@ -78,11 +80,31 @@ export function formatCapabilityReport(report: CapabilityReport): string {
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function probeCapability(svc: FactoryCapabilityService, fetcher: FetchLike, timeoutMs: number): Promise<CapabilityProbeResult> {
|
||||||
|
if (!svc.url) return { ...svc, ok: false, error: "missing url" };
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await fetcher(svc.url, { signal: controller.signal });
|
||||||
|
return { ...svc, ok: res.ok, status: res.status, error: res.ok ? undefined : `HTTP ${res.status}` };
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error && error.name === "AbortError" ? "timed out" : error instanceof Error ? error.message : String(error);
|
||||||
|
return { ...svc, ok: false, error: message };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseInlineList(value: string): string[] {
|
||||||
|
return value.replace(/^\[/, "").replace(/\]$/, "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeService(svc: Partial<FactoryCapabilityService>): FactoryCapabilityService {
|
function normalizeService(svc: Partial<FactoryCapabilityService>): FactoryCapabilityService {
|
||||||
return {
|
return {
|
||||||
name: svc.name ?? "unknown",
|
name: svc.name ?? "unknown",
|
||||||
url: svc.url ?? "",
|
url: svc.url ?? "",
|
||||||
provides: svc.provides ?? [],
|
provides: svc.provides ?? [],
|
||||||
required: svc.required ?? false,
|
required: svc.required ?? true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue