Source commit: 20b59dc46b72103c2f8a516c692b5cc3d54fab19 Public tree identity: sha256:aaa5ac7b58b2a0d23c6b11e5f76324bf3839ca1f62653a83deb736932adcfbd9
1422 lines
47 KiB
JavaScript
Executable file
1422 lines
47 KiB
JavaScript
Executable file
#!/usr/bin/env node
|
|
|
|
const assert = require("assert");
|
|
const cp = require("child_process");
|
|
const crypto = require("crypto");
|
|
const fs = require("fs");
|
|
const http = require("http");
|
|
const https = require("https");
|
|
const net = require("net");
|
|
const os = require("os");
|
|
const path = require("path");
|
|
const { configurePodmanTestEnvironment } = require("./podman-test-env");
|
|
const { coordinatorWireRequest } = require("./coordinator-wire");
|
|
const { nodeIdentity } = require("./node-signing");
|
|
const {
|
|
assertPreFinalLedger,
|
|
readPhase3Ledger,
|
|
} = require("./phase3-ledger");
|
|
|
|
const repo = path.resolve(__dirname, "..");
|
|
configurePodmanTestEnvironment(repo);
|
|
if (
|
|
!process.env.DISASMER_PODMAN_NIX_SHELL &&
|
|
cp.spawnSync("podman", ["--version"], { stdio: "ignore" }).status !== 0 &&
|
|
cp.spawnSync("nix", ["--version"], { stdio: "ignore" }).status === 0
|
|
) {
|
|
cp.execFileSync(
|
|
"nix",
|
|
["shell", "nixpkgs#podman", "--command", "node", __filename],
|
|
{
|
|
cwd: repo,
|
|
env: { ...process.env, DISASMER_PODMAN_NIX_SHELL: "1" },
|
|
stdio: "inherit",
|
|
}
|
|
);
|
|
process.exit(0);
|
|
}
|
|
const releaseRoot = path.resolve(
|
|
process.env.DISASMER_PUBLIC_RELEASE_DIR ||
|
|
path.join(repo, "target/public-release-dryrun")
|
|
);
|
|
const acceptanceRoot = path.join(repo, "target/acceptance");
|
|
const manifestPath =
|
|
process.env.DISASMER_PUBLIC_RELEASE_MANIFEST ||
|
|
path.join(releaseRoot, "public-release-manifest.json");
|
|
const forgejoReportPath =
|
|
process.env.DISASMER_PUBLIC_RELEASE_FORGEJO_REPORT ||
|
|
path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json");
|
|
const reportPath =
|
|
process.env.DISASMER_PUBLIC_RELEASE_E2E_REPORT ||
|
|
path.join(acceptanceRoot, "public-release-dryrun-e2e.json");
|
|
const serviceEndpoint = "https://disasmer.michelpaulissen.com";
|
|
const serviceHost = "disasmer.michelpaulissen.com";
|
|
const serviceAddr =
|
|
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR || `${serviceHost}:443`;
|
|
const forgejoHost = "git.michelpaulissen.com";
|
|
const enabled = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1";
|
|
const browserOpenCommand =
|
|
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND;
|
|
const dnsPublicationState =
|
|
process.env.DISASMER_DNS_PUBLICATION_STATE || "published";
|
|
const resolverOverride =
|
|
process.env.DISASMER_RESOLVER_OVERRIDE || "none-required-public-dns";
|
|
|
|
function requireEnabled() {
|
|
if (!enabled) {
|
|
throw new Error(
|
|
"DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1 is required because this runs the real public dry-run e2e"
|
|
);
|
|
}
|
|
if (!browserOpenCommand) {
|
|
throw new Error(
|
|
"DISASMER_PUBLIC_RELEASE_DRYRUN_BROWSER_OPEN_COMMAND is required to complete the server-owned hosted browser login"
|
|
);
|
|
}
|
|
}
|
|
|
|
function readJson(file) {
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
}
|
|
|
|
function ensureDir(dir) {
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const commandLine = [command, ...args].join(" ");
|
|
commands.push(commandLine);
|
|
const execOptions = {
|
|
cwd: repo,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
timeout: 15 * 60 * 1000,
|
|
...options,
|
|
};
|
|
execOptions.env = commandEnv(command, options.env);
|
|
return cp.execFileSync(command, args, execOptions);
|
|
}
|
|
|
|
function runJson(command, args, options = {}) {
|
|
const output = run(command, args, options);
|
|
try {
|
|
return JSON.parse(output);
|
|
} catch (_) {
|
|
const line = output
|
|
.trim()
|
|
.split("\n")
|
|
.filter(Boolean)
|
|
.at(-1);
|
|
return JSON.parse(line);
|
|
}
|
|
}
|
|
|
|
function commandOutput(command, args, options = {}) {
|
|
try {
|
|
const execOptions = {
|
|
cwd: repo,
|
|
encoding: "utf8",
|
|
stdio: ["ignore", "pipe", "ignore"],
|
|
...options,
|
|
};
|
|
execOptions.env = commandEnv(command, options.env);
|
|
return cp.execFileSync(command, args, execOptions).trim();
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function nonInteractiveGitEnv(extra = {}) {
|
|
return {
|
|
...process.env,
|
|
GIT_TERMINAL_PROMPT: "0",
|
|
GIT_ASKPASS: process.env.GIT_ASKPASS || "/bin/false",
|
|
SSH_ASKPASS: process.env.SSH_ASKPASS || "/bin/false",
|
|
GIT_SSH_COMMAND:
|
|
process.env.GIT_SSH_COMMAND ||
|
|
"ssh -o BatchMode=yes -o NumberOfPasswordPrompts=0",
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function commandEnv(command, env) {
|
|
if (command !== "git") {
|
|
return env;
|
|
}
|
|
return nonInteractiveGitEnv(env);
|
|
}
|
|
|
|
function expectedSourceCommit() {
|
|
return (
|
|
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
|
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
|
"unknown"
|
|
);
|
|
}
|
|
|
|
function publicTreeAlreadyPushed(manifest) {
|
|
return (
|
|
(manifest.public_tree_publish && manifest.public_tree_publish.pushed === true) ||
|
|
process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1"
|
|
);
|
|
}
|
|
|
|
function sha256File(file) {
|
|
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
}
|
|
|
|
function parseAddr(value) {
|
|
const match = /^([^:]+):(\d+)$/.exec(value);
|
|
if (!match) {
|
|
throw new Error(`service address must be host:port, got ${value}`);
|
|
}
|
|
return { host: match[1], port: Number(match[2]) };
|
|
}
|
|
|
|
function send(addr, message) {
|
|
return new Promise((resolve, reject) => {
|
|
const body = Buffer.from(JSON.stringify(message));
|
|
const request = https.request({
|
|
hostname: addr.host,
|
|
port: addr.port,
|
|
servername: serviceHost,
|
|
path: "/api/v1/control",
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Content-Length": body.length,
|
|
},
|
|
timeout: 30000,
|
|
}, (response) => {
|
|
const chunks = [];
|
|
response.on("data", (chunk) => chunks.push(chunk));
|
|
response.on("end", () => {
|
|
const responseBody = Buffer.concat(chunks).toString("utf8");
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
reject(new Error(`hosted control HTTP ${response.statusCode}: ${responseBody}`));
|
|
return;
|
|
}
|
|
try {
|
|
resolve(JSON.parse(responseBody));
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
});
|
|
request.on("timeout", () => request.destroy(new Error(`timed out waiting for ${message.type}`)));
|
|
request.on("error", reject);
|
|
request.end(body);
|
|
});
|
|
}
|
|
|
|
function sendCore(addr, message) {
|
|
return new Promise((resolve, reject) => {
|
|
const socket = net.connect(addr.port, addr.host, () => {
|
|
socket.write(
|
|
`${JSON.stringify(coordinatorWireRequest(message, "public-e2e-core"))}\n`
|
|
);
|
|
});
|
|
let buffer = "";
|
|
const timer = setTimeout(() => {
|
|
socket.destroy();
|
|
reject(new Error(`timed out waiting for standalone Core ${message.type}`));
|
|
}, 30000);
|
|
socket.on("data", (chunk) => {
|
|
buffer += chunk.toString();
|
|
const newline = buffer.indexOf("\n");
|
|
if (newline < 0) return;
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
try {
|
|
resolve(JSON.parse(buffer.slice(0, newline)));
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
socket.on("error", (error) => {
|
|
clearTimeout(timer);
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
let hostedClientRequestId = 0;
|
|
function authenticatedHostedClientRequest(sessionSecret, message) {
|
|
hostedClientRequestId += 1;
|
|
return {
|
|
type: "coordinator_request",
|
|
protocol_version: 1,
|
|
request_id: `public-e2e-client-${hostedClientRequestId}`,
|
|
operation: "authenticated",
|
|
authentication: {
|
|
kind: "cli_session",
|
|
session: true,
|
|
request_operation: message.type,
|
|
},
|
|
payload: {
|
|
type: "authenticated",
|
|
session_secret: sessionSecret,
|
|
request: message,
|
|
},
|
|
};
|
|
}
|
|
|
|
function waitForJsonLine(child, label = "process", timeoutMs = 240000) {
|
|
return new Promise((resolve, reject) => {
|
|
let buffer = "";
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
reject(new Error(`timed out waiting for JSON line from ${label}`));
|
|
}, timeoutMs);
|
|
function cleanup() {
|
|
clearTimeout(timer);
|
|
child.stdout.off("data", onData);
|
|
child.off("exit", onExit);
|
|
}
|
|
function onData(chunk) {
|
|
buffer += chunk.toString();
|
|
const newline = buffer.indexOf("\n");
|
|
if (newline < 0) return;
|
|
try {
|
|
cleanup();
|
|
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
|
} catch (error) {
|
|
cleanup();
|
|
reject(error);
|
|
}
|
|
}
|
|
function onExit(code) {
|
|
cleanup();
|
|
reject(new Error(`${label} exited before JSON line with code ${code}`));
|
|
}
|
|
child.stdout.on("data", onData);
|
|
child.once("exit", onExit);
|
|
});
|
|
}
|
|
|
|
function stopChild(child) {
|
|
if (!child || child.exitCode !== null) {
|
|
return Promise.resolve();
|
|
}
|
|
return new Promise((resolve) => {
|
|
const timer = setTimeout(() => {
|
|
child.kill("SIGKILL");
|
|
resolve();
|
|
}, 5000);
|
|
child.once("exit", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
child.kill("SIGTERM");
|
|
});
|
|
}
|
|
|
|
function sleep(ms) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
class DapClient {
|
|
constructor(command, options = {}) {
|
|
this.child = cp.spawn(command, [], {
|
|
cwd: options.cwd,
|
|
detached: process.platform !== "win32",
|
|
});
|
|
this.seq = 1;
|
|
this.buffer = Buffer.alloc(0);
|
|
this.messages = [];
|
|
this.waiters = [];
|
|
this.stderr = "";
|
|
|
|
this.child.stdout.on("data", (chunk) => {
|
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
|
this.parse();
|
|
});
|
|
this.child.stderr.on("data", (chunk) => {
|
|
this.stderr += chunk.toString();
|
|
});
|
|
this.child.on("exit", () => this.flushWaiters());
|
|
}
|
|
|
|
send(command, args = {}) {
|
|
const seq = this.seq++;
|
|
const message = { seq, type: "request", command, arguments: args };
|
|
const payload = Buffer.from(JSON.stringify(message));
|
|
this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`);
|
|
this.child.stdin.write(payload);
|
|
return seq;
|
|
}
|
|
|
|
async response(seq, command) {
|
|
const message = await this.waitFor(
|
|
(item) =>
|
|
item.type === "response" &&
|
|
item.request_seq === seq &&
|
|
item.command === command
|
|
);
|
|
if (!message.success) {
|
|
throw new Error(`DAP ${command} failed: ${message.message || JSON.stringify(message)}`);
|
|
}
|
|
return message;
|
|
}
|
|
|
|
terminate() {
|
|
if (this.child.exitCode !== null) return;
|
|
if (process.platform === "win32") {
|
|
this.child.kill("SIGKILL");
|
|
return;
|
|
}
|
|
try {
|
|
process.kill(-this.child.pid, "SIGKILL");
|
|
} catch (_) {
|
|
this.child.kill("SIGKILL");
|
|
}
|
|
}
|
|
|
|
waitFor(predicate, timeoutMs = 240000) {
|
|
const existing = this.messages.find(predicate);
|
|
if (existing) return Promise.resolve(existing);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const timer = setTimeout(() => {
|
|
this.terminate();
|
|
reject(new Error(`timed out waiting for DAP message\n${this.stderr}`));
|
|
}, timeoutMs);
|
|
this.waiters.push({ predicate, resolve, timer });
|
|
});
|
|
}
|
|
|
|
parse() {
|
|
while (true) {
|
|
const headerEnd = this.buffer.indexOf("\r\n\r\n");
|
|
if (headerEnd < 0) return;
|
|
const header = this.buffer.slice(0, headerEnd).toString();
|
|
const match = header.match(/Content-Length: (\d+)/i);
|
|
if (!match) throw new Error(`bad DAP header: ${header}`);
|
|
const length = Number(match[1]);
|
|
const start = headerEnd + 4;
|
|
const end = start + length;
|
|
if (this.buffer.length < end) return;
|
|
const payload = this.buffer.slice(start, end).toString();
|
|
this.buffer = this.buffer.slice(end);
|
|
this.messages.push(JSON.parse(payload));
|
|
this.flushWaiters();
|
|
}
|
|
}
|
|
|
|
flushWaiters() {
|
|
for (const waiter of [...this.waiters]) {
|
|
const message = this.messages.find(waiter.predicate);
|
|
if (!message) continue;
|
|
clearTimeout(waiter.timer);
|
|
this.waiters.splice(this.waiters.indexOf(waiter), 1);
|
|
waiter.resolve(message);
|
|
}
|
|
}
|
|
|
|
async close() {
|
|
if (this.child.exitCode !== null) return;
|
|
const seq = this.send("disconnect");
|
|
await this.response(seq, "disconnect");
|
|
this.child.stdin.end();
|
|
}
|
|
}
|
|
|
|
function download(url, dest, redirects = 0) {
|
|
return new Promise((resolve, reject) => {
|
|
const client = url.startsWith("http://") ? http : https;
|
|
const request = client.get(url, (response) => {
|
|
if (
|
|
[301, 302, 303, 307, 308].includes(response.statusCode) &&
|
|
response.headers.location &&
|
|
redirects < 5
|
|
) {
|
|
response.resume();
|
|
download(new URL(response.headers.location, url).toString(), dest, redirects + 1)
|
|
.then(resolve)
|
|
.catch(reject);
|
|
return;
|
|
}
|
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
response.resume();
|
|
reject(new Error(`download ${url} failed with ${response.statusCode}`));
|
|
return;
|
|
}
|
|
ensureDir(path.dirname(dest));
|
|
const file = fs.createWriteStream(dest);
|
|
response.pipe(file);
|
|
file.on("finish", () => file.close(resolve));
|
|
file.on("error", reject);
|
|
});
|
|
request.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function releaseAssetDownloads(forgejoReport) {
|
|
const assets = [
|
|
...(forgejoReport.uploaded_assets || []),
|
|
...(forgejoReport.reused_assets || []),
|
|
];
|
|
const byName = new Map();
|
|
for (const asset of assets) {
|
|
if (asset.name && asset.browser_download_url) {
|
|
byName.set(asset.name, asset.browser_download_url);
|
|
}
|
|
}
|
|
return byName;
|
|
}
|
|
|
|
async function downloadReleaseAssets(manifest, forgejoReport, assetsDir) {
|
|
const downloads = releaseAssetDownloads(forgejoReport);
|
|
const downloaded = [];
|
|
for (const asset of manifest.assets) {
|
|
const dest = path.join(assetsDir, asset.name);
|
|
const url = downloads.get(asset.name);
|
|
if (url) {
|
|
await download(url, dest);
|
|
} else if (process.env.DISASMER_PUBLIC_RELEASE_USE_LOCAL_ASSETS === "1") {
|
|
fs.copyFileSync(asset.file, dest);
|
|
} else {
|
|
throw new Error(`Forgejo Release report has no download URL for ${asset.name}`);
|
|
}
|
|
downloaded.push(dest);
|
|
}
|
|
return downloaded;
|
|
}
|
|
|
|
function verifyChecksums(assetsDir) {
|
|
const sumsPath = path.join(assetsDir, "SHA256SUMS");
|
|
assert(fs.existsSync(sumsPath), "SHA256SUMS must be downloaded from the release");
|
|
for (const line of fs.readFileSync(sumsPath, "utf8").split("\n")) {
|
|
if (!line.trim()) continue;
|
|
const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line.trim());
|
|
assert(match, `invalid SHA256SUMS line: ${line}`);
|
|
const [, expected, name] = match;
|
|
const file = path.join(assetsDir, name);
|
|
assert(fs.existsSync(file), `checksum references missing asset ${name}`);
|
|
assert.strictEqual(sha256File(file), expected, `checksum mismatch for ${name}`);
|
|
}
|
|
}
|
|
|
|
function binaryArchive(manifest) {
|
|
const platform = `${os.platform()}-${os.arch()}`;
|
|
const asset = manifest.assets.find(
|
|
(candidate) =>
|
|
candidate.name.startsWith("disasmer-public-binaries-") &&
|
|
candidate.name.includes(platform)
|
|
);
|
|
if (!asset) {
|
|
throw new Error(`release manifest has no binary archive for ${platform}`);
|
|
}
|
|
return asset.name;
|
|
}
|
|
|
|
function walkFiles(root, relative = "") {
|
|
const dir = path.join(root, relative);
|
|
const entries = fs
|
|
.readdirSync(dir, { withFileTypes: true })
|
|
.filter((entry) => relative || entry.name !== ".git")
|
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
const files = [];
|
|
for (const entry of entries) {
|
|
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
if (entry.isDirectory()) {
|
|
files.push(...walkFiles(root, childRelative));
|
|
} else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
files.push(childRelative);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function hashTree(root) {
|
|
const hash = crypto.createHash("sha256");
|
|
for (const file of walkFiles(root)) {
|
|
const absolute = path.join(root, file);
|
|
const stat = fs.lstatSync(absolute);
|
|
hash.update(file.replaceAll(path.sep, "/"));
|
|
hash.update("\0");
|
|
hash.update(String(stat.mode & 0o777));
|
|
hash.update("\0");
|
|
if (stat.isSymbolicLink()) {
|
|
hash.update("symlink");
|
|
hash.update("\0");
|
|
hash.update(fs.readlinkSync(absolute));
|
|
} else {
|
|
hash.update(fs.readFileSync(absolute));
|
|
}
|
|
hash.update("\0");
|
|
}
|
|
return `sha256:${hash.digest("hex")}`;
|
|
}
|
|
|
|
function assertFilteredPublicCheckout(checkout, manifest) {
|
|
for (const forbidden of ["private", "experiments", "target"]) {
|
|
assert(!fs.existsSync(path.join(checkout, forbidden)), `${forbidden}/ leaked into public repo`);
|
|
}
|
|
for (const file of walkFiles(checkout)) {
|
|
assert(
|
|
!file.split(path.sep).includes(".disasmer"),
|
|
`generated .disasmer view state leaked into public repo: ${file}`
|
|
);
|
|
}
|
|
for (const entry of fs.readdirSync(checkout, { withFileTypes: true })) {
|
|
if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".md") {
|
|
assert(
|
|
["README.md", "SECURITY.md"].includes(entry.name),
|
|
`internal root Markdown file leaked into public repo: ${entry.name}`
|
|
);
|
|
}
|
|
}
|
|
const readmePath = path.join(checkout, "README.md");
|
|
assert(fs.existsSync(readmePath), "public checkout must include product README.md");
|
|
for (const required of ["SECURITY.md", "LICENSE-APACHE", "LICENSE-MIT"]) {
|
|
assert(
|
|
fs.existsSync(path.join(checkout, required)),
|
|
`public checkout must include ${required}`
|
|
);
|
|
}
|
|
const readme = fs.readFileSync(readmePath, "utf8");
|
|
assert.match(readme, /# Disasmer/);
|
|
assert.match(readme, /disasmer (login|project|run|node)/);
|
|
const includeForgejoWorkflows =
|
|
manifest.public_export && manifest.public_export.include_forgejo_workflows;
|
|
if (!includeForgejoWorkflows) {
|
|
assert(!fs.existsSync(path.join(checkout, ".forgejo")), ".forgejo/ leaked into public repo");
|
|
}
|
|
const provenancePath = path.join(checkout, "DISASMER_PUBLIC_TREE.json");
|
|
assert(fs.existsSync(provenancePath), "public checkout must include DISASMER_PUBLIC_TREE.json");
|
|
const provenance = readJson(provenancePath);
|
|
assert.strictEqual(provenance.source_commit, manifest.source_commit);
|
|
assert.strictEqual(provenance.release_name, manifest.release_name);
|
|
for (const expected of [
|
|
"private/**",
|
|
"experiments/**",
|
|
".git",
|
|
"target",
|
|
"root/*.md except README.md and SECURITY.md",
|
|
"**/.disasmer/**",
|
|
]) {
|
|
assert(provenance.filtered_out.includes(expected), `public provenance missing ${expected}`);
|
|
}
|
|
if (!includeForgejoWorkflows) {
|
|
assert(
|
|
provenance.filtered_out.includes(".forgejo/**"),
|
|
"public provenance must record .forgejo filtering"
|
|
);
|
|
}
|
|
assert.strictEqual(hashTree(checkout), manifest.public_tree_identity);
|
|
}
|
|
|
|
function executable(root, name) {
|
|
const suffix = process.platform === "win32" ? ".exe" : "";
|
|
const file = path.join(root, "bin", `${name}${suffix}`);
|
|
assert(fs.existsSync(file), `missing release binary ${file}`);
|
|
return file;
|
|
}
|
|
|
|
const commands = [];
|
|
|
|
async function validateStandaloneCoreCoordinator(
|
|
disasmerCoordinator,
|
|
disasmerNode,
|
|
checkout,
|
|
bundleArtifact
|
|
) {
|
|
let coordinator;
|
|
let worker;
|
|
let workerStderr = "";
|
|
const suffix = String(Date.now());
|
|
const tenant = `public-coordinator-${suffix}`;
|
|
const project = `self-hosted-${suffix}`;
|
|
const node = `public-node-${suffix}`;
|
|
const processId = `vp-public-coordinator-${suffix}`;
|
|
const task = "prepare_source";
|
|
const artifactPath = "/vfs/artifacts/public-coordinator-output.txt";
|
|
const sessionSecret = `public-core-session-${suffix}`;
|
|
const nodeKey = nodeIdentity("public-release-core-node", node);
|
|
const bundleDirectory = path.resolve(checkout, bundleArtifact.directory);
|
|
const manifest = readJson(path.join(bundleDirectory, "manifest.json"));
|
|
const taskDescriptors = readJson(
|
|
path.join(bundleDirectory, manifest.task_descriptors)
|
|
);
|
|
const entrypoint = taskDescriptors.find((candidate) => candidate.name === task);
|
|
assert(entrypoint, "public bundle omitted the source task");
|
|
const taskDefinition = entrypoint.stable_id;
|
|
const taskInstance = `ti:${processId}:task`;
|
|
const wasmModuleBase64 = fs
|
|
.readFileSync(path.join(bundleDirectory, "module.wasm"))
|
|
.toString("base64");
|
|
|
|
try {
|
|
const coordinatorArgs = ["--listen", "127.0.0.1:0"];
|
|
commands.push([disasmerCoordinator, ...coordinatorArgs].join(" "));
|
|
coordinator = cp.spawn(disasmerCoordinator, coordinatorArgs, {
|
|
cwd: checkout,
|
|
env: {
|
|
...process.env,
|
|
DISASMER_SELF_HOSTED_SESSION_SECRET: sessionSecret,
|
|
DISASMER_SELF_HOSTED_TENANT: tenant,
|
|
DISASMER_SELF_HOSTED_PROJECT: project,
|
|
DISASMER_SELF_HOSTED_USER: "developer",
|
|
},
|
|
});
|
|
const ready = await waitForJsonLine(coordinator, "standalone Core coordinator");
|
|
const addr = parseAddr(ready.listen);
|
|
assert.strictEqual(ready.client_authority, "strict");
|
|
assert.strictEqual(ready.self_hosted_session_bootstrapped, true);
|
|
assert.strictEqual((await sendCore(addr, { type: "ping" })).type, "pong");
|
|
|
|
const forged = await sendCore(addr, {
|
|
type: "create_project",
|
|
tenant,
|
|
actor_user: "developer",
|
|
project,
|
|
});
|
|
assert.strictEqual(forged.type, "error");
|
|
|
|
const wrongSession = await sendCore(
|
|
addr,
|
|
authenticatedHostedClientRequest("wrong-session", {
|
|
type: "list_projects",
|
|
})
|
|
);
|
|
assert.strictEqual(wrongSession.type, "error");
|
|
|
|
const created = await sendCore(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "create_project",
|
|
project,
|
|
name: "Public Coordinator Dry Run",
|
|
})
|
|
);
|
|
assert(
|
|
["project", "project_created"].includes(created.type),
|
|
`unexpected standalone Core coordinator project response: ${created.type}`
|
|
);
|
|
|
|
const grant = await sendCore(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "create_node_enrollment_grant",
|
|
ttl_seconds: 900,
|
|
})
|
|
);
|
|
assert.strictEqual(grant.type, "node_enrollment_grant_created");
|
|
|
|
const workerArgs = [
|
|
"--coordinator",
|
|
ready.listen,
|
|
"--tenant",
|
|
tenant,
|
|
"--project-id",
|
|
project,
|
|
"--node",
|
|
node,
|
|
"--project-root",
|
|
checkout,
|
|
"--public-key",
|
|
nodeKey.publicKey,
|
|
"--enrollment-grant",
|
|
grant.grant,
|
|
"--worker",
|
|
"--assignment-poll-ms",
|
|
"50",
|
|
"--emit-ready",
|
|
];
|
|
commands.push([disasmerNode, ...workerArgs].join(" "));
|
|
worker = cp.spawn(disasmerNode, workerArgs, {
|
|
cwd: checkout,
|
|
env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: nodeKey.privateKey },
|
|
});
|
|
worker.stderr.on("data", (chunk) => {
|
|
workerStderr += chunk.toString();
|
|
});
|
|
const workerReady = await waitForJsonLine(worker, "standalone Core coordinator worker");
|
|
assert.strictEqual(workerReady.node_status, "ready");
|
|
assert.strictEqual(workerReady.mode, "worker");
|
|
assert.strictEqual(workerReady.node, node);
|
|
|
|
const started = await sendCore(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "start_process",
|
|
process: processId,
|
|
})
|
|
);
|
|
assert.strictEqual(started.type, "process_started");
|
|
|
|
const launch = await sendCore(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "launch_task",
|
|
task_spec: {
|
|
tenant,
|
|
project,
|
|
process: processId,
|
|
task_definition: taskDefinition,
|
|
task_instance: taskInstance,
|
|
dispatch: {
|
|
kind: "coordinator_node_wasm",
|
|
export: entrypoint.export,
|
|
abi: "task_v1",
|
|
},
|
|
environment_id: null,
|
|
environment: null,
|
|
environment_digest: null,
|
|
required_capabilities: entrypoint.required_capabilities,
|
|
dependency_cache: null,
|
|
source_snapshot: null,
|
|
required_artifacts: [],
|
|
args: [],
|
|
vfs_epoch: started.epoch,
|
|
bundle_digest: manifest.bundle_digest,
|
|
},
|
|
wait_for_node: false,
|
|
artifact_path: artifactPath,
|
|
wasm_module_base64: wasmModuleBase64,
|
|
})
|
|
);
|
|
assert.strictEqual(launch.type, "task_launched");
|
|
assert.strictEqual(launch.placement.node, node);
|
|
assert.strictEqual(launch.assignment.node, node);
|
|
assert.strictEqual(launch.assignment.process, processId);
|
|
|
|
const nodeRun = await waitForJsonLine(worker, "standalone Core coordinator worker completion");
|
|
assert.strictEqual(nodeRun.node_status, "completed");
|
|
assert.strictEqual(nodeRun.registration_response.type, "node_enrollment_exchanged");
|
|
assert.strictEqual(nodeRun.task_assignment_response.node, node);
|
|
assert.strictEqual(nodeRun.task_assignment_response.process, processId);
|
|
assert.strictEqual(nodeRun.task_assignment_response.task, taskInstance);
|
|
assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded");
|
|
|
|
const events = await sendCore(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "list_task_events",
|
|
process: processId,
|
|
})
|
|
);
|
|
assert.strictEqual(events.type, "task_events");
|
|
assert(events.events.length >= 3, "standalone Core did not record the real task tree");
|
|
assert(events.events.some((event) => event.node === node));
|
|
assert(events.events.some((event) => event.task === "compile_linux"));
|
|
|
|
return {
|
|
coordinator_implementation: "standalone-core-coordinator",
|
|
client_authority: ready.client_authority,
|
|
authenticated_session: true,
|
|
forged_body_authority_denied: forged.type,
|
|
wrong_session_denied: wrongSession.type,
|
|
coordinator_ready: Boolean(ready.listen),
|
|
project_response: created.type,
|
|
process_response: started.type,
|
|
launch_task_response: launch.type,
|
|
assignment_response: "task_assignment",
|
|
worker_status: nodeRun.node_status,
|
|
task_events: events.events.length,
|
|
node,
|
|
process: processId,
|
|
};
|
|
} finally {
|
|
if (workerStderr.trim()) {
|
|
console.error(workerStderr);
|
|
}
|
|
await stopChild(worker);
|
|
await stopChild(coordinator);
|
|
}
|
|
}
|
|
|
|
async function variables(client, variablesReference) {
|
|
const request = client.send("variables", { variablesReference });
|
|
return (await client.response(request, "variables")).body.variables;
|
|
}
|
|
|
|
function scopeReference(scopes, name) {
|
|
const scope = scopes.find((candidate) => candidate.name === name);
|
|
assert(scope, `missing DAP scope ${name}`);
|
|
return scope.variablesReference;
|
|
}
|
|
|
|
async function validateReleasedLiveDap(
|
|
disasmerDap,
|
|
checkout,
|
|
scope,
|
|
processId
|
|
) {
|
|
const projectRoot = path.join(checkout, "examples/launch-build-demo");
|
|
const sourcePath = path.join(projectRoot, "src/build.rs");
|
|
const sourceLines = fs.readFileSync(sourcePath, "utf8").split(/\r?\n/);
|
|
const sourceLine = (needle) => {
|
|
const index = sourceLines.findIndex((line) => line.includes(needle));
|
|
assert(index >= 0, `released flagship source must contain ${needle}`);
|
|
return index + 1;
|
|
};
|
|
const preTaskMainLine = sourceLine("pub source: SourceSnapshot");
|
|
const compileLinuxLine = sourceLine("pub fn compile_linux()");
|
|
|
|
const client = new DapClient(disasmerDap, { cwd: checkout });
|
|
try {
|
|
const initialize = client.send("initialize", {
|
|
adapterID: "disasmer",
|
|
linesStartAt1: true,
|
|
columnsStartAt1: true,
|
|
});
|
|
await client.response(initialize, "initialize");
|
|
|
|
const launch = client.send("attach", {
|
|
entry: "build",
|
|
project: projectRoot,
|
|
runtimeBackend: "live-services",
|
|
coordinatorEndpoint: serviceEndpoint,
|
|
processId,
|
|
});
|
|
await client.response(launch, "attach");
|
|
await client.waitFor(
|
|
(message) => message.type === "event" && message.event === "initialized"
|
|
);
|
|
|
|
const breakpoints = client.send("setBreakpoints", {
|
|
source: { path: sourcePath },
|
|
breakpoints: [{ line: preTaskMainLine }, { line: compileLinuxLine }],
|
|
});
|
|
const breakpointResponse = await client.response(breakpoints, "setBreakpoints");
|
|
assert.deepStrictEqual(
|
|
breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.verified),
|
|
[true, true]
|
|
);
|
|
|
|
const configurationDone = client.send("configurationDone");
|
|
await client.response(configurationDone, "configurationDone");
|
|
const stopped = await client.waitFor(
|
|
(message) => message.type === "event" && message.event === "stopped"
|
|
);
|
|
assert.strictEqual(stopped.body.reason, "breakpoint");
|
|
assert.strictEqual(stopped.body.threadId, 1);
|
|
assert.strictEqual(stopped.body.allThreadsStopped, true);
|
|
|
|
const stackRequest = client.send("stackTrace", {
|
|
threadId: 1,
|
|
startFrame: 0,
|
|
levels: 1,
|
|
});
|
|
const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames;
|
|
assert.strictEqual(stack[0].line, preTaskMainLine);
|
|
assert.strictEqual(stack[0].source.path, sourcePath);
|
|
|
|
const scopesRequest = client.send("scopes", { frameId: stack[0].id });
|
|
const scopes = (await client.response(scopesRequest, "scopes")).body.scopes;
|
|
const runtime = await variables(client, scopeReference(scopes, "Disasmer Runtime"));
|
|
const output = await variables(client, scopeReference(scopes, "Recent Output"));
|
|
assert(
|
|
runtime.some(
|
|
(variable) => variable.name === "runtime_backend" && variable.value === "LiveServices"
|
|
)
|
|
);
|
|
assert(
|
|
runtime.some(
|
|
(variable) => variable.name === "coordinator_task_events" && variable.value === 1
|
|
)
|
|
);
|
|
assert(
|
|
runtime.some(
|
|
(variable) =>
|
|
variable.name === "command_status" &&
|
|
String(variable.value).includes("attached to existing virtual process")
|
|
)
|
|
);
|
|
assert(
|
|
output.some((variable) =>
|
|
String(variable.value).includes("coordinator returned 1 task event")
|
|
)
|
|
);
|
|
|
|
const continueRequest = client.send("continue", { threadId: 1 });
|
|
await client.response(continueRequest, "continue");
|
|
await client.waitFor(
|
|
(message) => message.type === "event" && message.event === "continued"
|
|
);
|
|
const taskStopped = await client.waitFor(
|
|
(message) =>
|
|
message.type === "event" &&
|
|
message.event === "stopped" &&
|
|
message.body.reason === "breakpoint" &&
|
|
message.body.threadId === 2
|
|
);
|
|
assert.strictEqual(taskStopped.body.allThreadsStopped, true);
|
|
|
|
const taskStackRequest = client.send("stackTrace", {
|
|
threadId: 2,
|
|
startFrame: 0,
|
|
levels: 1,
|
|
});
|
|
const taskStack = (await client.response(taskStackRequest, "stackTrace")).body.stackFrames;
|
|
assert.strictEqual(taskStack[0].line, compileLinuxLine);
|
|
|
|
const taskScopesRequest = client.send("scopes", { frameId: taskStack[0].id });
|
|
const taskScopes = (await client.response(taskScopesRequest, "scopes")).body.scopes;
|
|
const taskRuntime = await variables(client, scopeReference(taskScopes, "Disasmer Runtime"));
|
|
const taskOutput = await variables(client, scopeReference(taskScopes, "Recent Output"));
|
|
assert(
|
|
taskRuntime.some(
|
|
(variable) => variable.name === "coordinator_task_events" && variable.value === 1
|
|
)
|
|
);
|
|
assert(
|
|
taskRuntime.some(
|
|
(variable) =>
|
|
variable.name === "command_status" &&
|
|
String(variable.value).includes("resumed through coordinator") &&
|
|
String(variable.value).includes("requested freeze")
|
|
)
|
|
);
|
|
assert(
|
|
taskRuntime.some(
|
|
(variable) => variable.name === "state" && variable.value === "Completed"
|
|
)
|
|
);
|
|
assert(taskRuntime.some((variable) => variable.name === "stdout_tail"));
|
|
assert(taskRuntime.some((variable) => variable.name === "stderr_tail"));
|
|
assert(taskOutput.some((variable) => variable.name === "stdout_tail"));
|
|
assert(taskOutput.some((variable) => variable.name === "stderr_tail"));
|
|
assert(
|
|
taskOutput.some((variable) =>
|
|
String(variable.value).includes("coordinator task event from node")
|
|
)
|
|
);
|
|
|
|
return {
|
|
coordinator_implementation: "hosted-policy-coordinator",
|
|
runtime_backend: "live-services",
|
|
attached_to_existing_client_process: true,
|
|
authenticated_with_cli_session: true,
|
|
process: processId,
|
|
main_thread_breakpoint: preTaskMainLine,
|
|
task_thread_breakpoint: compileLinuxLine,
|
|
task_events: 1,
|
|
task_runtime_status: "debug epoch resumed and refrozen through live services",
|
|
variables_verified: true,
|
|
};
|
|
} finally {
|
|
await client.close().catch(() => {});
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
requireEnabled();
|
|
assertPreFinalLedger(readPhase3Ledger(repo));
|
|
const manifest = readJson(manifestPath);
|
|
const forgejoReport = readJson(forgejoReportPath);
|
|
assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun");
|
|
assert.strictEqual(
|
|
manifest.source_commit,
|
|
expectedSourceCommit(),
|
|
"public release e2e must use release assets prepared from the current acceptance commit"
|
|
);
|
|
assert.strictEqual(manifest.default_hosted_coordinator_endpoint, serviceEndpoint);
|
|
assert.strictEqual(manifest.source_tree_clean, true);
|
|
assert.strictEqual(publicTreeAlreadyPushed(manifest), true);
|
|
assert.strictEqual(forgejoReport.release_name, manifest.release_name);
|
|
|
|
const addr = parseAddr(serviceAddr);
|
|
assert.strictEqual(addr.host, serviceHost);
|
|
assert.strictEqual(addr.port, 443);
|
|
|
|
const workRoot = path.resolve(
|
|
process.env.DISASMER_PUBLIC_RELEASE_E2E_WORKDIR ||
|
|
fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-public-dryrun-e2e-"))
|
|
);
|
|
const assetsDir = path.join(workRoot, "assets");
|
|
const installDir = path.join(workRoot, "install");
|
|
const checkout = path.join(workRoot, "public-repo");
|
|
ensureDir(workRoot);
|
|
fs.rmSync(assetsDir, { recursive: true, force: true });
|
|
fs.rmSync(installDir, { recursive: true, force: true });
|
|
fs.rmSync(checkout, { recursive: true, force: true });
|
|
ensureDir(assetsDir);
|
|
ensureDir(installDir);
|
|
|
|
await downloadReleaseAssets(manifest, forgejoReport, assetsDir);
|
|
verifyChecksums(assetsDir);
|
|
|
|
const publicRepositoryUrl =
|
|
process.env.DISASMER_PUBLIC_REPO_URL ||
|
|
manifest.public_repo_url ||
|
|
manifest.public_repo_remote;
|
|
assert(publicRepositoryUrl, "public repository URL is required");
|
|
assert(publicRepositoryUrl.includes(forgejoHost));
|
|
run("git", ["clone", "--depth", "1", publicRepositoryUrl, checkout]);
|
|
assertFilteredPublicCheckout(checkout, manifest);
|
|
|
|
run("tar", ["-xzf", path.join(assetsDir, binaryArchive(manifest)), "-C", installDir]);
|
|
const disasmer = executable(installDir, "disasmer");
|
|
const disasmerCoordinator = executable(installDir, "disasmer-coordinator");
|
|
const disasmerNode = executable(installDir, "disasmer-node");
|
|
const disasmerDap = executable(installDir, "disasmer-debug-dap");
|
|
for (const binary of [disasmer, disasmerCoordinator, disasmerNode, disasmerDap]) {
|
|
const name = path.basename(binary);
|
|
const expected = manifest.binary_digests && manifest.binary_digests[name];
|
|
assert(expected, `manifest omitted digest for ${name}`);
|
|
assert.strictEqual(`sha256:${sha256File(binary)}`, expected);
|
|
}
|
|
|
|
const defaultLoginPlan = runJson(disasmer, ["login", "--plan", "--json"], {
|
|
cwd: checkout,
|
|
});
|
|
assert.strictEqual(defaultLoginPlan.coordinator, serviceEndpoint);
|
|
|
|
const suffix = String(Date.now());
|
|
const requestedProject = `project-${suffix}`;
|
|
const cliNode = `cli-node-${suffix}`;
|
|
const runtimeNode = `runtime-node-${suffix}`;
|
|
let processId;
|
|
let task = "build";
|
|
let mainTask;
|
|
let artifactPath;
|
|
let artifact;
|
|
const projectRoot = path.join(checkout, "examples/launch-build-demo");
|
|
const buildReport = runJson(
|
|
disasmer,
|
|
["build", "--project", projectRoot, "--json"],
|
|
{ cwd: checkout }
|
|
);
|
|
assert.strictEqual(buildReport.status, "built");
|
|
assert(buildReport.bundle_artifact);
|
|
|
|
const login = runJson(
|
|
disasmer,
|
|
[
|
|
"login",
|
|
"--browser",
|
|
"--json",
|
|
"--project-id",
|
|
requestedProject,
|
|
],
|
|
{
|
|
cwd: projectRoot,
|
|
env: {
|
|
...process.env,
|
|
DISASMER_BROWSER_OPEN_COMMAND: browserOpenCommand,
|
|
},
|
|
}
|
|
);
|
|
assert.strictEqual(login.plan.coordinator, serviceEndpoint);
|
|
assert.strictEqual(login.boundary.cli_contacted_coordinator, true);
|
|
assert.strictEqual(login.boundary.scoped_cli_session_received, true);
|
|
assert.strictEqual(login.boundary.provider_tokens_sent_to_nodes, false);
|
|
const loginSession = login.coordinator_response.session;
|
|
assert(loginSession, "hosted browser login omitted its scoped session");
|
|
const tenant = loginSession.tenant;
|
|
const project = loginSession.project;
|
|
const user = loginSession.user;
|
|
for (const [name, value] of Object.entries({ tenant, project, user })) {
|
|
assert.strictEqual(typeof value, "string", `hosted session omitted ${name}`);
|
|
assert.notStrictEqual(value, "", `hosted session returned an empty ${name}`);
|
|
}
|
|
const storedSession = readJson(path.join(projectRoot, ".disasmer/session.json"));
|
|
const sessionSecret = storedSession.session_secret;
|
|
assert.strictEqual(typeof sessionSecret, "string");
|
|
assert.strictEqual(storedSession.tenant, tenant);
|
|
assert.strictEqual(storedSession.project, project);
|
|
assert.strictEqual(storedSession.user, user);
|
|
|
|
const created = await send(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "create_project",
|
|
project,
|
|
name: "Public Release Dry Run E2E",
|
|
})
|
|
);
|
|
assert.strictEqual(created.type, "project_created");
|
|
|
|
const cliGrant = await send(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "create_node_enrollment_grant",
|
|
ttl_seconds: 900,
|
|
})
|
|
);
|
|
assert.strictEqual(cliGrant.type, "node_enrollment_grant_created");
|
|
const cliNodeIdentity = nodeIdentity("public-release-cli-node", cliNode);
|
|
|
|
const attach = runJson(
|
|
disasmer,
|
|
[
|
|
"node",
|
|
"attach",
|
|
"--coordinator",
|
|
serviceEndpoint,
|
|
"--tenant",
|
|
tenant,
|
|
"--project-id",
|
|
project,
|
|
"--node",
|
|
cliNode,
|
|
"--public-key",
|
|
cliNodeIdentity.publicKey,
|
|
"--enrollment-grant",
|
|
cliGrant.grant,
|
|
"--json",
|
|
],
|
|
{
|
|
cwd: checkout,
|
|
env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: cliNodeIdentity.privateKey },
|
|
}
|
|
);
|
|
assert.strictEqual(attach.coordinator_response.type, "node_enrollment_exchanged");
|
|
assert.strictEqual(attach.heartbeat_response.type, "node_heartbeat");
|
|
|
|
let worker;
|
|
let workerStderr = "";
|
|
let launch;
|
|
let nodeRun;
|
|
let events;
|
|
try {
|
|
const runtimeGrant = await send(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "create_node_enrollment_grant",
|
|
ttl_seconds: 900,
|
|
})
|
|
);
|
|
assert.strictEqual(runtimeGrant.type, "node_enrollment_grant_created");
|
|
const runtimeNodeIdentity = nodeIdentity("public-release-runtime-node", runtimeNode);
|
|
|
|
const workerArgs = [
|
|
"--coordinator",
|
|
serviceEndpoint,
|
|
"--tenant",
|
|
tenant,
|
|
"--project-id",
|
|
project,
|
|
"--node",
|
|
runtimeNode,
|
|
"--project-root",
|
|
projectRoot,
|
|
"--public-key",
|
|
runtimeNodeIdentity.publicKey,
|
|
"--enrollment-grant",
|
|
runtimeGrant.grant,
|
|
"--worker",
|
|
"--assignment-poll-ms",
|
|
"500",
|
|
"--emit-ready",
|
|
];
|
|
commands.push([disasmerNode, ...workerArgs].join(" "));
|
|
worker = cp.spawn(disasmerNode, workerArgs, {
|
|
cwd: checkout,
|
|
env: { ...process.env, DISASMER_NODE_PRIVATE_KEY: runtimeNodeIdentity.privateKey },
|
|
});
|
|
worker.stderr.on("data", (chunk) => {
|
|
workerStderr += chunk.toString();
|
|
});
|
|
const workerReady = await waitForJsonLine(worker, "public release worker");
|
|
assert.strictEqual(workerReady.node_status, "ready");
|
|
assert.strictEqual(workerReady.mode, "worker");
|
|
assert.strictEqual(workerReady.node, runtimeNode);
|
|
|
|
const runReport = runJson(
|
|
disasmer,
|
|
[
|
|
"run",
|
|
"build",
|
|
"--coordinator",
|
|
serviceEndpoint,
|
|
"--project",
|
|
projectRoot,
|
|
"--json",
|
|
],
|
|
{ cwd: checkout }
|
|
);
|
|
assert.strictEqual(runReport.status, "main_launched");
|
|
processId = runReport.process;
|
|
launch = runReport.task_launch;
|
|
mainTask = launch.task_instance;
|
|
assert.strictEqual(launch.type, "main_launched");
|
|
assert.strictEqual(launch.process, processId);
|
|
assert.strictEqual(launch.task_instance, runReport.task_instance);
|
|
nodeRun = await waitForJsonLine(worker, "public release worker completion");
|
|
task = nodeRun.task_assignment_response.task;
|
|
assert(
|
|
task.startsWith(`${mainTask}:child:`),
|
|
`flagship worker received unexpected task ${task}`
|
|
);
|
|
const flagshipDeadline = Date.now() + 240000;
|
|
while (true) {
|
|
const progress = await send(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "list_task_events",
|
|
process: processId,
|
|
})
|
|
);
|
|
if (
|
|
progress.events.some(
|
|
(event) =>
|
|
event.task_definition === "package_release" &&
|
|
event.terminal_state === "completed"
|
|
)
|
|
) {
|
|
break;
|
|
}
|
|
const failed = progress.events.find(
|
|
(event) => event.terminal_state === "failed"
|
|
);
|
|
assert(!failed, failed?.stderr_tail || "public flagship task failed");
|
|
assert(Date.now() < flagshipDeadline, "timed out waiting for public flagship");
|
|
await sleep(500);
|
|
}
|
|
} catch (error) {
|
|
await stopChild(worker);
|
|
throw error;
|
|
}
|
|
if (workerStderr.trim() && (!nodeRun || nodeRun.node_status !== "completed")) {
|
|
console.error(workerStderr);
|
|
}
|
|
assert.strictEqual(nodeRun.node_status, "completed");
|
|
assert.strictEqual(nodeRun.registration_response.type, "node_enrollment_exchanged");
|
|
assert.strictEqual(nodeRun.capability_response.type, "node_capabilities_recorded");
|
|
assert.strictEqual(nodeRun.task_assignment_response.node, runtimeNode);
|
|
assert.strictEqual(nodeRun.task_assignment_response.process, processId);
|
|
assert.strictEqual(nodeRun.task_assignment_response.task, task);
|
|
assert.strictEqual(nodeRun.debug_command_response.type, "debug_command");
|
|
assert.strictEqual(nodeRun.log_event_response.type, "task_log_recorded");
|
|
assert.strictEqual(nodeRun.vfs_metadata_response.type, "vfs_metadata_recorded");
|
|
assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded");
|
|
|
|
events = await send(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "list_task_events",
|
|
process: processId,
|
|
})
|
|
);
|
|
assert.strictEqual(events.type, "task_events");
|
|
assert(events.events.length >= 3, "real flagship run must publish its task tree");
|
|
const artifactEvent = events.events.find(
|
|
(event) =>
|
|
event.task_definition === "package_release" &&
|
|
event.artifact_path &&
|
|
event.artifact_digest
|
|
);
|
|
assert(artifactEvent, "real flagship run did not publish artifact metadata");
|
|
artifactPath = artifactEvent.artifact_path;
|
|
artifact = artifactPath.slice("/vfs/artifacts/".length);
|
|
|
|
const link = await send(
|
|
addr,
|
|
authenticatedHostedClientRequest(sessionSecret, {
|
|
type: "create_artifact_download_link",
|
|
artifact,
|
|
max_bytes: 1024 * 1024,
|
|
ttl_seconds: 300,
|
|
})
|
|
);
|
|
await stopChild(worker);
|
|
worker = null;
|
|
assert.strictEqual(link.type, "artifact_download_link");
|
|
assert.match(link.link.scoped_token_digest, /^sha256:[a-f0-9]{64}$/);
|
|
|
|
const coreCoordinator = await validateStandaloneCoreCoordinator(
|
|
disasmerCoordinator,
|
|
disasmerNode,
|
|
checkout,
|
|
buildReport.bundle_artifact
|
|
);
|
|
const releasedLiveDap = await validateReleasedLiveDap(
|
|
disasmerDap,
|
|
checkout,
|
|
{ tenant, project, user },
|
|
processId
|
|
);
|
|
|
|
run("node", ["scripts/vscode-f5-smoke.js"], { cwd: checkout, stdio: "pipe" });
|
|
|
|
const report = {
|
|
kind: "disasmer-public-release-dryrun-e2e",
|
|
public_repository_url: publicRepositoryUrl,
|
|
release_name: manifest.release_name,
|
|
source_commit: manifest.source_commit,
|
|
public_tree_identity: manifest.public_tree_identity,
|
|
default_hosted_coordinator_endpoint: serviceEndpoint,
|
|
service_addr: serviceAddr,
|
|
dns_publication_state: dnsPublicationState,
|
|
resolver_override: resolverOverride,
|
|
downloaded_release_assets: true,
|
|
verified_checksums: true,
|
|
clean_public_checkout: true,
|
|
public_repo_build_or_install: true,
|
|
default_operator_selected: true,
|
|
browser_or_cli_login: true,
|
|
attached_user_node: true,
|
|
launch_task_verified: true,
|
|
worker_assignment_poll_verified: true,
|
|
worker_assignment_poll_protocol: "poll_task_assignment",
|
|
core_coordinator_validated: true,
|
|
core_coordinator_implementation: coreCoordinator.coordinator_implementation,
|
|
core_coordinator_client_authority: coreCoordinator.client_authority,
|
|
core_coordinator_authenticated_session: coreCoordinator.authenticated_session,
|
|
core_coordinator_forged_body_authority_denied:
|
|
coreCoordinator.forged_body_authority_denied,
|
|
core_coordinator_wrong_session_denied: coreCoordinator.wrong_session_denied,
|
|
core_coordinator_launch_task_response: coreCoordinator.launch_task_response,
|
|
core_coordinator_assignment_response: coreCoordinator.assignment_response,
|
|
core_coordinator_task_events: coreCoordinator.task_events,
|
|
released_live_dap_verified: true,
|
|
released_live_dap_coordinator_implementation: releasedLiveDap.coordinator_implementation,
|
|
released_live_dap_attached_to_client_process:
|
|
releasedLiveDap.attached_to_existing_client_process,
|
|
released_live_dap_authenticated_with_cli_session:
|
|
releasedLiveDap.authenticated_with_cli_session,
|
|
released_live_dap_task_events: releasedLiveDap.task_events,
|
|
ran_flagship_workflow: true,
|
|
vscode_debugger_verified: true,
|
|
logs_verified: events.events.some(
|
|
(event) => event.stdout_bytes > 0 || event.stderr_bytes > 0
|
|
),
|
|
artifact_metadata_verified: Boolean(
|
|
artifactEvent.artifact_path && artifactEvent.artifact_digest
|
|
),
|
|
artifact_download_or_export_verified: true,
|
|
tenant,
|
|
project,
|
|
process: processId,
|
|
node: runtimeNode,
|
|
launch_task_response: launch.type,
|
|
worker_assignment_process: nodeRun.task_assignment_response.process,
|
|
artifact,
|
|
commands,
|
|
tool_versions: {
|
|
node: process.version,
|
|
git: commandOutput("git", ["--version"]),
|
|
tar: commandOutput("tar", ["--version"]),
|
|
rustc: commandOutput("rustc", ["--version"], { cwd: checkout }),
|
|
cargo: commandOutput("cargo", ["--version"], { cwd: checkout }),
|
|
},
|
|
evidence: {
|
|
login: login.coordinator_response.type,
|
|
attach: attach.coordinator_response.type,
|
|
node_run: nodeRun.coordinator_response.type,
|
|
task_events: events.events.length,
|
|
download_link: link.type,
|
|
core_coordinator: coreCoordinator,
|
|
released_live_dap: releasedLiveDap,
|
|
},
|
|
acceptance_result: "passed",
|
|
};
|
|
for (const [key, value] of Object.entries(report)) {
|
|
if (key.endsWith("_verified") || key.endsWith("_validated") || key.endsWith("_selected") || key.endsWith("_checkout") || key.endsWith("_assets") || key === "public_repo_build_or_install" || key === "browser_or_cli_login" || key === "attached_user_node" || key === "ran_flagship_workflow" || key === "verified_checksums") {
|
|
assert.strictEqual(value, true, `${key} must be true`);
|
|
}
|
|
}
|
|
|
|
ensureDir(path.dirname(reportPath));
|
|
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
|
console.log(`Public release dry-run e2e passed: ${reportPath}`);
|
|
}
|
|
|
|
main().catch((error) => {
|
|
console.error(error.stack || error.message);
|
|
process.exit(1);
|
|
});
|