feat(cli): validate prerequisites and seal installation plans
This commit is contained in:
@@ -13,6 +13,8 @@ const database = databaseConfigurationSchema.extend({
|
||||
});
|
||||
const bootstrap = z.object({ schemaVersion: z.literal(1), databases: z.array(database).min(1).max(1000) }).strict();
|
||||
|
||||
export const parseDatabaseBootstrap = (value: unknown) => bootstrap.parse(value);
|
||||
|
||||
export interface BootstrapReference { field: string; path: string }
|
||||
|
||||
/** Offline bootstrap boundary: runtime Catalog owns the resulting bindings after import. */
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { S3Client, ListObjectsV2Command } from "@aws-sdk/client-s3";
|
||||
import { parseWorkspaceYaml } from "../workspaces/schema.js";
|
||||
import { evidencePolicy } from "../workspaces/evidence/preprocessing.js";
|
||||
import type { parseDatabaseBootstrap } from "./bootstrap-documents.js";
|
||||
import { probePublicEvidenceUrl, publicEvidenceAgent } from "./evidence-probe-http.js";
|
||||
|
||||
type Entry = ReturnType<typeof parseDatabaseBootstrap>["databases"][number];
|
||||
|
||||
/** Read-only availability probes; domain correctness and materialization remain runtime gates. */
|
||||
export async function probeEvidence(entry: Entry, root: string): Promise<void> {
|
||||
const evidence = parseWorkspaceYaml(readFileSync(join(root, entry.workspaceId, "workspace.yaml"), "utf8")).evidence;
|
||||
if (!evidence || evidence.source.type === "filesystem") return;
|
||||
if (evidencePolicy(evidence)) throw new Error("Evidence egress policy refused");
|
||||
const secret = (name: keyof NonNullable<Entry["evidenceSecretFiles"]>) => {
|
||||
const path = entry.evidenceSecretFiles?.[name];
|
||||
if (!path) throw new Error("Evidence credential missing");
|
||||
return readFileSync(path, "utf8").trim();
|
||||
};
|
||||
const source = evidence.source;
|
||||
if (source.type === "http") {
|
||||
const urls: unknown = source.authentication === "signed_urls_file"
|
||||
? JSON.parse(secret("evidence.signed_urls")) : source.uris;
|
||||
if (!Array.isArray(urls) || urls.length !== source.uris.length || urls.length > 1000) throw new Error("Invalid signed URLs");
|
||||
for (const [index, value] of urls.entries()) {
|
||||
if (typeof value !== "string") throw new Error("Invalid signed URL");
|
||||
const url = new URL(value);
|
||||
const provenance = new URL(source.uris[index]);
|
||||
// Signed queries may authorize the same identity, never a different host/path.
|
||||
if (url.origin !== provenance.origin || url.pathname !== provenance.pathname || url.username || url.password || url.hash) throw new Error("Invalid signed URL identity");
|
||||
await probePublicEvidenceUrl(url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// The shared runtime policy currently permits trusted AWS endpoints with explicit file credentials.
|
||||
const location = new URL(source.uri);
|
||||
const client = new S3Client({
|
||||
region: source.region ?? "us-east-1", maxAttempts: 1,
|
||||
requestHandler: { httpsAgent: publicEvidenceAgent(), connectionTimeout: 5_000, requestTimeout: 5_000 },
|
||||
credentials: { accessKeyId: secret("evidence.access_key"), secretAccessKey: secret("evidence.secret_key"),
|
||||
...(entry.evidenceSecretFiles?.["evidence.session_token"] ? { sessionToken: secret("evidence.session_token") } : {}) },
|
||||
});
|
||||
try {
|
||||
await client.send(new ListObjectsV2Command({ Bucket: location.hostname, Prefix: decodeURIComponent(location.pathname.slice(1)), MaxKeys: 1 }), { abortSignal: AbortSignal.timeout(5_000) });
|
||||
} finally { client.destroy(); }
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse } from "yaml";
|
||||
import { parseDatabaseBootstrap } from "./bootstrap-documents.js";
|
||||
import { runBootstrapValidation } from "./bootstrap-cli.js";
|
||||
import { createConcreteDiagnosticAdapters, type DiagnosticAdapters } from "../workspaces/diagnostics.js";
|
||||
import { probeEvidence } from "./bootstrap-evidence-probes.js";
|
||||
|
||||
interface ProbeCheck { id: string; outcome: "passed" | "error"; field: string; action: string }
|
||||
|
||||
/** Uses the same read-only, authenticated connector diagnostics as the Catalog. */
|
||||
export async function probeBootstrapDependencies(value: unknown, adapters: DiagnosticAdapters = createConcreteDiagnosticAdapters(), workspaceRoot?: string) {
|
||||
const document = parseDatabaseBootstrap(value);
|
||||
const checks: ProbeCheck[] = [];
|
||||
for (const [index, entry] of document.databases.entries()) {
|
||||
let outcome: ProbeCheck["outcome"] = "passed";
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5_000);
|
||||
try {
|
||||
if (entry.binding.transport === "ssh_tunnel") throw new Error("session transport unavailable");
|
||||
await adapters.probeConnector({
|
||||
role: "dwh", transport: entry.binding.transport,
|
||||
host: entry.binding.host, port: entry.binding.port, user: entry.binding.username,
|
||||
baseUrl: entry.binding.baseUrl,
|
||||
credentialFile: entry.binding.transport === "rest_api" ? entry.secretFiles.apiKey : entry.secretFiles.password,
|
||||
tlsCaFile: entry.secretFiles.tlsCa, tlsServername: entry.binding.tlsServername,
|
||||
resource: { database: entry.databaseName, schema: entry.schema },
|
||||
timeoutMs: 5_000, signal: controller.signal,
|
||||
diagnostic: { method: "GET", path: entry.binding.restPath ?? "/health", auth: entry.binding.restAuth ?? "bearer" },
|
||||
});
|
||||
} catch { outcome = "error"; } finally { clearTimeout(timer); }
|
||||
checks.push({ id: `database-${index}`, outcome, field: `database-bootstrap.databases.${index}`,
|
||||
action: entry.binding.transport === "ssh_tunnel"
|
||||
? "Choose postgres_direct or rest_api for NL-to-SQL practice; SSH diagnostics alone cannot establish session readiness."
|
||||
: "Require an authenticated read-only connection and access to the configured database/schema; correct endpoint, permissions or protected credentials." });
|
||||
if (workspaceRoot) {
|
||||
let evidenceOutcome: ProbeCheck["outcome"] = "passed";
|
||||
try { await probeEvidence(entry, workspaceRoot); } catch { evidenceOutcome = "error"; }
|
||||
checks.push({ id: `evidence-${index}`, outcome: evidenceOutcome, field: `workspaces.${index}.evidence`, action: "Require readable local Evidence or authenticated bounded HTTP/S3 access under the canonical egress policy; domain meaning is verified during practice." });
|
||||
}
|
||||
}
|
||||
return { schema_version: 1, ok: checks.every((check) => check.outcome === "passed"), checks };
|
||||
}
|
||||
|
||||
export async function runBootstrapProbes(args: string[]) {
|
||||
const validation = runBootstrapValidation(args);
|
||||
if (validation.status !== 0) return validation;
|
||||
try {
|
||||
const report = await probeBootstrapDependencies(parse(readFileSync(args[3], "utf8")), undefined, args[1]);
|
||||
return { status: report.ok ? 0 : 1, output: JSON.stringify(report) };
|
||||
} catch {
|
||||
return { status: 1, output: JSON.stringify({ schema_version: 1, ok: false, checks: [{ id: "database-probes", outcome: "error", field: "database-bootstrap", action: "Revalidate prepared documents and protected credential references." }] }) };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { lookup } from "node:dns/promises";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { request as httpsRequest, Agent } from "node:https";
|
||||
import type { LookupFunction } from "node:net";
|
||||
import ipaddr from "ipaddr.js";
|
||||
|
||||
const refused = () => new Error("Evidence network policy refused");
|
||||
function normalizedPublicAddress(value: string): string {
|
||||
const address = ipaddr.process(value);
|
||||
if (address.range() !== "unicast") throw refused();
|
||||
return address.toString();
|
||||
}
|
||||
|
||||
/** Reject the entire DNS answer set, then pin the connection to that verified set. */
|
||||
export async function resolvePublicEvidenceHost(hostname: string) {
|
||||
const values = await lookup(hostname.replace(/^\[|\]$/g, ""), { all: true });
|
||||
if (!values.length) throw refused();
|
||||
values.forEach((value) => normalizedPublicAddress(value.address));
|
||||
return values;
|
||||
}
|
||||
const publicLookup: LookupFunction = (hostname, options, callback) => {
|
||||
void resolvePublicEvidenceHost(hostname).then((values) => {
|
||||
if (options.all) callback(null, values);
|
||||
else callback(null, values[0].address, values[0].family);
|
||||
}, () => callback(refused(), "", 0));
|
||||
};
|
||||
|
||||
// Node's direct agent does not inherit HTTP proxy environment or ambient credentials.
|
||||
export const publicEvidenceAgent = () => new Agent({ lookup: publicLookup });
|
||||
|
||||
export async function probePublicEvidenceUrl(url: URL): Promise<void> {
|
||||
if (!['http:', 'https:'].includes(url.protocol)) throw refused();
|
||||
const signal = AbortSignal.timeout(5_000);
|
||||
const values = await Promise.race([
|
||||
resolvePublicEvidenceHost(url.hostname),
|
||||
new Promise<never>((_, reject) => signal.addEventListener("abort", () => reject(refused()), { once: true })),
|
||||
]);
|
||||
signal.throwIfAborted();
|
||||
const allowed = new Set(values.map((value) => normalizedPublicAddress(value.address)));
|
||||
const pinned: LookupFunction = (_hostname, options, callback) => {
|
||||
if (options.all) callback(null, values);
|
||||
else callback(null, values[0].address, values[0].family);
|
||||
};
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(url, {
|
||||
method: "GET", lookup: pinned, signal, agent: false,
|
||||
}, (response) => {
|
||||
try {
|
||||
const peer = response.socket.remoteAddress;
|
||||
if (!peer || !allowed.has(normalizedPublicAddress(peer)) || !response.statusCode || response.statusCode < 200 || response.statusCode >= 300) throw refused();
|
||||
resolve();
|
||||
} catch { reject(refused()); } finally { response.destroy(); }
|
||||
});
|
||||
request.on("error", () => reject(refused()));
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
/** Compiled with its runtime for the host CLI: no installation, Docker or host Node required. */
|
||||
import { runWorkspaceDocuments } from "./workspaces/documents.js";
|
||||
import { runBootstrapValidation } from "./catalog/bootstrap-cli.js";
|
||||
import { runBootstrapProbes } from "./catalog/bootstrap-probes.js";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const result = args[0] === "bootstrap" ? runBootstrapValidation(args.slice(1)) : runWorkspaceDocuments(args);
|
||||
const result = args[0] === "probe" ? await runBootstrapProbes(args.slice(1))
|
||||
: args[0] === "bootstrap" ? runBootstrapValidation(args.slice(1)) : runWorkspaceDocuments(args);
|
||||
console.log(result.output);
|
||||
process.exitCode = result.status;
|
||||
|
||||
Reference in New Issue
Block a user