400 lines
26 KiB
JavaScript
400 lines
26 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn } from "node:child_process";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { closeSync, constants as fsConstants, fsyncSync, lstatSync, openSync, realpathSync } from "node:fs";
|
|
import { access, lstat, mkdir, open, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
|
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { promisify } from "node:util";
|
|
import { execFile } from "node:child_process";
|
|
import http from "node:http";
|
|
|
|
import { buildSafeEnvironment } from "./p1-acceptance.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const modulePath = fileURLToPath(import.meta.url);
|
|
const defaultRepositoryRoot = realpathSync(resolve(dirname(modulePath), "../.."));
|
|
const HOST = "127.0.0.1";
|
|
const BACKEND_PORT = 8791;
|
|
const FRONTEND_PORT = 8792;
|
|
const HEX64 = /^[0-9a-f]{64}$/;
|
|
const OWNERSHIP_DIGEST = "ownership.sha256";
|
|
|
|
function resolveSystemExecutable(name) {
|
|
for (const candidate of [`/usr/bin/${name}`, `/bin/${name}`, `/opt/homebrew/bin/${name}`, `/usr/local/bin/${name}`]) {
|
|
try {
|
|
const resolved = realpathSync(candidate);
|
|
if (lstatSync(resolved).isFile()) return resolved;
|
|
} catch {}
|
|
}
|
|
throw new Error(`required executable not found: ${name}`);
|
|
}
|
|
function resolveExecutables(repositoryRoot) {
|
|
const repo = realpathSync(repositoryRoot);
|
|
const thtPath = join(repo, "harness", ".venv", "bin", "tht");
|
|
if (!lstatSync(thtPath).isFile()) throw new Error("required executable not found: tht");
|
|
return { gitPath: resolveSystemExecutable("git"), pythonPath: resolveSystemExecutable("python3"), thtPath: realpathSync(thtPath) };
|
|
}
|
|
|
|
function nowIso() { return new Date().toISOString(); }
|
|
function fixedManualRoot(repositoryRoot = defaultRepositoryRoot) { return join(realpathSync(repositoryRoot), ".artifacts", "manual-acceptance", "p11"); }
|
|
function below(parent, child) { const rel = relative(parent, child); return rel !== "" && !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel); }
|
|
function noSymlinkExisting(repo, target) {
|
|
const rel = relative(repo, target);
|
|
if (rel.startsWith("..") || isAbsolute(rel)) throw new Error("root leaves repository");
|
|
let cursor = repo;
|
|
for (const part of rel.split(sep).filter(Boolean)) {
|
|
cursor = join(cursor, part);
|
|
if (!lstatSync(cursor, { throwIfNoEntry: false })) break;
|
|
if (lstatSync(cursor).isSymbolicLink()) throw new Error("owned path contains a symlink");
|
|
}
|
|
}
|
|
async function atomicWrite(path, bytes, mode = 0o600) {
|
|
await mkdir(dirname(path), { recursive: true });
|
|
const staging = join(dirname(path), `.${basename(path)}.${randomBytes(12).toString("hex")}.tmp`);
|
|
let handle;
|
|
try {
|
|
handle = await open(staging, "wx", mode);
|
|
await handle.writeFile(bytes);
|
|
await handle.sync();
|
|
await handle.close();
|
|
handle = undefined;
|
|
await rename(staging, path);
|
|
const directory = openSync(dirname(path), fsConstants.O_RDONLY);
|
|
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
} catch (error) {
|
|
if (handle) await handle.close().catch(() => {});
|
|
await rm(staging, { force: true }).catch(() => {});
|
|
throw error;
|
|
}
|
|
}
|
|
function ownershipDigest(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|
|
async function writeManualOwnership(root, value) {
|
|
const body = `${JSON.stringify(value, null, 2)}\n`;
|
|
await atomicWrite(join(root, "ownership.json"), body);
|
|
await atomicWrite(join(root, OWNERSHIP_DIGEST), `${ownershipDigest(body)}\n`);
|
|
}
|
|
async function git(executable, argv, options = {}) {
|
|
const result = await execFileAsync(executable, argv, { cwd: options.cwd, env: options.env, timeout: options.timeoutMs ?? 30_000, maxBuffer: 8 * 1024 * 1024, encoding: "utf8" });
|
|
return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
|
|
}
|
|
function namespace(id) { return id.toUpperCase().replaceAll("-", "_"); }
|
|
function baseWorkspace(id, evidenceSource) {
|
|
return {
|
|
workspace: { schema_version: 4, id, name: `P1.1 ${id}`, description: `Catalog entry for ${id}`, language: "en" },
|
|
dwh: { engine: "postgres", database: "postgres", schema: "public", supported_transports: ["postgres_direct"] },
|
|
evidence: { source: evidenceSource, policy: { max_chunk_chars: 4000, retain_published_generations: 3 } },
|
|
};
|
|
}
|
|
function descriptors() {
|
|
return [
|
|
baseWorkspace("p11-filesystem", { type: "filesystem", uri: "p11-filesystem/evidence", patterns: ["**/*.md"], max_bytes: 10485760 }),
|
|
baseWorkspace("p11-http", { type: "http", uris: ["https://evidence.example.test/guide.md"], authentication: "signed_urls_file", connect_timeout_ms: 1250, read_timeout_ms: 30001, max_bytes: 12345, max_redirects: 2, allow_private_hosts: false, max_cache_bytes: 67890 }),
|
|
baseWorkspace("p11-s3", { type: "s3", uri: "s3://p11-evidence/published/", endpoint_url: "https://s3.example.test/", region: "eu-west-1", credentials: "static_files", trusted_endpoint: true, allow_private_endpoint: false, allow_insecure_endpoint: false, max_bytes: 12345, max_objects: 33, max_pages: 4, page_size: 5 }),
|
|
];
|
|
}
|
|
function catalog(entries) {
|
|
return { schema_version: 1, workspaces: entries.map(({ workspace }) => ({ id: workspace.id, name: workspace.name, description: workspace.description })) };
|
|
}
|
|
function quote(value) { return `'${String(value).replaceAll("'", `'"'"'`)}'`; }
|
|
function requestFixtures(items) {
|
|
const fixtures = { "status.json": { method: "GET", path: "/workspace-registry/status" }, "pull.json": { method: "POST", path: "/workspace-registry/pull" } };
|
|
for (const workspace of items) {
|
|
const id = workspace.workspace.id;
|
|
fixtures[`validate-${id}.json`] = { workspace };
|
|
fixtures[`publish-${id}.json`] = { action: "create", workspace };
|
|
fixtures[`read-${id}.json`] = { method: "GET", path: `/workspaces/${id}` };
|
|
fixtures[`export-${id}.json`] = { method: "GET", path: `/workspaces/${id}/export` };
|
|
}
|
|
fixtures["negative-invalid-uri.json"] = { workspace: { ...items[0], evidence: { ...items[0].evidence, source: { ...items[0].evidence.source, uri: "/etc/passwd" } } } };
|
|
fixtures["negative-secret-field.json"] = { workspace: { ...items[2], evidence: { ...items[2].evidence, source: { ...items[2].evidence.source, access_key: "CANARY-MUST-BE-REJECTED" } } } };
|
|
return fixtures;
|
|
}
|
|
function curlGet(url, output) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; }
|
|
function curlPost(url, output, body) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --request POST --header 'content-type: application/json' --data-binary @${quote(body)} --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; }
|
|
function curlPostEmpty(url, output) { return `#!/usr/bin/env bash\nset -euo pipefail\ncurl --fail-with-body --silent --show-error --request POST --output ${quote(output)} --write-out 'HTTP %{http_code}\\n' ${quote(url)}\n`; }
|
|
function publishCurl(root, id, previousResponse) {
|
|
const descriptor = join(root, "requests", `publish-${id}.json`);
|
|
const response = join(root, "responses", `publish-${id}.json`);
|
|
return `#!/usr/bin/env bash\nset -euo pipefail\nbase_commit=$(node -e 'const fs=require("node:fs");const value=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));console.log(value.head ?? value.revision?.commit ?? "");' ${quote(previousResponse)})\nnode -e 'const fs=require("node:fs");const body=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));body.baseCommit=process.argv[2];fs.writeFileSync(process.argv[1],JSON.stringify(body,null,2)+"\\n");' ${quote(descriptor)} "$base_commit"\ncurl --fail-with-body --silent --show-error --request POST --header 'content-type: application/json' --data-binary @${quote(descriptor)} --output ${quote(response)} --write-out 'HTTP %{http_code}\\n' 'http://${HOST}:${BACKEND_PORT}/workspaces/publish'\n`; }
|
|
function renderCommand(repo, root, observation) {
|
|
const readResponse = join(root, "responses", "read-p11-filesystem.json");
|
|
const output = join(root, "rendered", `runtime-${observation}.yaml`);
|
|
return `#!/usr/bin/env bash\nset -euo pipefail\nread_snapshot=$(node -e 'const fs=require("node:fs");const read=JSON.parse(fs.readFileSync(process.argv[1],"utf8"));const path=read.revision.snapshotPath;const manifest=JSON.parse(fs.readFileSync(require("node:path").join(require("node:path").dirname(path),"snapshot.json"),"utf8"));const name=require("node:path").basename(path);console.log(JSON.stringify({snapshot:path,digest:manifest.files[name]}));' ${quote(readResponse)})\nsnapshot=$(node -e 'const value=JSON.parse(process.argv[1]);console.log(value.snapshot)' "$read_snapshot")\ndigest=$(node -e 'const value=JSON.parse(process.argv[1]);console.log(value.digest)' "$read_snapshot")\nnode ${quote(join(repo, "backend", "scripts", "p11-render-snapshot.mjs"))} --ownership ${quote(join(root, "ownership.json"))} --snapshot "$snapshot" --output ${quote(output)} --snapshot-sha256 "$digest"\n`;
|
|
}
|
|
function guide(root) {
|
|
return `# P1.1 manual acceptance guide
|
|
|
|
1. Inspect ${join(root, "ownership.json")}, ${join(root, "author", "thoth-workspaces.yaml")}, nested workspace directories, evidence tree, and fixture secret paths without printing secret bytes.
|
|
2. Run ./scripts/p11-manual-acceptance.sh serve and confirm only ${HOST}:${BACKEND_PORT} and ${HOST}:${FRONTEND_PORT} are listening for this lab.
|
|
3. Run commands/http-01-status.sh and inspect responses/status.json plus GET /workspaces for configuration_required slots.
|
|
4. Run the validate and publish scripts once per slot in numeric order.
|
|
5. Inspect Git object IDs for thoth-workspaces.yaml, <id>/workspace.yaml, <id>/evidence, and workspace-docs/<id>.
|
|
6. Retry create/update/delete and verify refusal plus unchanged object IDs.
|
|
7. In ${join(root, "author")}, edit p11-filesystem/workspace.yaml and thoth-workspaces.yaml together, commit, push, then run commands/http-08-pull.sh and verify the API activated curator bytes without rewriting the descriptor.
|
|
8. Make an evidence-only commit under p11-filesystem/evidence, push, pull, and inspect the new revision commit with unchanged descriptor blob.
|
|
9. In the UI at http://${HOST}:${FRONTEND_PORT}, confirm ready workspaces are read-only and bootstrap-only slots are editable before creation.
|
|
10. Export/import only under bootstrap rules.
|
|
11. Run commands/render-1.sh and commands/render-2.sh, diff rendered/runtime-1.yaml rendered/runtime-2.yaml, then run tht config check -c on both outputs.
|
|
12. Run the negative validate scripts and a bounded secret scan outside fixture-secrets.
|
|
13. Run ./scripts/p11-manual-acceptance.sh stop, verify cleanup of both listeners, write VERDICT.md yourself, and run cleanup only when evidence is no longer needed.
|
|
`;
|
|
}
|
|
function ownershipValue(root, repositoryRoot, nonce, extras = {}) {
|
|
return {
|
|
schemaVersion: 1,
|
|
kind: "p11-manual-acceptance",
|
|
nonce,
|
|
repositoryRoot,
|
|
root,
|
|
createdAt: nowIso(),
|
|
status: "PENDING",
|
|
listeners: {
|
|
backend: { host: HOST, port: BACKEND_PORT },
|
|
frontend: { host: HOST, port: FRONTEND_PORT },
|
|
},
|
|
resources: [root, join(root, "remote.git"), join(root, "author"), join(root, "fixture-secrets")],
|
|
...extras,
|
|
};
|
|
}
|
|
export async function readManualOwnership({ repositoryRoot = defaultRepositoryRoot } = {}) {
|
|
const repo = realpathSync(repositoryRoot);
|
|
const root = fixedManualRoot(repo);
|
|
noSymlinkExisting(repo, root);
|
|
const rootEntry = await lstat(root);
|
|
const ownershipPath = join(root, "ownership.json");
|
|
const digestPath = join(root, OWNERSHIP_DIGEST);
|
|
const ownershipEntry = await lstat(ownershipPath);
|
|
const digestEntry = await lstat(digestPath);
|
|
if (!rootEntry.isDirectory() || rootEntry.isSymbolicLink() || !ownershipEntry.isFile() || ownershipEntry.isSymbolicLink() || !digestEntry.isFile() || digestEntry.isSymbolicLink()) throw new Error("manual ownership is unsafe");
|
|
const ownershipBytes = await readFile(ownershipPath, "utf8");
|
|
const recordedDigest = (await readFile(digestPath, "utf8")).trim();
|
|
if (!HEX64.test(recordedDigest) || recordedDigest !== ownershipDigest(ownershipBytes)) throw new Error("manual ownership digest mismatch");
|
|
const value = JSON.parse(ownershipBytes);
|
|
if (value?.schemaVersion !== 1 || value.kind !== "p11-manual-acceptance" || !HEX64.test(value.nonce ?? "") || value.repositoryRoot !== repo || value.root !== root) {
|
|
throw new Error("manual ownership identity mismatch");
|
|
}
|
|
return value;
|
|
}
|
|
async function ensureRootAbsent(root) {
|
|
try { await lstat(root); throw new Error("manual acceptance root already exists"); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
}
|
|
async function waitForHttp(url, timeoutMs = 15_000) {
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
await new Promise((resolvePromise, reject) => {
|
|
const request = http.get(url, (response) => { response.resume(); response.statusCode && response.statusCode < 500 ? resolvePromise() : reject(new Error("not ready")); });
|
|
request.on("error", reject);
|
|
});
|
|
return;
|
|
} catch {
|
|
await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
|
|
}
|
|
}
|
|
throw new Error(`timed out waiting for ${url}`);
|
|
}
|
|
function live(pid) { try { process.kill(pid, 0); return true; } catch { return false; } }
|
|
async function writeCommands(repo, root) {
|
|
const commands = [
|
|
["http-01-status.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspace-registry/status`, join(root, "responses", "status.json"))],
|
|
["http-02-validate-p11-filesystem.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-filesystem.json"), join(root, "requests", "validate-p11-filesystem.json"))],
|
|
["http-03-validate-p11-http.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-http.json"), join(root, "requests", "validate-p11-http.json"))],
|
|
["http-04-validate-p11-s3.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "validate-p11-s3.json"), join(root, "requests", "validate-p11-s3.json"))],
|
|
["http-05-publish-p11-filesystem.sh", publishCurl(root, "p11-filesystem", join(root, "responses", "status.json"))],
|
|
["http-06-publish-p11-http.sh", publishCurl(root, "p11-http", join(root, "responses", "publish-p11-filesystem.json"))],
|
|
["http-07-publish-p11-s3.sh", publishCurl(root, "p11-s3", join(root, "responses", "publish-p11-http.json"))],
|
|
["http-08-pull.sh", curlPostEmpty(`http://${HOST}:${BACKEND_PORT}/workspace-registry/pull`, join(root, "responses", "pull.json"))],
|
|
["http-09-read-p11-filesystem.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspaces/p11-filesystem`, join(root, "responses", "read-p11-filesystem.json"))],
|
|
["http-10-export-p11-filesystem.sh", curlGet(`http://${HOST}:${BACKEND_PORT}/workspaces/p11-filesystem/export`, join(root, "exports", "raw", "p11-filesystem.zip"))],
|
|
["http-11-negative-invalid-uri.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "negative-invalid-uri.json"), join(root, "requests", "negative-invalid-uri.json"))],
|
|
["http-12-negative-secret-field.sh", curlPost(`http://${HOST}:${BACKEND_PORT}/workspaces/validate`, join(root, "responses", "negative-secret-field.json"), join(root, "requests", "negative-secret-field.json"))],
|
|
["render-1.sh", renderCommand(repo, root, 1)],
|
|
["render-2.sh", renderCommand(repo, root, 2)],
|
|
];
|
|
for (const [name, body] of commands) {
|
|
const path = join(root, "commands", name);
|
|
await atomicWrite(path, body, 0o700);
|
|
}
|
|
}
|
|
export async function prepareManual({ repositoryRoot = defaultRepositoryRoot } = {}) {
|
|
const repo = realpathSync(repositoryRoot);
|
|
const root = fixedManualRoot(repo);
|
|
noSymlinkExisting(repo, root);
|
|
await ensureRootAbsent(root);
|
|
await mkdir(join(repo, ".artifacts", "manual-acceptance"), { recursive: true, mode: 0o700 });
|
|
await mkdir(root, { mode: 0o700 });
|
|
const executables = resolveExecutables(repo);
|
|
const nonce = randomBytes(32).toString("hex");
|
|
await writeManualOwnership(root, ownershipValue(root, repo, nonce));
|
|
for (const path of ["fixture-secrets", "requests", "responses", "commands", "rendered", "logs", "exports/raw", "exports/extracted", "installation/registry", "installation/data", "installation/runtime"]) {
|
|
await mkdir(join(root, path), { recursive: true, mode: path === "fixture-secrets" ? 0o700 : 0o755 });
|
|
}
|
|
const env = buildSafeEnvironment({ ambient: process.env, fixture: { PATH: dirname(executables.gitPath) } });
|
|
await git(executables.gitPath, ["init", "--bare", "--initial-branch=main", join(root, "remote.git")], { cwd: root, env });
|
|
await git(executables.gitPath, ["clone", join(root, "remote.git"), join(root, "author")], { cwd: root, env });
|
|
await git(executables.gitPath, ["config", "user.name", "P1 Fixture Curator"], { cwd: join(root, "author"), env });
|
|
await git(executables.gitPath, ["config", "user.email", "p1-curator@example.invalid"], { cwd: join(root, "author"), env });
|
|
const items = descriptors();
|
|
await atomicWrite(join(root, "author", "thoth-workspaces.yaml"), `${JSON.stringify(catalog(items), null, 2)}\n`, 0o644);
|
|
await mkdir(join(root, "author", "p11-filesystem", "evidence", "domain"), { recursive: true });
|
|
await atomicWrite(join(root, "author", "p11-filesystem", "evidence", "guide.md"), "# P1.1 curated Evidence\n", 0o644);
|
|
await atomicWrite(join(root, "author", "p11-filesystem", "evidence", "domain", "table.md"), "# Curated table\n", 0o644);
|
|
await git(executables.gitPath, ["add", "thoth-workspaces.yaml"], { cwd: join(root, "author"), env });
|
|
await git(executables.gitPath, ["add", "-A", "p11-filesystem/evidence"], { cwd: join(root, "author"), env });
|
|
await git(executables.gitPath, ["commit", "-m", "Bootstrap curated P1 content"], { cwd: join(root, "author"), env });
|
|
await git(executables.gitPath, ["push", "origin", "main"], { cwd: join(root, "author"), env });
|
|
const secrets = {
|
|
dwh: join(root, "fixture-secrets", "dwh-password"),
|
|
signed: join(root, "fixture-secrets", "evidence-signed-urls.json"),
|
|
access: join(root, "fixture-secrets", "evidence-access"),
|
|
secret: join(root, "fixture-secrets", "evidence-secret"),
|
|
session: join(root, "fixture-secrets", "evidence-session"),
|
|
};
|
|
await atomicWrite(secrets.dwh, "manual-dwh-secret", 0o600);
|
|
await atomicWrite(secrets.signed, JSON.stringify(["https://evidence.example.test/guide.md?token=manual"]), 0o600);
|
|
await atomicWrite(secrets.access, "manual-access", 0o600);
|
|
await atomicWrite(secrets.secret, "manual-secret", 0o600);
|
|
await atomicWrite(secrets.session, "manual-session", 0o600);
|
|
const bindings = {};
|
|
for (const workspace of items) {
|
|
const prefix = `THT_WS_${namespace(workspace.workspace.id)}`;
|
|
Object.assign(bindings, {
|
|
[`${prefix}_DWH_TRANSPORT`]: "postgres_direct",
|
|
[`${prefix}_DWH_HOST`]: "dwh.invalid",
|
|
[`${prefix}_DWH_PORT`]: "5432",
|
|
[`${prefix}_DWH_USER`]: "reader",
|
|
[`${prefix}_DWH_PASSWORD_FILE`]: secrets.dwh,
|
|
});
|
|
}
|
|
Object.assign(bindings, {
|
|
THT_WS_P11_HTTP_EVIDENCE_SIGNED_URLS_FILE: secrets.signed,
|
|
THT_WS_P11_S3_EVIDENCE_ACCESS_KEY_FILE: secrets.access,
|
|
THT_WS_P11_S3_EVIDENCE_SECRET_KEY_FILE: secrets.secret,
|
|
THT_WS_P11_S3_EVIDENCE_SESSION_TOKEN_FILE: secrets.session,
|
|
});
|
|
await atomicWrite(join(root, "installation", "bindings.env"), `${Object.entries(bindings).map(([key, value]) => `${key}=${value}`).join("\n")}\n`);
|
|
await atomicWrite(join(root, "installation", "runtime", "base.yaml"), "{}\n");
|
|
for (const [name, value] of Object.entries(requestFixtures(items))) await atomicWrite(join(root, "requests", name), `${JSON.stringify(value, null, 2)}\n`, 0o600);
|
|
await writeCommands(repo, root);
|
|
await atomicWrite(join(root, "GUIDE.md"), guide(root), 0o600);
|
|
await atomicWrite(join(root, "logs", "backend.log"), "", 0o600);
|
|
const current = await readManualOwnership({ repositoryRoot: repo });
|
|
current.status = "PENDING";
|
|
current.requestFixtures = Object.keys(requestFixtures(items));
|
|
current.commandScripts = (await readdir(join(root, "commands"))).sort();
|
|
await writeManualOwnership(root, current);
|
|
return root;
|
|
}
|
|
export async function serveManual({ repositoryRoot = defaultRepositoryRoot } = {}) {
|
|
const repo = realpathSync(repositoryRoot);
|
|
const root = fixedManualRoot(repo);
|
|
const owned = await readManualOwnership({ repositoryRoot: repo });
|
|
if (owned.status === "RUNNING") throw new Error("manual acceptance is already serving");
|
|
await access(join(repo, "backend", "dist", "server.js"));
|
|
await access(join(repo, "frontend", "dist", "index.html"));
|
|
const executables = resolveExecutables(repo);
|
|
const logHandle = await open(join(root, "logs", "backend.log"), fsConstants.O_WRONLY | fsConstants.O_APPEND);
|
|
const homeDir = join(root, "installation", "runtime", "home");
|
|
const tmpDir = join(root, "installation", "runtime", "tmp");
|
|
await mkdir(homeDir, { recursive: true, mode: 0o700 });
|
|
await mkdir(tmpDir, { recursive: true, mode: 0o700 });
|
|
const fixtureEnv = {
|
|
PATH: `${dirname(executables.gitPath)}:${dirname(executables.pythonPath)}:${dirname(executables.thtPath)}:/usr/bin:/bin`,
|
|
HOME: homeDir,
|
|
TMPDIR: tmpDir,
|
|
HOST,
|
|
PORT: String(BACKEND_PORT),
|
|
AUTH_MODE: "none",
|
|
THT_BIN: executables.thtPath,
|
|
THT_HARNESS_DIR: join(repo, "harness"),
|
|
THT_DATA_ROOT: join(root, "installation", "data"),
|
|
SETTINGS_FILE: join(root, "installation", "data", "settings.json"),
|
|
MAINTENANCE_STATE_FILE: join(root, "installation", "data", "maintenance.json"),
|
|
THT_WORKSPACE_REGISTRY_ROOT: join(root, "installation", "registry"),
|
|
THT_WORKSPACE_GIT_REMOTE: join(root, "remote.git"),
|
|
THT_WORKSPACE_GIT_BRANCH: "main",
|
|
THT_WORKSPACE_GIT_AUTHOR_NAME: "P1 API Publisher",
|
|
THT_WORKSPACE_GIT_AUTHOR_EMAIL: "p1-api@example.invalid",
|
|
THT_WORKSPACE_INSTALLATION_ID: "p11-manual-acceptance",
|
|
THT_WORKSPACE_SECRET_ROOTS: join(root, "fixture-secrets"),
|
|
THT_HOME: join(root, "installation", "runtime", "tht-home"),
|
|
PYTHONDONTWRITEBYTECODE: "1",
|
|
PYTHONNOUSERSITE: "1",
|
|
};
|
|
const bindingEnv = Object.fromEntries((await readFile(join(root, "installation", "bindings.env"), "utf8")).trim().split(/\n+/).map((line) => line.split(/=(.+)/)));
|
|
const env = buildSafeEnvironment({ ambient: process.env, fixture: { ...fixtureEnv, ...bindingEnv } });
|
|
const backend = spawn(process.execPath, [join(repo, "backend", "dist", "server.js")], { cwd: repo, env, stdio: ["ignore", logHandle.fd, logHandle.fd], detached: true });
|
|
const frontend = spawn(executables.pythonPath, ["-m", "http.server", String(FRONTEND_PORT), "--bind", HOST, "--directory", join(repo, "frontend", "dist")], { cwd: repo, env, stdio: ["ignore", "ignore", "ignore"], detached: true });
|
|
backend.unref(); frontend.unref();
|
|
await waitForHttp(`http://${HOST}:${BACKEND_PORT}/health`);
|
|
await waitForHttp(`http://${HOST}:${FRONTEND_PORT}/`);
|
|
await logHandle.close();
|
|
owned.status = "RUNNING";
|
|
owned.backend = { pid: backend.pid, port: BACKEND_PORT, command: [process.execPath, join(repo, "backend", "dist", "server.js")] };
|
|
owned.frontend = { pid: frontend.pid, port: FRONTEND_PORT, command: [executables.pythonPath, "-m", "http.server", String(FRONTEND_PORT)] };
|
|
await writeManualOwnership(root, owned);
|
|
return owned;
|
|
}
|
|
async function processCommandMatches(pid, expectedCommand) {
|
|
if (!Array.isArray(expectedCommand) || expectedCommand.length === 0) return false;
|
|
let output;
|
|
try {
|
|
const { stdout } = await execFileAsync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" });
|
|
output = stdout.trim();
|
|
} catch {
|
|
return false;
|
|
}
|
|
if (output.length === 0) return false;
|
|
// The recorded command is the argv array used to spawn the process; verify every token appears
|
|
// in the current command line in order, so a reused PID with unrelated command is refused.
|
|
let cursor = 0;
|
|
for (const token of expectedCommand) {
|
|
if (token.length === 0) continue;
|
|
const index = output.indexOf(token, cursor);
|
|
if (index < 0) return false;
|
|
cursor = index + token.length;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export async function stopManual({ repositoryRoot = defaultRepositoryRoot } = {}) {
|
|
const repo = realpathSync(repositoryRoot);
|
|
const root = fixedManualRoot(repo);
|
|
const owned = await readManualOwnership({ repositoryRoot: repo });
|
|
if (owned.status !== "RUNNING" || !owned.backend?.pid || !owned.frontend?.pid) throw new Error("manual acceptance is not running");
|
|
for (const pid of [owned.backend.pid, owned.frontend.pid]) {
|
|
try { process.kill(-pid, "SIGTERM"); } catch (error) { if (error?.code !== "ESRCH") throw error; }
|
|
}
|
|
const deadline = Date.now() + 15_000;
|
|
while (Date.now() < deadline && (live(owned.backend.pid) || live(owned.frontend.pid))) await new Promise((resolvePromise) => setTimeout(resolvePromise, 250));
|
|
owned.status = "STOPPED";
|
|
await writeManualOwnership(root, owned);
|
|
return owned;
|
|
}
|
|
|
|
export async function cleanupManual({ repositoryRoot = defaultRepositoryRoot } = {}) {
|
|
const repo = realpathSync(repositoryRoot);
|
|
const root = fixedManualRoot(repo);
|
|
const owned = await readManualOwnership({ repositoryRoot: repo });
|
|
if (owned.status === "RUNNING") throw new Error("manual acceptance is still live");
|
|
if (owned.backend?.pid && live(owned.backend.pid)) throw new Error("backend process is still live");
|
|
if (owned.frontend?.pid && live(owned.frontend.pid)) throw new Error("frontend process is still live");
|
|
const parent = dirname(root);
|
|
const tombstone = join(parent, `.deleting-p11-${owned.nonce.slice(0, 16)}`);
|
|
await rename(root, tombstone);
|
|
await rm(tombstone, { recursive: true, force: false });
|
|
}
|
|
export async function main(argv = process.argv.slice(2)) {
|
|
if (argv.length !== 1 || !["prepare", "serve", "stop", "cleanup"].includes(argv[0])) throw new Error("usage: p11-manual-acceptance.mjs prepare|serve|stop|cleanup");
|
|
switch (argv[0]) {
|
|
case "prepare": await prepareManual(); break;
|
|
case "serve": await serveManual(); break;
|
|
case "stop": await stopManual(); break;
|
|
case "cleanup": await cleanupManual(); break;
|
|
}
|
|
}
|
|
if (process.argv[1] && realpathSync(process.argv[1]) === modulePath) {
|
|
try { await main(); } catch (error) { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; }
|
|
}
|