fix: align factory probes with git-proxy routes

This commit is contained in:
artale 2026-06-18 01:35:37 +02:00
parent 9df2929446
commit 2fadb8a428
7 changed files with 38 additions and 17 deletions

View File

@ -1,6 +1,6 @@
services: services:
deploy-webhook: deploy-webhook:
url: http://77.42.112.29:8098/health url: http://77.42.112.29:8099/deploy
provides: [deploy] provides: [deploy]
required: true required: true
git-proxy: git-proxy:

View File

@ -33,7 +33,7 @@ export interface CyberPreflightOptions {
const REQUIRED_ENDPOINTS = [ const REQUIRED_ENDPOINTS = [
["git_proxy_health", "http://127.0.0.1:8099/health"], ["git_proxy_health", "http://127.0.0.1:8099/health"],
["git_proxy_factory_status", "http://127.0.0.1:8099/factory-status"], ["git_proxy_factory_status", "http://127.0.0.1:8099/factory-status"],
["deploy_webhook_health", "http://127.0.0.1:8098/health"], ["deploy_webhook_health", "http://127.0.0.1:8099/deploy"],
] as const; ] as const;
export function runCyberPreflight(opts: CyberPreflightOptions): CyberPreflightReceipt { export function runCyberPreflight(opts: CyberPreflightOptions): CyberPreflightReceipt {
@ -45,7 +45,10 @@ export function runCyberPreflight(opts: CyberPreflightOptions): CyberPreflightRe
checks.push(runCheck("docker_inventory", "ssh", ["-p", String(port), `root@${opts.host}`, "docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"], runner)); checks.push(runCheck("docker_inventory", "ssh", ["-p", String(port), `root@${opts.host}`, "docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"], runner));
for (const [name, url] of REQUIRED_ENDPOINTS) { for (const [name, url] of REQUIRED_ENDPOINTS) {
checks.push(runCheck(name, "ssh", ["-p", String(port), `root@${opts.host}`, `curl -fsS -m 5 ${url}`], runner)); const probe = name === "deploy_webhook_health"
? `test "$(curl -sS -m 5 -o /dev/null -w '%{http_code}' -X POST ${url})" = "401"`
: `curl -fsS -m 5 ${url}`;
checks.push(runCheck(name, "ssh", ["-p", String(port), `root@${opts.host}`, probe], runner));
} }
const requiredOk = checks.filter((c) => c.name !== "docker_inventory").every((c) => c.ok); const requiredOk = checks.filter((c) => c.name !== "docker_inventory").every((c) => c.ok);

View File

@ -31,7 +31,7 @@ function writeInputs(root: string, checks: Array<{ name: string; ok: boolean; st
}, null, 2)); }, null, 2));
fs.writeFileSync(capabilities, `services: fs.writeFileSync(capabilities, `services:
deploy-webhook: deploy-webhook:
url: http://127.0.0.1:8098/health url: http://127.0.0.1:8099/deploy
provides: [deploy] provides: [deploy]
required: true required: true
git-proxy: git-proxy:

View File

@ -3,7 +3,7 @@ import { formatCapabilityReport, parseFactoryCapabilities, probeFactoryCapabilit
const yaml = `services: const yaml = `services:
deploy-webhook: deploy-webhook:
url: http://77.42.112.29:8098/health url: http://77.42.112.29:8099/deploy
provides: [deploy] provides: [deploy]
required: true required: true
git-proxy: git-proxy:
@ -21,7 +21,7 @@ describe("factory capabilities", () => {
it("fails required capabilities that return 404", async () => { it("fails required capabilities that return 404", async () => {
const services = parseFactoryCapabilities(yaml); const services = parseFactoryCapabilities(yaml);
const report = await probeFactoryCapabilities(services, async (url) => ({ ok: !url.includes("8099"), status: url.includes("8099") ? 404 : 200 })); const report = await probeFactoryCapabilities(services, async (url) => ({ ok: url.includes("/deploy"), status: url.includes("/deploy") ? 401 : 404 }));
expect(report.requiredOk).toBe(false); expect(report.requiredOk).toBe(false);
expect(report.available).toContain("deploy"); expect(report.available).toContain("deploy");

View File

@ -20,7 +20,7 @@ export interface CapabilityReport {
requiredOk: boolean; requiredOk: boolean;
} }
type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; status: number }>; type FetchLike = (url: string, init?: { method?: string; 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. // ponytail: tiny YAML subset for this checked-in registry; add a parser only when the format grows.
@ -86,8 +86,10 @@ async function probeCapability(svc: FactoryCapabilityService, fetcher: FetchLike
const controller = new AbortController(); const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs); const timeout = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const res = await fetcher(svc.url, { signal: controller.signal }); const res = await fetcher(svc.url, { method: svc.provides.includes("deploy") ? "POST" : "GET", signal: controller.signal });
return { ...svc, ok: res.ok, status: res.status, error: res.ok ? undefined : `HTTP ${res.status}` }; // ponytail: unauthenticated 401 proves the deploy route exists without requiring the deploy token.
const ok = res.ok || (svc.provides.includes("deploy") && res.status === 401);
return { ...svc, ok, status: res.status, error: ok ? undefined : `HTTP ${res.status}` };
} catch (error) { } catch (error) {
const message = error instanceof Error && error.name === "AbortError" ? "timed out" : error instanceof Error ? error.message : String(error); const message = error instanceof Error && error.name === "AbortError" ? "timed out" : error instanceof Error ? error.message : String(error);
return { ...svc, ok: false, error: message }; return { ...svc, ok: false, error: message };

View File

@ -17,9 +17,9 @@ describe("factory check", () => {
it("accepts factory-status 200", async () => { it("accepts factory-status 200", async () => {
const result = await checkFactory(async (url) => const result = await checkFactory(async (url) =>
url.includes(":8099/") url.includes("/factory-status")
? ok({ status: "ok", containers: 25 }) ? ok({ status: "ok", containers: 25 })
: ok({ status: "ok", service: "deploy-webhook" }), : fail(401),
); );
expect(result.factoryAvailable).toBe(true); expect(result.factoryAvailable).toBe(true);
@ -29,7 +29,7 @@ describe("factory check", () => {
it("degrades factory-status 404 to unknown while preserving deploy health", async () => { it("degrades factory-status 404 to unknown while preserving deploy health", async () => {
const result = await checkFactory(async (url) => const result = await checkFactory(async (url) =>
url.includes(":8099/") ? fail(404) : ok({ status: "ok", service: "deploy-webhook" }), url.includes("/factory-status") ? fail(404) : fail(401),
); );
expect(result.factoryAvailable).toBe(false); expect(result.factoryAvailable).toBe(false);
@ -42,7 +42,7 @@ describe("factory check", () => {
it("degrades malformed factory-status JSON to unknown", async () => { it("degrades malformed factory-status JSON to unknown", async () => {
const result = await checkFactory(async (url) => const result = await checkFactory(async (url) =>
url.includes(":8099/") ? ok(null) : ok({ status: "ok", service: "deploy-webhook" }), url.includes("/factory-status") ? ok(null) : fail(401),
); );
expect(result.factoryAvailable).toBe(false); expect(result.factoryAvailable).toBe(false);
@ -52,7 +52,7 @@ describe("factory check", () => {
it("degrades factory-status timeout without using stale memory", async () => { it("degrades factory-status timeout without using stale memory", async () => {
const result = await checkFactory(async (url, init) => { const result = await checkFactory(async (url, init) => {
if (!url.includes(":8099/")) return ok({ status: "ok", service: "deploy-webhook" }); if (!url.includes("/factory-status")) return fail(401);
await new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" })))); await new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(Object.assign(new Error("aborted"), { name: "AbortError" }))));
return ok({ status: "stale-memory-should-not-appear" }); return ok({ status: "stale-memory-should-not-appear" });
}, 1); }, 1);

View File

@ -23,9 +23,9 @@ export interface FactoryCheckResult {
} }
const FACTORY_STATUS_URL = "http://77.42.112.29:8099/factory-status"; const FACTORY_STATUS_URL = "http://77.42.112.29:8099/factory-status";
const DEPLOY_HEALTH_URL = "http://77.42.112.29:8098/health"; const DEPLOY_HEALTH_URL = "http://77.42.112.29:8099/deploy";
type FetchLike = (url: string, init?: { signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>; type FetchLike = (url: string, init?: { method?: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>;
function asRecord(value: unknown): Record<string, unknown> { function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {}; return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
@ -53,7 +53,7 @@ export async function checkFactory(fetcher: FetchLike = fetch, timeoutMs = 5_000
// ponytail: live HTTP only; no memory fallback, because stale factory state must not open the deploy gate. // ponytail: live HTTP only; no memory fallback, because stale factory state must not open the deploy gate.
const [factoryResult, deployResult] = await Promise.allSettled([ const [factoryResult, deployResult] = await Promise.allSettled([
getJson(fetcher, FACTORY_STATUS_URL, timeoutMs), getJson(fetcher, FACTORY_STATUS_URL, timeoutMs),
getJson(fetcher, DEPLOY_HEALTH_URL, timeoutMs), checkDeployRoute(fetcher, timeoutMs),
]); ]);
const errors: string[] = []; const errors: string[] = [];
if (factoryResult.status === "rejected") errors.push(factoryResult.reason instanceof Error ? factoryResult.reason.message : String(factoryResult.reason)); if (factoryResult.status === "rejected") errors.push(factoryResult.reason instanceof Error ? factoryResult.reason.message : String(factoryResult.reason));
@ -68,6 +68,22 @@ export async function checkFactory(fetcher: FetchLike = fetch, timeoutMs = 5_000
}; };
} }
async function checkDeployRoute(fetcher: FetchLike, timeoutMs: number): Promise<DeployHealth> {
// ponytail: unauthenticated 401 proves the deploy route exists without leaking or requiring the deploy token.
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetcher(DEPLOY_HEALTH_URL, { method: "POST", signal: controller.signal });
if (res.status !== 401 && !res.ok) throw new Error(`${DEPLOY_HEALTH_URL} returned ${res.status}`);
return { status: "ok", service: "deploy-webhook" };
} catch (error) {
if (error instanceof Error && error.name === "AbortError") throw new Error(`${DEPLOY_HEALTH_URL} timed out`);
throw error;
} finally {
clearTimeout(timeout);
}
}
export function factoryGateReady(result: FactoryCheckResult): boolean { export function factoryGateReady(result: FactoryCheckResult): boolean {
return result.factoryAvailable && result.deployAvailable && result.factory.status === "ok" && result.deploy.status === "ok"; return result.factoryAvailable && result.deployAvailable && result.factory.status === "ok" && result.deploy.status === "ok";
} }