diff --git a/factory-capabilities.yaml b/factory-capabilities.yaml index 0c60601..400c27d 100644 --- a/factory-capabilities.yaml +++ b/factory-capabilities.yaml @@ -1,6 +1,6 @@ services: deploy-webhook: - url: http://77.42.112.29:8098/health + url: http://77.42.112.29:8099/deploy provides: [deploy] required: true git-proxy: diff --git a/src/fable5/cyber-preflight.ts b/src/fable5/cyber-preflight.ts index 6174074..c2a4825 100644 --- a/src/fable5/cyber-preflight.ts +++ b/src/fable5/cyber-preflight.ts @@ -33,7 +33,7 @@ export interface CyberPreflightOptions { const REQUIRED_ENDPOINTS = [ ["git_proxy_health", "http://127.0.0.1:8099/health"], ["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; 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)); 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); diff --git a/src/fable5/cyber-reconcile.test.ts b/src/fable5/cyber-reconcile.test.ts index 8d52f15..f67067e 100644 --- a/src/fable5/cyber-reconcile.test.ts +++ b/src/fable5/cyber-reconcile.test.ts @@ -31,7 +31,7 @@ function writeInputs(root: string, checks: Array<{ name: string; ok: boolean; st }, null, 2)); fs.writeFileSync(capabilities, `services: deploy-webhook: - url: http://127.0.0.1:8098/health + url: http://127.0.0.1:8099/deploy provides: [deploy] required: true git-proxy: diff --git a/src/fable5/factory-capabilities.test.ts b/src/fable5/factory-capabilities.test.ts index 7e93657..c0546b4 100644 --- a/src/fable5/factory-capabilities.test.ts +++ b/src/fable5/factory-capabilities.test.ts @@ -3,7 +3,7 @@ import { formatCapabilityReport, parseFactoryCapabilities, probeFactoryCapabilit const yaml = `services: deploy-webhook: - url: http://77.42.112.29:8098/health + url: http://77.42.112.29:8099/deploy provides: [deploy] required: true git-proxy: @@ -21,7 +21,7 @@ describe("factory capabilities", () => { it("fails required capabilities that return 404", async () => { 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.available).toContain("deploy"); diff --git a/src/fable5/factory-capabilities.ts b/src/fable5/factory-capabilities.ts index 7d334cd..fcfd44c 100644 --- a/src/fable5/factory-capabilities.ts +++ b/src/fable5/factory-capabilities.ts @@ -20,7 +20,7 @@ export interface CapabilityReport { 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[] { // 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 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}` }; + const res = await fetcher(svc.url, { method: svc.provides.includes("deploy") ? "POST" : "GET", signal: controller.signal }); + // 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) { const message = error instanceof Error && error.name === "AbortError" ? "timed out" : error instanceof Error ? error.message : String(error); return { ...svc, ok: false, error: message }; diff --git a/src/fable5/factory-status.test.ts b/src/fable5/factory-status.test.ts index e462d63..34032f9 100644 --- a/src/fable5/factory-status.test.ts +++ b/src/fable5/factory-status.test.ts @@ -17,9 +17,9 @@ describe("factory check", () => { it("accepts factory-status 200", async () => { const result = await checkFactory(async (url) => - url.includes(":8099/") + url.includes("/factory-status") ? ok({ status: "ok", containers: 25 }) - : ok({ status: "ok", service: "deploy-webhook" }), + : fail(401), ); expect(result.factoryAvailable).toBe(true); @@ -29,7 +29,7 @@ describe("factory check", () => { it("degrades factory-status 404 to unknown while preserving deploy health", async () => { 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); @@ -42,7 +42,7 @@ describe("factory check", () => { it("degrades malformed factory-status JSON to unknown", async () => { 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); @@ -52,7 +52,7 @@ describe("factory check", () => { it("degrades factory-status timeout without using stale memory", async () => { 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" })))); return ok({ status: "stale-memory-should-not-appear" }); }, 1); diff --git a/src/fable5/factory-status.ts b/src/fable5/factory-status.ts index 78e55be..301f336 100644 --- a/src/fable5/factory-status.ts +++ b/src/fable5/factory-status.ts @@ -23,9 +23,9 @@ export interface FactoryCheckResult { } 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 }>; +type FetchLike = (url: string, init?: { method?: string; signal?: AbortSignal }) => Promise<{ ok: boolean; status: number; json(): Promise }>; function asRecord(value: unknown): Record { return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; @@ -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. const [factoryResult, deployResult] = await Promise.allSettled([ getJson(fetcher, FACTORY_STATUS_URL, timeoutMs), - getJson(fetcher, DEPLOY_HEALTH_URL, timeoutMs), + checkDeployRoute(fetcher, timeoutMs), ]); const errors: string[] = []; 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 { + // 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 { return result.factoryAvailable && result.deployAvailable && result.factory.status === "ok" && result.deploy.status === "ok"; }