127 lines
4.7 KiB
TypeScript
127 lines
4.7 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
import { CANONICAL_DEPLOY_ROUTE } from "./factory-routes.js";
|
|
import { validateGateReceipt } from "./zte-protocol.js";
|
|
|
|
export interface FactoryDeployOptions {
|
|
command: string;
|
|
token?: string;
|
|
url?: string;
|
|
yes?: boolean;
|
|
gateReceipt?: string;
|
|
planReceipt?: string;
|
|
out?: string;
|
|
now?: Date;
|
|
fetcher?: (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ status: number; json(): Promise<unknown>; text(): Promise<string> }>;
|
|
}
|
|
|
|
export interface FactoryDeployReceipt {
|
|
created_at: string;
|
|
url: string;
|
|
command: string;
|
|
deploy_attempted: boolean;
|
|
deploy_allowed: boolean;
|
|
status: "blocked" | "posted" | "failed";
|
|
http_status?: number;
|
|
response?: unknown;
|
|
reason?: string;
|
|
provenance?: { gateReceipt?: string; plan?: string };
|
|
}
|
|
|
|
const DEFAULT_URL = CANONICAL_DEPLOY_ROUTE;
|
|
const SAFE_COMMANDS = [
|
|
/^echo [A-Za-z0-9 _.,:;@/+\-=]+$/,
|
|
/^cat \/tmp\/[A-Za-z0-9._/-]+$/,
|
|
/^docker ps(?: --format '[A-Za-z0-9 {}_.:;@/+\-=|]+')?$/,
|
|
/^docker logs --tail \d{1,4} [A-Za-z0-9_.-]+$/,
|
|
/^docker inspect [A-Za-z0-9_.-]+$/,
|
|
/^curl -fsS -m \d{1,2} http:\/\/127\.0\.0\.1:8099\/(?:health|factory-status)$/,
|
|
];
|
|
|
|
export async function deployToFactory(opts: FactoryDeployOptions): Promise<FactoryDeployReceipt> {
|
|
const url = opts.url ?? DEFAULT_URL;
|
|
const blocked = blockReason(opts, url);
|
|
if (blocked) return writeMaybe(opts.out, receipt(opts, url, "blocked", false, false, blocked));
|
|
|
|
try {
|
|
const res = await (opts.fetcher ?? fetchJson)(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${opts.token}` },
|
|
body: JSON.stringify({ command: opts.command }),
|
|
});
|
|
const body = await readBody(res);
|
|
const posted = res.status >= 200 && res.status < 300;
|
|
return writeMaybe(opts.out, {
|
|
...receipt(opts, url, posted ? "posted" : "failed", true, posted, posted ? undefined : `HTTP ${res.status}`),
|
|
http_status: res.status,
|
|
response: body,
|
|
});
|
|
} catch (error) {
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
return writeMaybe(opts.out, receipt(opts, url, "failed", false, false, reason));
|
|
}
|
|
}
|
|
|
|
export function isAllowedFactoryCommand(command: string): boolean {
|
|
return SAFE_COMMANDS.some((pattern) => pattern.test(command.trim()));
|
|
}
|
|
|
|
export function isAllowedDeployUrl(url: string): boolean {
|
|
return url === DEFAULT_URL;
|
|
}
|
|
|
|
export function hasPassedGateReceipt(file: string, planReceipt?: string): boolean {
|
|
return validateGateReceipt(file, { requiredCommands: ["factory gate", "fable5 verify"], requiredPlanReceipt: planReceipt }).ok;
|
|
}
|
|
|
|
export function writeFactoryDeployReceipt(file: string, value: FactoryDeployReceipt): FactoryDeployReceipt {
|
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
return value;
|
|
}
|
|
|
|
function blockReason(opts: FactoryDeployOptions, url: string): string | undefined {
|
|
if (!opts.yes) return "missing --yes human approval";
|
|
if (!isAllowedDeployUrl(url)) return "deploy url must be the guarded 8099 git-proxy route";
|
|
if (!isAllowedFactoryCommand(opts.command)) return "command is outside the guarded deploy allowlist";
|
|
if (!opts.gateReceipt) return "missing --gate-receipt verifier evidence";
|
|
if (!hasPassedGateReceipt(opts.gateReceipt, opts.planReceipt)) return "gate receipt missing passed verifier status";
|
|
if (!opts.token) return "missing deploy token";
|
|
return undefined;
|
|
}
|
|
|
|
function receipt(opts: FactoryDeployOptions, url: string, status: FactoryDeployReceipt["status"], attempted: boolean, allowed: boolean, reason?: string): FactoryDeployReceipt {
|
|
return {
|
|
created_at: (opts.now ?? new Date()).toISOString(),
|
|
url,
|
|
command: opts.command,
|
|
deploy_attempted: attempted,
|
|
deploy_allowed: allowed,
|
|
status,
|
|
reason,
|
|
provenance: deployProvenance(opts),
|
|
};
|
|
}
|
|
|
|
function deployProvenance(opts: FactoryDeployOptions): FactoryDeployReceipt["provenance"] {
|
|
if (!opts.gateReceipt) return undefined;
|
|
const validation = validateGateReceipt(opts.gateReceipt, { requiredCommands: ["factory gate", "fable5 verify"] });
|
|
return { gateReceipt: opts.gateReceipt, plan: validation.receipt?.provenance?.plan };
|
|
}
|
|
|
|
function writeMaybe(file: string | undefined, value: FactoryDeployReceipt): FactoryDeployReceipt {
|
|
return file ? writeFactoryDeployReceipt(file, value) : value;
|
|
}
|
|
|
|
async function fetchJson(url: string, init: { method: string; headers: Record<string, string>; body: string }) {
|
|
return fetch(url, init);
|
|
}
|
|
|
|
async function readBody(res: { json(): Promise<unknown>; text(): Promise<string> }): Promise<unknown> {
|
|
try {
|
|
return await res.json();
|
|
} catch {
|
|
return await res.text();
|
|
}
|
|
}
|