125 lines
4.7 KiB
TypeScript
125 lines
4.7 KiB
TypeScript
import * as fs from "node:fs";
|
|
import * as path from "node:path";
|
|
|
|
export interface FactoryDeployOptions {
|
|
command: string;
|
|
token?: string;
|
|
url?: string;
|
|
yes?: boolean;
|
|
gateReceipt?: 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;
|
|
}
|
|
|
|
const DEFAULT_URL = "http://77.42.112.29:8099/deploy";
|
|
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): boolean {
|
|
if (!fs.existsSync(file)) return false;
|
|
return fs.readFileSync(file, "utf-8")
|
|
.split(/\r?\n/)
|
|
.filter(Boolean)
|
|
.some((line) => {
|
|
try {
|
|
const json = JSON.parse(line) as { status?: unknown; task?: unknown; repo?: unknown; commands?: unknown; createdAt?: unknown };
|
|
return json.status === "passed"
|
|
&& typeof json.task === "string"
|
|
&& typeof json.repo === "string"
|
|
&& Array.isArray(json.commands)
|
|
&& json.commands.some((cmd) => typeof cmd === "string" && cmd.includes("fable5 verify"))
|
|
&& typeof json.createdAt === "string"
|
|
&& Number.isFinite(Date.parse(json.createdAt));
|
|
} catch {
|
|
return false;
|
|
}
|
|
});
|
|
}
|
|
|
|
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 (!opts.gateReceipt) return "missing --gate-receipt verifier evidence";
|
|
if (!hasPassedGateReceipt(opts.gateReceipt)) return "gate receipt missing passed verifier status";
|
|
if (!opts.token) return "missing deploy token";
|
|
if (!isAllowedFactoryCommand(opts.command)) return "command is outside the guarded deploy allowlist";
|
|
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 };
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|