Public dry run dryrun-309831e1e021
Source commit: 309831e1e021f962c118452336776fd9a94025f9 Public tree identity: sha256:6fa95c1745579bd6256dbeb3d476db0b07c2f24aa9213b0f7783ce1adfc8aca5
This commit is contained in:
commit
f22d0a5791
113 changed files with 39348 additions and 0 deletions
111
scripts/acceptance-doc-contract-smoke.js
Executable file
111
scripts/acceptance-doc-contract-smoke.js
Executable file
|
|
@ -0,0 +1,111 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function criterionLines(source) {
|
||||
return source
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => /^- \[[ x]\] \*\*/.test(line));
|
||||
}
|
||||
|
||||
function assertEveryCriterionHasStatus(source, name) {
|
||||
const lines = criterionLines(source);
|
||||
assert(lines.length > 0, `${name} must contain acceptance criteria`);
|
||||
for (const line of lines) {
|
||||
assert.match(
|
||||
line,
|
||||
/^- \[[ x]\] \*\*(Passed|Partial|Open)(?: \([^)]+\))?:\*\*/,
|
||||
`${name} criterion lacks an explicit status prefix: ${line}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertNoOpenCriteria(source, name) {
|
||||
const open = criterionLines(source).filter((line) => /\*\*Open(?::| \()/.test(line));
|
||||
assert.deepStrictEqual(open, [], `${name} still has Open criteria`);
|
||||
}
|
||||
|
||||
const phase2 = read("acceptance_criteria_phase2.md");
|
||||
const base = read("acceptance_criteria.md");
|
||||
const docsSmoke = read("scripts/docs-smoke.js");
|
||||
const releaseBlockerSmoke = read("scripts/release-blocker-smoke.js");
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
|
||||
assert.match(
|
||||
phase2,
|
||||
/phase 2 superset of `acceptance_criteria\.md`/,
|
||||
"phase 2 criteria must declare that they are a superset of the base criteria"
|
||||
);
|
||||
assert.match(
|
||||
phase2,
|
||||
/Existing `acceptance_criteria\.md` remains required unless it conflicts with this stricter release document; this document wins in conflicts/,
|
||||
"phase 2 criteria must keep base acceptance criteria required unless stricter phase 2 criteria conflict"
|
||||
);
|
||||
assert.match(
|
||||
phase2,
|
||||
/- \[x\] \*\*Passed:\*\* Existing `acceptance_criteria\.md` remains required unless it conflicts with this stricter release document; this document wins in conflicts\./,
|
||||
"phase 2 cross-document requirement must be marked passed only when this guard is wired"
|
||||
);
|
||||
|
||||
for (const [source, name] of [
|
||||
[base, "acceptance_criteria.md"],
|
||||
[phase2, "acceptance_criteria_phase2.md"],
|
||||
]) {
|
||||
assertEveryCriterionHasStatus(source, name);
|
||||
assertNoOpenCriteria(source, name);
|
||||
}
|
||||
|
||||
for (const file of ["MVP.md", "acceptance_criteria.md", "acceptance_criteria_phase2.md"]) {
|
||||
assert(
|
||||
docsSmoke.includes(`"${file}"`),
|
||||
`docs smoke must include ${file} as user-facing acceptance context`
|
||||
);
|
||||
}
|
||||
|
||||
assert(
|
||||
releaseBlockerSmoke.includes('const phase2 = read("acceptance_criteria_phase2.md")'),
|
||||
"release-blocker smoke must read phase 2 acceptance criteria"
|
||||
);
|
||||
assert(
|
||||
releaseBlockerSmoke.includes('const base = read("acceptance_criteria.md")'),
|
||||
"release-blocker smoke must read base acceptance criteria"
|
||||
);
|
||||
|
||||
for (const [source, name] of [
|
||||
[base, "acceptance_criteria.md"],
|
||||
[phase2, "acceptance_criteria_phase2.md"],
|
||||
]) {
|
||||
for (const [label, pattern] of [
|
||||
["MVP selected locals", /selected (?:top-level )?locals|selected real source locals/],
|
||||
["MVP task args", /task arguments|task args/],
|
||||
["MVP handle inspection", /Artifact.*SourceSnapshot.*Blob|Disasmer handles/],
|
||||
["MVP stdout stderr", /stdout\/stderr/],
|
||||
["MVP unavailable locals", /cannot be inspected|unavailable-local/],
|
||||
["MVP required DAP surface", /initialize[\s\S]*launch.*attach[\s\S]*setBreakpoints[\s\S]*configurationDone[\s\S]*threads[\s\S]*stackTrace[\s\S]*scopes[\s\S]*variables[\s\S]*continue[\s\S]*pause/],
|
||||
]) {
|
||||
assert.match(source, pattern, `${name} must include MVP debugging criterion: ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [scriptName, script] of [
|
||||
["public acceptance", publicAcceptance],
|
||||
["private acceptance", privateAcceptance],
|
||||
["public split", publicSplit],
|
||||
]) {
|
||||
assert(
|
||||
script.includes("node scripts/acceptance-doc-contract-smoke.js"),
|
||||
`${scriptName} must run acceptance-doc-contract-smoke.js`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Acceptance doc contract smoke passed");
|
||||
244
scripts/acceptance-environment-contract-smoke.js
Executable file
244
scripts/acceptance-environment-contract-smoke.js
Executable file
|
|
@ -0,0 +1,244 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function maybeRead(relativePath) {
|
||||
const absolute = path.join(repo, relativePath);
|
||||
if (!fs.existsSync(absolute)) return null;
|
||||
return fs.readFileSync(absolute, "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing acceptance environment evidence: ${name}`);
|
||||
}
|
||||
|
||||
function expectIncludes(source, name, text) {
|
||||
assert(source.includes(text), `missing acceptance environment evidence: ${name}`);
|
||||
}
|
||||
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
const acceptanceReport = read("scripts/acceptance-report.js");
|
||||
const acceptanceReportSmoke = read("scripts/acceptance-report-smoke.js");
|
||||
const readme = read("README.md");
|
||||
const windowsWorkflow = read(".forgejo/workflows/windows-validation.yml");
|
||||
const publicDryrunServiceSmoke = maybeRead("private/hosted-policy/scripts/public-release-dryrun-service-smoke.js");
|
||||
const publicDryrunDeployPrep = maybeRead("private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js");
|
||||
const publicDryrunSystemd = maybeRead("private/hosted-policy/deploy/disasmer-public-release-dryrun.service");
|
||||
const publicDryrunRunbook = maybeRead("private/hosted-policy/deploy/README.md");
|
||||
const publicOperatorCompatSmoke = maybeRead("private/hosted-policy/scripts/public-operator-compat-smoke.js");
|
||||
const hostedService = maybeRead("private/hosted-policy/src/bin/disasmer-hosted-service.rs");
|
||||
const publicDryrunE2e = read("scripts/public-release-dryrun-e2e.js");
|
||||
const finalDryrunEvidence = read("scripts/public-release-dryrun-final-evidence.js");
|
||||
|
||||
for (const [name, script] of [
|
||||
["public acceptance", publicAcceptance],
|
||||
["private acceptance", privateAcceptance],
|
||||
]) {
|
||||
expect(script, `${name} writes acceptance environment report first`, /node scripts\/acceptance-report\.js (public|private)[\s\S]*node scripts\/acceptance-report-smoke\.js/);
|
||||
expectIncludes(
|
||||
script,
|
||||
`${name} runs acceptance environment contract`,
|
||||
"node scripts/acceptance-environment-contract-smoke.js"
|
||||
);
|
||||
}
|
||||
|
||||
for (const [name, script] of [
|
||||
["public acceptance", publicAcceptance],
|
||||
["public split", publicSplit],
|
||||
]) {
|
||||
for (const smoke of [
|
||||
"scripts/local-services-smoke.js",
|
||||
"scripts/node-attach-smoke.js",
|
||||
"scripts/cli-local-run-smoke.js",
|
||||
"scripts/vscode-f5-smoke.js",
|
||||
"scripts/dap-smoke.js",
|
||||
"scripts/artifact-download-smoke.js",
|
||||
"scripts/artifact-export-smoke.js",
|
||||
"scripts/public-local-demo-matrix-smoke.js",
|
||||
]) {
|
||||
assert(script.includes(`node ${smoke}`), `${name} must run ${smoke}`);
|
||||
}
|
||||
}
|
||||
|
||||
expectIncludes(publicAcceptance, "public gate runs rootless Podman backend smoke", "node scripts/podman-backend-smoke.js");
|
||||
expectIncludes(publicAcceptance, "public gate runs Wasmtime node smoke", "node scripts/wasmtime-node-smoke.js");
|
||||
expectIncludes(privateAcceptance, "private gate runs hosted deployment smoke", "node private/hosted-policy/scripts/hosted-deployment-smoke.js");
|
||||
expectIncludes(privateAcceptance, "private gate prepares public dry-run deployment bundle", "node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js");
|
||||
expectIncludes(privateAcceptance, "private gate runs hosted community smoke", "node private/hosted-policy/scripts/hosted-community-smoke.js");
|
||||
expectIncludes(privateAcceptance, "private gate runs public operator compatibility smoke", "node private/hosted-policy/scripts/public-operator-compat-smoke.js");
|
||||
expectIncludes(privateAcceptance, "private gate runs standalone public coordinator smoke", "node scripts/self-hosted-coordinator-smoke.js");
|
||||
expectIncludes(privateAcceptance, "private gate runs Postgres durable smoke", "node private/hosted-policy/scripts/postgres-durable-smoke.js");
|
||||
expectIncludes(privateAcceptance, "private gate can run public release dry-run service smoke", "node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js");
|
||||
expectIncludes(privateAcceptance, "public release dry-run service smoke is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR");
|
||||
expectIncludes(privateAcceptance, "private gate runs hosted policy cargo tests", "cargo test --manifest-path private/hosted-policy/Cargo.toml");
|
||||
expectIncludes(publicAcceptance, "public gate can run final dry-run evidence verifier", "node scripts/public-release-dryrun-final-evidence.js");
|
||||
expectIncludes(publicAcceptance, "public gate can run public release dry-run e2e", "node scripts/public-release-dryrun-e2e.js");
|
||||
expectIncludes(publicAcceptance, "public release dry-run e2e is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_E2E");
|
||||
expectIncludes(privateAcceptance, "private gate can run final dry-run evidence verifier", "node scripts/public-release-dryrun-final-evidence.js");
|
||||
expectIncludes(publicAcceptance, "public final dry-run verifier is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL");
|
||||
expectIncludes(privateAcceptance, "private final dry-run verifier is env gated", "DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL");
|
||||
|
||||
expect(publicSplit, "public split excludes private modules", /--exclude='\.\/private'/);
|
||||
expect(publicSplit, "public split excludes experiments", /--exclude='\.\/experiments'/);
|
||||
expect(publicSplit, "public split tests copied workspace", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/);
|
||||
expect(publicSplit, "public split builds copied workspace binaries", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/);
|
||||
expectIncludes(
|
||||
publicSplit,
|
||||
"public split runs acceptance environment contract from copied tree",
|
||||
'(cd "$tmp_dir" && node scripts/acceptance-environment-contract-smoke.js)'
|
||||
);
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["commit SHA fallback", /process\.env\.DISASMER_ACCEPTANCE_COMMIT \|\| commandOutput\("git", \["rev-parse", "HEAD"\]\)/],
|
||||
["tree status", /tree_status: \(commandOutput\("git", \["status", "--short"\]\) \|\| ""\)[\s\S]*\.filter\(Boolean\)/],
|
||||
["OS report", /platform: os\.platform\(\)[\s\S]*kernel: os\.release\(\)/],
|
||||
["Rust report", /rustc: commandOutput\("rustc", \["--version"\]\)/],
|
||||
["Node report", /version: process\.version/],
|
||||
["Podman report", /function podmanReport\(\)/],
|
||||
["Postgres report", /postgres: \{[\s\S]*commandOutput\("postgres", \["--version"\]\) \|\|[\s\S]*commandOutput\("psql", \["--version"\]\)/],
|
||||
["browser harness report", /browser_harness:/],
|
||||
["VS Code harness report", /vscode_harness:/],
|
||||
["Windows validation report", /windows_validation: process\.env\.DISASMER_WINDOWS_VALIDATION \|\| "not-run"/],
|
||||
]) {
|
||||
expect(acceptanceReport, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["acceptance report validates Podman incomplete state", /assertPodmanReport/],
|
||||
["acceptance report validates Windows not-run", /assertReport\(runReport\(mode\), mode, "not-run"\)/],
|
||||
["acceptance report validates Windows runner mode", /DISASMER_WINDOWS_VALIDATION: "forgejo-windows-runner"/],
|
||||
]) {
|
||||
expect(acceptanceReportSmoke, name, pattern);
|
||||
}
|
||||
|
||||
expect(readme, "README documents public acceptance script", /scripts\/acceptance-public\.sh/);
|
||||
expect(readme, "README documents private acceptance script", /scripts\/acceptance-private\.sh/);
|
||||
expect(readme, "README documents rootless Podman incomplete handling", /Podman backend behavior is marked `incomplete`/);
|
||||
expect(readme, "README documents Postgres discovery in environment report", /Podman\/Postgres discovery/);
|
||||
expect(readme, "README documents manual Windows validation", /manual `Windows validation`\s+workflow/);
|
||||
|
||||
expect(windowsWorkflow, "Windows workflow is manual", /workflow_dispatch/);
|
||||
expect(windowsWorkflow, "Windows workflow uses intermittent Windows runner", /runs-on:\s*windows/);
|
||||
expect(windowsWorkflow, "Windows workflow writes acceptance report", /node scripts\/acceptance-report\.js windows/);
|
||||
|
||||
if (publicDryrunServiceSmoke && publicDryrunDeployPrep && publicDryrunSystemd && publicDryrunRunbook) {
|
||||
for (const [name, pattern] of [
|
||||
["service smoke requires external service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR is required/],
|
||||
["service smoke requires OIDC test issuer", /DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL/],
|
||||
["service smoke loads release manifest", /public-release-manifest\.json/],
|
||||
["service smoke rejects stale release manifest", /manifest\.source_commit[\s\S]*expectedCommit/],
|
||||
["service smoke records source commit", /source_commit: release\.sourceCommit/],
|
||||
["service smoke records release name", /release_name: release\.releaseName/],
|
||||
["service smoke connects through public domain", /addr\.host[\s\S]*serviceHost/],
|
||||
["service smoke verifies DNS state", /\["not-published", "published"\]\.includes\(dnsPublicationState\)/],
|
||||
["service smoke performs OIDC login", /type: "oidc_browser_login"/],
|
||||
["service smoke creates project", /type: "create_project"/],
|
||||
["service smoke enrolls node", /type: "create_node_enrollment_token"[\s\S]*exchange_node_enrollment_token/],
|
||||
["service smoke starts user node process", /type: "start_user_node_process"/],
|
||||
["service smoke records task", /type: "record_user_node_task_completion"/],
|
||||
["service smoke reads debug state", /type: "debug_process"/],
|
||||
["service smoke reads artifact metadata", /type: "artifact_metadata"/],
|
||||
["service smoke creates download link", /type: "create_artifact_download_link"/],
|
||||
["service smoke records observability", /type: "observability_snapshot"/],
|
||||
["service smoke records private hosted coordinator", /operator_implementation:[\s\S]*"private-hosted-coordinator"/],
|
||||
["service smoke writes evidence report", /public-release-dryrun-service\.json/],
|
||||
]) {
|
||||
expect(publicDryrunServiceSmoke, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, source, pattern] of [
|
||||
["deployment prep builds hosted service release", publicDryrunDeployPrep, /cargo"[\s\S]*"build"[\s\S]*"--release"[\s\S]*"private\/hosted-policy\/Cargo\.toml"[\s\S]*"disasmer-hosted-service"/],
|
||||
["deployment prep stages systemd unit", publicDryrunDeployPrep, /disasmer-public-release-dryrun\.service/],
|
||||
["deployment prep writes manifest", publicDryrunDeployPrep, /deployment-manifest\.json/],
|
||||
["deployment prep records private hosted coordinator", publicDryrunDeployPrep, /operator_implementation:[\s\S]*"private-hosted-coordinator"/],
|
||||
["deployment prep records service address", publicDryrunDeployPrep, /service_addr:[\s\S]*`\$\{serviceHost\}:\$\{servicePort\}`/],
|
||||
["deployment prep records DNS state", publicDryrunDeployPrep, /dns_publication_state: dnsPublicationState/],
|
||||
["deployment prep records service smoke command", publicDryrunDeployPrep, /public-release-dryrun-service-smoke\.js/],
|
||||
["systemd binds public TCP 9443", publicDryrunSystemd, /--listen 0\.0\.0\.0:9443/],
|
||||
["systemd avoids privileged port capability", publicDryrunSystemd, /NoNewPrivileges=true/],
|
||||
["systemd uses dedicated user", publicDryrunSystemd, /User=disasmer[\s\S]*Group=disasmer/],
|
||||
["runbook says externally reachable", publicDryrunRunbook, /externally reachable host/],
|
||||
["runbook documents DNS pending fallback", publicDryrunRunbook, /Until the `disasmer\.michelpaulissen\.com` DNS record is deployed/],
|
||||
["runbook gives hosts entry", publicDryrunRunbook, /<deployment-ip> disasmer\.michelpaulissen\.com/],
|
||||
]) {
|
||||
expect(source, name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if (publicOperatorCompatSmoke && hostedService) {
|
||||
for (const [name, pattern] of [
|
||||
["hosted service embeds private coordinator runtime", /private_coordinator: CoordinatorService/],
|
||||
["hosted service parses public client protocol requests", /serde_json::from_str::<CoordinatorRequest>/],
|
||||
["hosted service delegates public requests", /handle_public_request/],
|
||||
["hosted service marks public protocol sessions", /ConnectionProtocol::Public/],
|
||||
["hosted service seeds public projects from hosted auth", /seed_public_project/],
|
||||
["hosted service seeds public enrollment grants", /seed_public_node_enrollment_grant/],
|
||||
]) {
|
||||
expect(hostedService, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["compat smoke starts hosted service", /disasmer-hosted-service/],
|
||||
["compat smoke creates hosted project", /type: "create_project"/],
|
||||
["compat smoke creates hosted enrollment grant", /type: "create_node_enrollment_token"/],
|
||||
["compat smoke runs public CLI attach", /"disasmer-cli"[\s\S]*"node"[\s\S]*"attach"/],
|
||||
["compat smoke verifies public enrollment exchange", /node_enrollment_exchanged/],
|
||||
["compat smoke runs public node runtime", /"disasmer-node"/],
|
||||
["compat smoke launches through coordinator assignment", /type: "launch_task"/],
|
||||
["compat smoke verifies assignment polling", /poll_task_assignment/],
|
||||
["compat smoke verifies public node metadata", /vfs_metadata_recorded/],
|
||||
["compat smoke writes evidence report", /public-operator-compat\.json/],
|
||||
]) {
|
||||
expect(publicOperatorCompatSmoke, name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["e2e runner requires explicit opt-in", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E/],
|
||||
["e2e runner requires public domain service address", /serviceAddr[\s\S]*serviceHost/],
|
||||
["e2e runner downloads release assets", /downloadReleaseAssets/],
|
||||
["e2e runner verifies release checksums", /verifyChecksums/],
|
||||
["e2e runner clones public repo", /git"[\s\S]*"clone"[\s\S]*publicRepositoryUrl/],
|
||||
["e2e runner uses default operator", /defaultLoginPlan\.coordinator[\s\S]*serviceEndpoint/],
|
||||
["e2e runner completes browser login", /--complete-browser-code/],
|
||||
["e2e runner attaches user node", /node"[\s\S]*"attach"/],
|
||||
["e2e runner starts public worker runtime", /workerArgs[\s\S]*"--worker"[\s\S]*cp\.spawn\(disasmerNode/],
|
||||
["e2e runner launches through coordinator assignment", /type: "launch_task"/],
|
||||
["e2e runner verifies public assignment polling", /worker_assignment_poll_protocol/],
|
||||
["e2e runner validates standalone public coordinator", /validateStandalonePublicCoordinator/],
|
||||
["e2e runner records standalone public coordinator", /public_coordinator_operator_implementation/],
|
||||
["e2e runner verifies task events", /list_task_events/],
|
||||
["e2e runner creates artifact download link", /create_artifact_download_link/],
|
||||
["e2e runner verifies VS Code debugger", /vscode-f5-smoke\.js/],
|
||||
["e2e runner writes e2e report", /public-release-dryrun-e2e\.json/],
|
||||
]) {
|
||||
expect(publicDryrunE2e, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["final verifier requires public release manifest", /public-release-manifest\.json/],
|
||||
["final verifier requires Forgejo release evidence", /public-release-dryrun-forgejo-release\.json/],
|
||||
["final verifier requires deployment manifest", /deployment-manifest\.json/],
|
||||
["final verifier requires service smoke evidence", /public-release-dryrun-service\.json/],
|
||||
["final verifier requires current service smoke source", /service\.source_commit[\s\S]*manifest\.source_commit/],
|
||||
["final verifier requires current service smoke release", /service\.release_name[\s\S]*manifest\.release_name/],
|
||||
["final verifier requires public operator compatibility evidence", /public-operator-compat\.json/],
|
||||
["final verifier requires public coordinator compatibility evidence", /public-coordinator-compat\.json/],
|
||||
["final verifier requires public e2e evidence", /public-release-dryrun-e2e\.json/],
|
||||
["final verifier records both coordinator validations", /coordinator_validation/],
|
||||
["final verifier writes final evidence", /public-release-dryrun-final\.json/],
|
||||
]) {
|
||||
expect(finalDryrunEvidence, name, pattern);
|
||||
}
|
||||
|
||||
console.log("Acceptance environment contract smoke passed");
|
||||
236
scripts/acceptance-evidence-contract-smoke.js
Executable file
236
scripts/acceptance-evidence-contract-smoke.js
Executable file
|
|
@ -0,0 +1,236 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing acceptance evidence guard: ${name}`);
|
||||
}
|
||||
|
||||
function expectGate(script, gateName) {
|
||||
assert(
|
||||
script.includes("node scripts/acceptance-evidence-contract-smoke.js"),
|
||||
`${gateName} must run acceptance-evidence-contract-smoke.js`
|
||||
);
|
||||
}
|
||||
|
||||
const phase2 = read("acceptance_criteria_phase2.md");
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
const localServicesSmoke = read("scripts/local-services-smoke.js");
|
||||
const nodeAttachSmoke = read("scripts/node-attach-smoke.js");
|
||||
const cliLocalRunSmoke = read("scripts/cli-local-run-smoke.js");
|
||||
const vscodeF5Smoke = read("scripts/vscode-f5-smoke.js");
|
||||
const dapSmoke = read("scripts/dap-smoke.js");
|
||||
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||
const artifactExportSmoke = read("scripts/artifact-export-smoke.js");
|
||||
const selfHostedCoordinatorSmoke = read("scripts/self-hosted-coordinator-smoke.js");
|
||||
const publicLocalDemoMatrix = read("scripts/public-local-demo-matrix-smoke.js");
|
||||
const publicOperatorCompatSmoke = fs.existsSync(
|
||||
path.join(repo, "private/hosted-policy/scripts/public-operator-compat-smoke.js")
|
||||
)
|
||||
? read("private/hosted-policy/scripts/public-operator-compat-smoke.js")
|
||||
: null;
|
||||
const publicDryrunE2e = read("scripts/public-release-dryrun-e2e.js");
|
||||
const finalDryrunEvidence = read("scripts/public-release-dryrun-final-evidence.js");
|
||||
|
||||
expect(
|
||||
phase2,
|
||||
"phase 2 rejects type-only acceptance",
|
||||
/- \[x\] \*\*Passed:\*\* A criterion cannot be accepted solely because a type, trait, schema, mock, or unit-level model exists\./
|
||||
);
|
||||
|
||||
for (const [gateName, script] of [
|
||||
["public acceptance", publicAcceptance],
|
||||
["private acceptance", privateAcceptance],
|
||||
["public split", publicSplit],
|
||||
]) {
|
||||
expectGate(script, gateName);
|
||||
}
|
||||
|
||||
expect(publicAcceptance, "public gate includes unit coverage", /cargo test --workspace/);
|
||||
expect(publicAcceptance, "public gate includes binary build coverage", /cargo build --workspace --bins/);
|
||||
expect(privateAcceptance, "private gate includes hosted unit coverage", /cargo test --manifest-path private\/hosted-policy\/Cargo\.toml/);
|
||||
expect(publicSplit, "public split includes copied-tree unit coverage", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/);
|
||||
expect(publicSplit, "public split includes copied-tree binary build coverage", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/);
|
||||
|
||||
for (const [gateName, script] of [
|
||||
["public acceptance", publicAcceptance],
|
||||
["public split", publicSplit],
|
||||
]) {
|
||||
for (const smoke of [
|
||||
"scripts/local-services-smoke.js",
|
||||
"scripts/node-attach-smoke.js",
|
||||
"scripts/cli-local-run-smoke.js",
|
||||
"scripts/vscode-f5-smoke.js",
|
||||
"scripts/dap-smoke.js",
|
||||
"scripts/artifact-download-smoke.js",
|
||||
"scripts/artifact-export-smoke.js",
|
||||
"scripts/public-local-demo-matrix-smoke.js",
|
||||
]) {
|
||||
assert(script.includes(`node ${smoke}`), `${gateName} must run boundary smoke ${smoke}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const smoke of [
|
||||
"private/hosted-policy/scripts/hosted-deployment-smoke.js",
|
||||
"private/hosted-policy/scripts/hosted-community-smoke.js",
|
||||
"private/hosted-policy/scripts/public-operator-compat-smoke.js",
|
||||
"private/hosted-policy/scripts/postgres-durable-smoke.js",
|
||||
]) {
|
||||
assert(
|
||||
privateAcceptance.includes(`node ${smoke}`),
|
||||
`private acceptance must run hosted boundary smoke ${smoke}`
|
||||
);
|
||||
}
|
||||
assert(
|
||||
privateAcceptance.includes("node scripts/self-hosted-coordinator-smoke.js"),
|
||||
"private acceptance must run standalone public coordinator smoke"
|
||||
);
|
||||
|
||||
for (const [name, source, patterns] of [
|
||||
[
|
||||
"local services",
|
||||
localServicesSmoke,
|
||||
[/cp\.spawn/, /disasmer-coordinator/, /type: "create_node_enrollment_grant"/, /disasmer-node/],
|
||||
],
|
||||
[
|
||||
"node attach",
|
||||
nodeAttachSmoke,
|
||||
[/cp\.spawn/, /"node"[\s\S]*"attach"/, /used_enrollment_exchange/, /runAttachedNodeWork/],
|
||||
],
|
||||
[
|
||||
"CLI local run",
|
||||
cliLocalRunSmoke,
|
||||
[/cp\.spawn/, /disasmer[\s\S]*run/, /cli_process_started_node_process[\s\S]*true/],
|
||||
],
|
||||
[
|
||||
"VS Code F5",
|
||||
vscodeF5Smoke,
|
||||
[
|
||||
/runtimeBackend[\s\S]*local-services/,
|
||||
/coordinator_task_events[\s\S]*value === 1/,
|
||||
/Source Locals/,
|
||||
/Task Args and Handles/,
|
||||
/unavailable-local-diagnostic/,
|
||||
/TaskHandle/,
|
||||
/linux_thread/,
|
||||
/linux_artifact/,
|
||||
/command_spec/,
|
||||
/stdout_tail/,
|
||||
/stderr_tail/,
|
||||
],
|
||||
],
|
||||
[
|
||||
"DAP",
|
||||
dapSmoke,
|
||||
[
|
||||
/runtimeBackend: "local-services"/,
|
||||
/threads[\s\S]*compile linux/,
|
||||
/send\("attach"/,
|
||||
/Source Locals/,
|
||||
/return_value/,
|
||||
/TaskHandle/,
|
||||
/linux_thread/,
|
||||
/linux_artifact/,
|
||||
/vfs_mounts/,
|
||||
/command_spec/,
|
||||
/stdout_tail/,
|
||||
/stderr_tail/,
|
||||
],
|
||||
],
|
||||
[
|
||||
"artifact download",
|
||||
artifactDownloadSmoke,
|
||||
[/create_artifact_download_link/, /open_artifact_download_stream/, /crossTenantOpen/],
|
||||
],
|
||||
[
|
||||
"artifact export",
|
||||
artifactExportSmoke,
|
||||
[/export_artifact_to_node/, /node-export-receiver/, /coordinator_bulk_relay_allowed[\s\S]*false/],
|
||||
],
|
||||
[
|
||||
"standalone public coordinator",
|
||||
selfHostedCoordinatorSmoke,
|
||||
[/disasmer-coordinator/, /standalone-public-coordinator/, /public-coordinator-compat\.json/],
|
||||
],
|
||||
[
|
||||
"public local demo matrix",
|
||||
publicLocalDemoMatrix,
|
||||
[/scripts\/cli-install-smoke\.js/, /scripts\/node-attach-smoke\.js/, /scripts\/vscode-f5-smoke\.js/],
|
||||
],
|
||||
]) {
|
||||
for (const pattern of patterns) {
|
||||
expect(source, name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if (publicOperatorCompatSmoke) {
|
||||
for (const pattern of [
|
||||
/disasmer-hosted-service/,
|
||||
/"disasmer-cli"[\s\S]*"node"[\s\S]*"attach"/,
|
||||
/node_enrollment_exchanged/,
|
||||
/"disasmer-node"/,
|
||||
/node_capabilities_recorded/,
|
||||
/task_launched/,
|
||||
/poll_task_assignment/,
|
||||
/debug_command/,
|
||||
/task_log_recorded/,
|
||||
/vfs_metadata_recorded/,
|
||||
/public-operator-compat\.json/,
|
||||
]) {
|
||||
expect(publicOperatorCompatSmoke, "public operator compatibility", pattern);
|
||||
}
|
||||
}
|
||||
|
||||
for (const pattern of [
|
||||
/DISASMER_PUBLIC_RELEASE_DRYRUN_E2E/,
|
||||
/downloadReleaseAssets/,
|
||||
/verifyChecksums/,
|
||||
/git"[\s\S]*"clone"/,
|
||||
/defaultLoginPlan\.coordinator/,
|
||||
/--complete-browser-code/,
|
||||
/node_enrollment_exchanged/,
|
||||
/disasmerNode/,
|
||||
/launch_task/,
|
||||
/worker_assignment_poll_verified/,
|
||||
/validateStandalonePublicCoordinator/,
|
||||
/public_coordinator_validated/,
|
||||
/standalone-public-coordinator/,
|
||||
/task_recorded/,
|
||||
/list_task_events/,
|
||||
/create_artifact_download_link/,
|
||||
/vscode-f5-smoke\.js/,
|
||||
/downloaded_release_assets: true/,
|
||||
/vscode_debugger_verified: true/,
|
||||
/artifact_download_or_export_verified: true/,
|
||||
/public-release-dryrun-e2e\.json/,
|
||||
]) {
|
||||
expect(publicDryrunE2e, "public release e2e evidence", pattern);
|
||||
}
|
||||
|
||||
for (const pattern of [
|
||||
/public-release-dryrun-forgejo-release\.json/,
|
||||
/deployment-manifest\.json/,
|
||||
/public-release-dryrun-service\.json/,
|
||||
/public-operator-compat\.json/,
|
||||
/public-coordinator-compat\.json/,
|
||||
/public-release-dryrun-e2e\.json/,
|
||||
/coordinator_validation/,
|
||||
/downloaded_release_assets/,
|
||||
/vscode_debugger_verified/,
|
||||
/artifact_download_or_export_verified/,
|
||||
/public-release-dryrun-final\.json/,
|
||||
]) {
|
||||
expect(finalDryrunEvidence, "public release final evidence", pattern);
|
||||
}
|
||||
|
||||
console.log("Acceptance evidence contract smoke passed");
|
||||
30
scripts/acceptance-private.sh
Executable file
30
scripts/acceptance-private.sh
Executable file
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo"
|
||||
|
||||
node scripts/acceptance-report.js private
|
||||
node scripts/acceptance-report-smoke.js
|
||||
node scripts/acceptance-doc-contract-smoke.js
|
||||
node scripts/acceptance-environment-contract-smoke.js
|
||||
node scripts/acceptance-evidence-contract-smoke.js
|
||||
node scripts/public-private-boundary-smoke.js
|
||||
node scripts/release-blocker-smoke.js
|
||||
node scripts/resource-metering-contract-smoke.js
|
||||
node scripts/hostile-input-contract-smoke.js
|
||||
node scripts/tenant-isolation-contract-smoke.js
|
||||
scripts/release-source-scan.sh
|
||||
cargo test --manifest-path private/hosted-policy/Cargo.toml
|
||||
node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js
|
||||
node private/hosted-policy/scripts/hosted-deployment-smoke.js
|
||||
node private/hosted-policy/scripts/hosted-community-smoke.js
|
||||
node private/hosted-policy/scripts/public-operator-compat-smoke.js
|
||||
node scripts/self-hosted-coordinator-smoke.js
|
||||
node private/hosted-policy/scripts/postgres-durable-smoke.js
|
||||
if [[ -n "${DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR:-}" ]]; then
|
||||
node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js
|
||||
fi
|
||||
if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:-}" == "1" ]]; then
|
||||
node scripts/public-release-dryrun-final-evidence.js
|
||||
fi
|
||||
64
scripts/acceptance-public.sh
Executable file
64
scripts/acceptance-public.sh
Executable file
|
|
@ -0,0 +1,64 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo"
|
||||
|
||||
node scripts/acceptance-report.js public
|
||||
node scripts/acceptance-report-smoke.js
|
||||
node scripts/acceptance-doc-contract-smoke.js
|
||||
node scripts/acceptance-environment-contract-smoke.js
|
||||
node scripts/acceptance-evidence-contract-smoke.js
|
||||
node scripts/public-private-boundary-smoke.js
|
||||
node scripts/release-blocker-smoke.js
|
||||
node scripts/resource-metering-contract-smoke.js
|
||||
node scripts/hostile-input-contract-smoke.js
|
||||
node scripts/tenant-isolation-contract-smoke.js
|
||||
node scripts/public-story-contract-smoke.js
|
||||
node scripts/public-release-dryrun-contract-smoke.js
|
||||
node scripts/public-browser-login-contract-smoke.js
|
||||
node scripts/self-hosted-coordinator-smoke.js
|
||||
node scripts/public-local-demo-matrix-smoke.js
|
||||
scripts/release-source-scan.sh
|
||||
node scripts/prepare-public-release-dryrun.js
|
||||
if [[ "${DISASMER_PUBLIC_RELEASE_PREFLIGHT:-}" == "1" ]]; then
|
||||
node scripts/public-release-dryrun-preflight.js
|
||||
fi
|
||||
if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]; then
|
||||
node scripts/publish-public-release-dryrun.js
|
||||
fi
|
||||
if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_E2E:-}" == "1" ]]; then
|
||||
node scripts/public-release-dryrun-e2e.js
|
||||
fi
|
||||
if [[ "${DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:-}" == "1" ]]; then
|
||||
node scripts/public-release-dryrun-final-evidence.js
|
||||
fi
|
||||
cargo fmt --all --check
|
||||
cargo test --workspace
|
||||
cargo build --workspace --bins
|
||||
node scripts/docs-smoke.js
|
||||
node scripts/cli-login-smoke.js
|
||||
node scripts/cli-browser-login-flow-smoke.js
|
||||
node scripts/cli-install-smoke.js
|
||||
node scripts/user-session-token-boundary-smoke.js
|
||||
node scripts/sdk-spawn-runtime-smoke.js
|
||||
node scripts/node-lifecycle-contract-smoke.js
|
||||
node scripts/wasmtime-node-smoke.js
|
||||
node scripts/podman-backend-smoke.js
|
||||
node scripts/vscode-extension-smoke.js
|
||||
node scripts/vscode-f5-smoke.js
|
||||
node scripts/node-attach-smoke.js
|
||||
node scripts/local-services-smoke.js
|
||||
node scripts/cancellation-smoke.js
|
||||
node scripts/cli-local-run-smoke.js
|
||||
node scripts/artifact-download-smoke.js
|
||||
node scripts/artifact-export-smoke.js
|
||||
node scripts/operator-panel-smoke.js
|
||||
node scripts/source-preparation-smoke.js
|
||||
node scripts/scheduler-placement-smoke.js
|
||||
node scripts/windows-best-effort-smoke.js
|
||||
node scripts/windows-validation-contract-smoke.js
|
||||
node scripts/quic-smoke.js
|
||||
node scripts/dap-smoke.js
|
||||
node scripts/flagship-demo-smoke.js
|
||||
scripts/verify-public-split.sh
|
||||
113
scripts/acceptance-report-smoke.js
Normal file
113
scripts/acceptance-report-smoke.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function runReport(mode, env = {}) {
|
||||
const output = cp.execFileSync(
|
||||
"node",
|
||||
["scripts/acceptance-report.js", mode],
|
||||
{
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
}
|
||||
);
|
||||
const report = JSON.parse(output);
|
||||
const persistedPath = path.join(
|
||||
repo,
|
||||
"target",
|
||||
"acceptance",
|
||||
`${mode}-environment.json`
|
||||
);
|
||||
const persisted = JSON.parse(fs.readFileSync(persistedPath, "utf8"));
|
||||
assert.deepStrictEqual(persisted, report, `${mode} report was not persisted`);
|
||||
return report;
|
||||
}
|
||||
|
||||
function assertString(value, name) {
|
||||
assert.strictEqual(typeof value, "string", `${name} must be a string`);
|
||||
assert(value.length > 0, `${name} must not be empty`);
|
||||
}
|
||||
|
||||
function assertNullableString(value, name) {
|
||||
if (value === null) return;
|
||||
assertString(value, name);
|
||||
}
|
||||
|
||||
function assertPodmanReport(podman) {
|
||||
assert(podman && typeof podman === "object", "podman report must be an object");
|
||||
assert(
|
||||
["available", "incomplete"].includes(podman.status),
|
||||
"podman.status must be available or incomplete"
|
||||
);
|
||||
assertNullableString(podman.version, "podman.version");
|
||||
assertNullableString(podman.rootless, "podman.rootless");
|
||||
assertNullableString(podman.incomplete_reason, "podman.incomplete_reason");
|
||||
if (podman.status === "available") {
|
||||
assertString(podman.version, "podman.version");
|
||||
assert.strictEqual(podman.rootless, "true");
|
||||
assert.strictEqual(podman.incomplete_reason, null);
|
||||
} else {
|
||||
assertString(podman.incomplete_reason, "podman.incomplete_reason");
|
||||
}
|
||||
}
|
||||
|
||||
function assertReport(report, mode, expectedWindowsValidation) {
|
||||
assert.strictEqual(report.kind, "disasmer_acceptance_environment");
|
||||
assert.strictEqual(report.mode, mode);
|
||||
assert.match(report.generated_at, /^\d{4}-\d{2}-\d{2}T/);
|
||||
assert.match(report.commit, /^[0-9a-f]{40}$/);
|
||||
assert(Array.isArray(report.tree_status), "tree_status must be an array");
|
||||
|
||||
assertString(report.os.platform, "os.platform");
|
||||
assertString(report.os.release, "os.release");
|
||||
assertString(report.os.kernel, "os.kernel");
|
||||
assertString(report.os.arch, "os.arch");
|
||||
|
||||
assertString(report.rust.rustc, "rust.rustc");
|
||||
assertString(report.rust.cargo, "rust.cargo");
|
||||
assertString(report.node.version, "node.version");
|
||||
assert(report.node.version.startsWith("v"), "node.version must be Node.js style");
|
||||
|
||||
assertPodmanReport(report.podman);
|
||||
assertNullableString(report.postgres.version, "postgres.version");
|
||||
assertNullableString(report.browser_harness.version, "browser_harness.version");
|
||||
assertNullableString(report.browser_harness.command, "browser_harness.command");
|
||||
assertString(report.browser_harness.configured, "browser_harness.configured");
|
||||
|
||||
assert(Array.isArray(report.vscode_harness.smokes), "vscode smokes must be listed");
|
||||
assert(
|
||||
report.vscode_harness.smokes.includes("scripts/vscode-extension-smoke.js"),
|
||||
"VS Code extension smoke must be recorded"
|
||||
);
|
||||
assert(
|
||||
report.vscode_harness.smokes.includes("scripts/vscode-f5-smoke.js"),
|
||||
"VS Code F5 smoke must be recorded"
|
||||
);
|
||||
assertString(report.vscode_harness.engine, "vscode_harness.engine");
|
||||
assertString(
|
||||
report.vscode_harness.extension_version,
|
||||
"vscode_harness.extension_version"
|
||||
);
|
||||
|
||||
assert.strictEqual(report.windows_validation, expectedWindowsValidation);
|
||||
}
|
||||
|
||||
for (const mode of ["public", "private"]) {
|
||||
assertReport(runReport(mode), mode, "not-run");
|
||||
}
|
||||
|
||||
assertReport(
|
||||
runReport("windows", {
|
||||
DISASMER_WINDOWS_VALIDATION: "forgejo-windows-runner",
|
||||
}),
|
||||
"windows",
|
||||
"forgejo-windows-runner"
|
||||
);
|
||||
|
||||
console.log("Acceptance report smoke passed");
|
||||
139
scripts/acceptance-report.js
Executable file
139
scripts/acceptance-report.js
Executable file
|
|
@ -0,0 +1,139 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const mode = process.argv[2] || "public";
|
||||
|
||||
function commandOutput(command, args = []) {
|
||||
try {
|
||||
return cp
|
||||
.execFileSync(command, args, {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
})
|
||||
.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function packageJson() {
|
||||
return JSON.parse(
|
||||
fs.readFileSync(path.join(repo, "vscode-extension/package.json"), "utf8")
|
||||
);
|
||||
}
|
||||
|
||||
function firstCommandOutput(candidates) {
|
||||
for (const [command, args] of candidates) {
|
||||
const output = commandOutput(command, args);
|
||||
if (output) {
|
||||
return { command, version: output };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function podmanReport() {
|
||||
const version = commandOutput("podman", ["--version"]);
|
||||
if (!version) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
version: null,
|
||||
rootless: null,
|
||||
incomplete_reason: "podman command is unavailable"
|
||||
};
|
||||
}
|
||||
const rootless = commandOutput("podman", [
|
||||
"info",
|
||||
"--format",
|
||||
"{{.Host.Security.Rootless}}"
|
||||
]);
|
||||
if (!rootless) {
|
||||
return {
|
||||
status: "incomplete",
|
||||
version,
|
||||
rootless: null,
|
||||
incomplete_reason: "podman info did not report rootless status"
|
||||
};
|
||||
}
|
||||
if (rootless !== "true") {
|
||||
return {
|
||||
status: "incomplete",
|
||||
version,
|
||||
rootless,
|
||||
incomplete_reason: "podman is not running in rootless mode"
|
||||
};
|
||||
}
|
||||
return {
|
||||
status: "available",
|
||||
version,
|
||||
rootless,
|
||||
incomplete_reason: null
|
||||
};
|
||||
}
|
||||
|
||||
const extensionPackage = packageJson();
|
||||
const sourceCommit =
|
||||
process.env.DISASMER_ACCEPTANCE_COMMIT || commandOutput("git", ["rev-parse", "HEAD"]);
|
||||
const browserVersion = firstCommandOutput([
|
||||
["chromium", ["--version"]],
|
||||
["chromium-browser", ["--version"]],
|
||||
["google-chrome", ["--version"]],
|
||||
["firefox", ["--version"]]
|
||||
]);
|
||||
const report = {
|
||||
kind: "disasmer_acceptance_environment",
|
||||
mode,
|
||||
commit: sourceCommit,
|
||||
tree_status: (commandOutput("git", ["status", "--short"]) || "")
|
||||
.split("\n")
|
||||
.filter(Boolean),
|
||||
generated_at: new Date().toISOString(),
|
||||
os: {
|
||||
platform: os.platform(),
|
||||
release: os.release(),
|
||||
kernel: os.release(),
|
||||
arch: os.arch()
|
||||
},
|
||||
rust: {
|
||||
rustc: commandOutput("rustc", ["--version"]),
|
||||
cargo: commandOutput("cargo", ["--version"])
|
||||
},
|
||||
node: {
|
||||
version: process.version
|
||||
},
|
||||
podman: podmanReport(),
|
||||
postgres: {
|
||||
version:
|
||||
commandOutput("postgres", ["--version"]) ||
|
||||
commandOutput("psql", ["--version"])
|
||||
},
|
||||
browser_harness: {
|
||||
version: browserVersion && browserVersion.version,
|
||||
command: browserVersion && browserVersion.command,
|
||||
configured: process.env.DISASMER_BROWSER_HARNESS || "not-configured"
|
||||
},
|
||||
vscode_harness: {
|
||||
smokes: [
|
||||
"scripts/vscode-extension-smoke.js",
|
||||
"scripts/vscode-f5-smoke.js"
|
||||
],
|
||||
engine: extensionPackage.engines && extensionPackage.engines.vscode,
|
||||
extension_version: extensionPackage.version
|
||||
},
|
||||
windows_validation: process.env.DISASMER_WINDOWS_VALIDATION || "not-run"
|
||||
};
|
||||
|
||||
const outDir = path.join(repo, "target/acceptance");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, `${mode}-environment.json`),
|
||||
`${JSON.stringify(report, null, 2)}\n`
|
||||
);
|
||||
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
414
scripts/artifact-download-smoke.js
Executable file
414
scripts/artifact-download-smoke.js
Executable file
|
|
@ -0,0 +1,414 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const project = path.join(repo, "examples/launch-build-demo");
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function runNode(addr) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-node",
|
||||
"--",
|
||||
"--coordinator",
|
||||
`${addr.host}:${addr.port}`,
|
||||
"--tenant",
|
||||
"tenant",
|
||||
"--project-id",
|
||||
"project",
|
||||
"--node",
|
||||
"node-download",
|
||||
"--process",
|
||||
"vp-download",
|
||||
"--task",
|
||||
"compile-linux",
|
||||
"--project",
|
||||
project,
|
||||
"--artifact",
|
||||
"/vfs/artifacts/download-output.txt",
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`node process failed with code ${code}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
|
||||
} catch (error) {
|
||||
reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function downloadNodeCapabilities() {
|
||||
return {
|
||||
os: "Linux",
|
||||
arch: "x86_64",
|
||||
capabilities: ["Command", "VfsArtifacts"],
|
||||
environment_backends: [],
|
||||
source_providers: ["filesystem"],
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0",
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
const report = await runNode(addr);
|
||||
assert.strictEqual(report.node_status, "completed");
|
||||
assert.strictEqual(report.status_code, 0);
|
||||
assert.strictEqual(report.large_bytes_uploaded, false);
|
||||
assert.strictEqual(report.staged_artifact.path, "/vfs/artifacts/download-output.txt");
|
||||
|
||||
const disconnectedReport = await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "node-download",
|
||||
capabilities: downloadNodeCapabilities(),
|
||||
cached_environment_digests: [],
|
||||
dependency_cache_digests: [],
|
||||
source_snapshots: [],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: false,
|
||||
online: true,
|
||||
});
|
||||
assert.strictEqual(disconnectedReport.type, "node_capabilities_recorded");
|
||||
|
||||
const disconnectedLink = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "disconnected",
|
||||
now_epoch_seconds: 10,
|
||||
ttl_seconds: 60,
|
||||
});
|
||||
assert.strictEqual(disconnectedLink.type, "error");
|
||||
assert.match(disconnectedLink.message, /direct connectivity unavailable/);
|
||||
|
||||
const connectedReport = await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "node-download",
|
||||
capabilities: downloadNodeCapabilities(),
|
||||
cached_environment_digests: [],
|
||||
dependency_cache_digests: [],
|
||||
source_snapshots: [],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: true,
|
||||
online: true,
|
||||
});
|
||||
assert.strictEqual(connectedReport.type, "node_capabilities_recorded");
|
||||
|
||||
const link = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
now_epoch_seconds: 10,
|
||||
ttl_seconds: 60,
|
||||
});
|
||||
assert.strictEqual(link.type, "artifact_download_link");
|
||||
assert.strictEqual(link.link.tenant, "tenant");
|
||||
assert.strictEqual(link.link.project, "project");
|
||||
assert.strictEqual(link.link.process, "vp-download");
|
||||
assert.deepStrictEqual(link.link.actor, { User: "user" });
|
||||
assert.match(link.link.policy_context_digest, /^sha256:[0-9a-f]{64}$/);
|
||||
assert.strictEqual(link.link.expires_at_epoch_seconds, 70);
|
||||
assert.match(link.link.url_path, /\/artifacts\/tenant\/project\/vp-download\/download-output\.txt$/);
|
||||
assert.deepStrictEqual(link.link.source, { RetainedNode: "node-download" });
|
||||
|
||||
const crossTenant = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
tenant: "other",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
now_epoch_seconds: 10,
|
||||
ttl_seconds: 60,
|
||||
});
|
||||
assert.strictEqual(crossTenant.type, "error");
|
||||
assert.match(crossTenant.message, /tenant mismatch/);
|
||||
|
||||
const crossProject = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
tenant: "tenant",
|
||||
project: "other-project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
now_epoch_seconds: 10,
|
||||
ttl_seconds: 60,
|
||||
});
|
||||
assert.strictEqual(crossProject.type, "error");
|
||||
assert.match(crossProject.message, /project mismatch/);
|
||||
|
||||
const crossTenantOpen = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
tenant: "other",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
token_digest: link.link.scoped_token_digest,
|
||||
now_epoch_seconds: 11,
|
||||
chunk_bytes: 1,
|
||||
});
|
||||
assert.strictEqual(crossTenantOpen.type, "error");
|
||||
assert.match(crossTenantOpen.message, /tenant mismatch/);
|
||||
|
||||
const crossProjectOpen = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
tenant: "tenant",
|
||||
project: "other-project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
token_digest: link.link.scoped_token_digest,
|
||||
now_epoch_seconds: 11,
|
||||
chunk_bytes: 1,
|
||||
});
|
||||
assert.strictEqual(crossProjectOpen.type, "error");
|
||||
assert.match(crossProjectOpen.message, /project mismatch/);
|
||||
|
||||
const guessed = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
token_digest: "sha256:guessed",
|
||||
now_epoch_seconds: 11,
|
||||
chunk_bytes: 1,
|
||||
});
|
||||
assert.strictEqual(guessed.type, "error");
|
||||
assert.match(guessed.message, /token is invalid/);
|
||||
|
||||
const crossActorOpen = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "other-user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
token_digest: link.link.scoped_token_digest,
|
||||
now_epoch_seconds: 11,
|
||||
chunk_bytes: 1,
|
||||
});
|
||||
assert.strictEqual(crossActorOpen.type, "error");
|
||||
assert.match(crossActorOpen.message, /token is invalid/);
|
||||
|
||||
const expired = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
token_digest: link.link.scoped_token_digest,
|
||||
now_epoch_seconds: 71,
|
||||
chunk_bytes: 1,
|
||||
});
|
||||
assert.strictEqual(expired.type, "error");
|
||||
assert.match(expired.message, /expired/);
|
||||
|
||||
await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "node-download",
|
||||
capabilities: downloadNodeCapabilities(),
|
||||
cached_environment_digests: [],
|
||||
dependency_cache_digests: [],
|
||||
source_snapshots: [],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: false,
|
||||
online: true,
|
||||
});
|
||||
const disconnectedOpen = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
token_digest: link.link.scoped_token_digest,
|
||||
now_epoch_seconds: 11,
|
||||
chunk_bytes: 1,
|
||||
});
|
||||
assert.strictEqual(disconnectedOpen.type, "error");
|
||||
assert.match(disconnectedOpen.message, /direct connectivity unavailable/);
|
||||
|
||||
await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "node-download",
|
||||
capabilities: downloadNodeCapabilities(),
|
||||
cached_environment_digests: [],
|
||||
dependency_cache_digests: [],
|
||||
source_snapshots: [],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: true,
|
||||
online: true,
|
||||
});
|
||||
|
||||
const stream = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
token_digest: link.link.scoped_token_digest,
|
||||
now_epoch_seconds: 11,
|
||||
chunk_bytes: 16,
|
||||
});
|
||||
assert.strictEqual(stream.type, "artifact_download_stream");
|
||||
assert.strictEqual(stream.streamed_bytes, 16);
|
||||
assert.strictEqual(stream.charged_download_bytes, 16);
|
||||
assert.strictEqual(stream.link.artifact, "download-output.txt");
|
||||
|
||||
const crossActorRevoke = await send(addr, {
|
||||
type: "revoke_artifact_download_link",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "other-user",
|
||||
artifact: "download-output.txt",
|
||||
token_digest: link.link.scoped_token_digest,
|
||||
});
|
||||
assert.strictEqual(crossActorRevoke.type, "error");
|
||||
assert.match(crossActorRevoke.message, /token is invalid/);
|
||||
|
||||
const revoked = await send(addr, {
|
||||
type: "revoke_artifact_download_link",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
token_digest: link.link.scoped_token_digest,
|
||||
});
|
||||
assert.strictEqual(revoked.type, "artifact_download_link_revoked");
|
||||
assert.strictEqual(revoked.link.scoped_token_digest, link.link.scoped_token_digest);
|
||||
|
||||
const revokedOpen = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "download-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "nonce",
|
||||
token_digest: link.link.scoped_token_digest,
|
||||
now_epoch_seconds: 12,
|
||||
chunk_bytes: 1,
|
||||
});
|
||||
assert.strictEqual(revokedOpen.type, "error");
|
||||
assert.match(revokedOpen.message, /revoked/);
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("Artifact download smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
244
scripts/artifact-export-smoke.js
Normal file
244
scripts/artifact-export-smoke.js
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const project = path.join(repo, "examples/launch-build-demo");
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function runProducerNode(addr) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-node",
|
||||
"--",
|
||||
"--coordinator",
|
||||
`${addr.host}:${addr.port}`,
|
||||
"--tenant",
|
||||
"tenant",
|
||||
"--project-id",
|
||||
"project",
|
||||
"--node",
|
||||
"node-export-source",
|
||||
"--process",
|
||||
"vp-export",
|
||||
"--task",
|
||||
"compile-linux",
|
||||
"--project",
|
||||
project,
|
||||
"--artifact",
|
||||
"/vfs/artifacts/export-output.txt",
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`producer node failed with code ${code}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
|
||||
} catch (error) {
|
||||
reject(new Error(`producer node output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function nodeCapabilities() {
|
||||
return {
|
||||
os: "Linux",
|
||||
arch: "x86_64",
|
||||
capabilities: ["Command", "VfsArtifacts"],
|
||||
environment_backends: [],
|
||||
source_providers: ["filesystem"],
|
||||
};
|
||||
}
|
||||
|
||||
async function reportNode(addr, node, { directConnectivity = true, online = true } = {}) {
|
||||
const response = await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node,
|
||||
capabilities: nodeCapabilities(),
|
||||
cached_environment_digests: [],
|
||||
dependency_cache_digests: [],
|
||||
source_snapshots: [],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: directConnectivity,
|
||||
online,
|
||||
});
|
||||
assert.strictEqual(response.type, "node_capabilities_recorded");
|
||||
assert.strictEqual(response.node, node);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0",
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
const produced = await runProducerNode(addr);
|
||||
assert.strictEqual(produced.node_status, "completed");
|
||||
assert.strictEqual(produced.coordinator_response.type, "task_recorded");
|
||||
assert.strictEqual(produced.staged_artifact.path, "/vfs/artifacts/export-output.txt");
|
||||
|
||||
await reportNode(addr, "node-export-source");
|
||||
|
||||
const attachedReceiver = await send(addr, {
|
||||
type: "attach_node",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "node-export-receiver",
|
||||
public_key: "node-export-receiver-public-key",
|
||||
});
|
||||
assert.strictEqual(attachedReceiver.type, "node_attached");
|
||||
await reportNode(addr, "node-export-receiver");
|
||||
|
||||
const exportPlan = await send(addr, {
|
||||
type: "export_artifact_to_node",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "export-output.txt",
|
||||
receiver_node: "node-export-receiver",
|
||||
direct_connectivity: true,
|
||||
failure_reason: "",
|
||||
});
|
||||
assert.strictEqual(exportPlan.type, "artifact_export_plan");
|
||||
assert.strictEqual(exportPlan.source_node, "node-export-source");
|
||||
assert.strictEqual(exportPlan.receiver_node, "node-export-receiver");
|
||||
assert.strictEqual(exportPlan.plan.transport, "NativeQuic");
|
||||
assert.strictEqual(exportPlan.plan.scope.tenant, "tenant");
|
||||
assert.strictEqual(exportPlan.plan.scope.project, "project");
|
||||
assert.strictEqual(exportPlan.plan.scope.process, "vp-export");
|
||||
assert.deepStrictEqual(exportPlan.plan.scope.object, { Artifact: "export-output.txt" });
|
||||
assert.strictEqual(exportPlan.plan.source.node, "node-export-source");
|
||||
assert.strictEqual(exportPlan.plan.destination.node, "node-export-receiver");
|
||||
assert.strictEqual(exportPlan.plan.coordinator_assisted_rendezvous, true);
|
||||
assert.strictEqual(exportPlan.plan.coordinator_bulk_relay_allowed, false);
|
||||
assert.match(exportPlan.plan.authorization_digest, /^sha256:[0-9a-f]{64}$/);
|
||||
|
||||
const crossTenant = await send(addr, {
|
||||
type: "export_artifact_to_node",
|
||||
tenant: "other",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "export-output.txt",
|
||||
receiver_node: "node-export-receiver",
|
||||
direct_connectivity: true,
|
||||
failure_reason: "",
|
||||
});
|
||||
assert.strictEqual(crossTenant.type, "error");
|
||||
assert.match(crossTenant.message, /tenant mismatch/);
|
||||
|
||||
const failedDirect = await send(addr, {
|
||||
type: "export_artifact_to_node",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "export-output.txt",
|
||||
receiver_node: "node-export-receiver",
|
||||
direct_connectivity: false,
|
||||
failure_reason: "nat traversal failed",
|
||||
});
|
||||
assert.strictEqual(failedDirect.type, "error");
|
||||
assert.match(failedDirect.message, /nat traversal failed/);
|
||||
assert.match(failedDirect.message, /coordinator bulk relay is disabled/);
|
||||
|
||||
await reportNode(addr, "node-export-receiver", { online: false });
|
||||
const offlineReceiver = await send(addr, {
|
||||
type: "export_artifact_to_node",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "export-output.txt",
|
||||
receiver_node: "node-export-receiver",
|
||||
direct_connectivity: true,
|
||||
failure_reason: "",
|
||||
});
|
||||
assert.strictEqual(offlineReceiver.type, "error");
|
||||
assert.match(offlineReceiver.message, /offline/);
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("Artifact export smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
263
scripts/cancellation-smoke.js
Normal file
263
scripts/cancellation-smoke.js
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function spawnNode(addr) {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-node",
|
||||
"--",
|
||||
"--coordinator",
|
||||
`${addr.host}:${addr.port}`,
|
||||
"--tenant",
|
||||
"tenant",
|
||||
"--project-id",
|
||||
"project",
|
||||
"--node",
|
||||
"node-cancel",
|
||||
"--process",
|
||||
"vp-cancel",
|
||||
"--task",
|
||||
"compile-linux",
|
||||
"--artifact",
|
||||
"/vfs/artifacts/cancelled-output.txt",
|
||||
"--emit-ready",
|
||||
"--control-poll-ms",
|
||||
"5000",
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
|
||||
let buffer = "";
|
||||
let rawStdout = "";
|
||||
let stderr = "";
|
||||
let exitCode = null;
|
||||
let exitSignal = null;
|
||||
const messages = [];
|
||||
const waiters = [];
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
rawStdout += chunk.toString();
|
||||
buffer += chunk.toString();
|
||||
while (true) {
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (!line) continue;
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(line);
|
||||
} catch (error) {
|
||||
rejectWaiters(new Error(`node emitted non-JSON line: ${line}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
messages.push(message);
|
||||
flush();
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
exitCode = code;
|
||||
exitSignal = signal;
|
||||
flush();
|
||||
rejectWaiters(
|
||||
new Error(
|
||||
`node process exited before expected message with code ${code} signal ${signal}\nstdout:\n${rawStdout}\nstderr:\n${stderr}`
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
function flush() {
|
||||
for (const waiter of [...waiters]) {
|
||||
const message = messages.find(waiter.predicate);
|
||||
if (!message) continue;
|
||||
clearTimeout(waiter.timer);
|
||||
waiters.splice(waiters.indexOf(waiter), 1);
|
||||
waiter.resolve(message);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForMessage(predicate, timeoutMs = 120000) {
|
||||
const existing = messages.find(predicate);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
if (exitCode !== null) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
`node process already exited with code ${exitCode} signal ${exitSignal}\nstdout:\n${rawStdout}\nstderr:\n${stderr}`
|
||||
)
|
||||
);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`timed out waiting for node message\n${stderr}`));
|
||||
}, timeoutMs);
|
||||
waiters.push({ predicate, resolve, reject, timer });
|
||||
});
|
||||
}
|
||||
|
||||
function rejectWaiters(error) {
|
||||
for (const waiter of [...waiters]) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiters.splice(waiters.indexOf(waiter), 1);
|
||||
waiter.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
function waitForExit() {
|
||||
if (child.exitCode !== null) {
|
||||
if (child.exitCode === 0) return Promise.resolve();
|
||||
return Promise.reject(new Error(`node process failed with code ${child.exitCode}\n${stderr}`));
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`node process failed with code ${code}\n${stderr}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return { child, waitForMessage, waitForExit };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0",
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
|
||||
let node;
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
node = spawnNode(addr);
|
||||
const nodeReady = await node.waitForMessage((message) => message.node_status === "ready");
|
||||
assert.strictEqual(nodeReady.process, "vp-cancel");
|
||||
assert.strictEqual(nodeReady.task, "compile-linux");
|
||||
|
||||
const cancel = await send(addr, {
|
||||
type: "cancel_task",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-cancel",
|
||||
node: "node-cancel",
|
||||
task: "compile-linux",
|
||||
});
|
||||
assert.strictEqual(cancel.type, "task_cancellation_requested");
|
||||
assert.strictEqual(cancel.process, "vp-cancel");
|
||||
assert.strictEqual(cancel.task, "compile-linux");
|
||||
assert.strictEqual(cancel.node, "node-cancel");
|
||||
|
||||
const report = await node.waitForMessage((message) => message.node_status === "cancelled");
|
||||
assert.strictEqual(report.terminal_state, "cancelled");
|
||||
assert.strictEqual(report.status_code, null);
|
||||
assert.strictEqual(report.large_bytes_uploaded, false);
|
||||
assert.strictEqual(report.coordinator_response.type, "task_recorded");
|
||||
await node.waitForExit();
|
||||
|
||||
const events = await send(addr, {
|
||||
type: "list_task_events",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
process: "vp-cancel"
|
||||
});
|
||||
assert.strictEqual(events.type, "task_events");
|
||||
assert.strictEqual(events.events.length, 1);
|
||||
assert.strictEqual(events.events[0].node, "node-cancel");
|
||||
assert.strictEqual(events.events[0].process, "vp-cancel");
|
||||
assert.strictEqual(events.events[0].task, "compile-linux");
|
||||
assert.strictEqual(events.events[0].terminal_state, "cancelled");
|
||||
assert.strictEqual(events.events[0].status_code, null);
|
||||
|
||||
const control = await send(addr, {
|
||||
type: "poll_task_control",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-cancel",
|
||||
node: "node-cancel",
|
||||
task: "compile-linux",
|
||||
});
|
||||
assert.strictEqual(control.type, "task_control");
|
||||
assert.strictEqual(control.cancel_requested, false);
|
||||
} finally {
|
||||
if (node && node.child.exitCode === null) node.child.kill("SIGKILL");
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("Cancellation smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
206
scripts/cli-browser-login-flow-smoke.js
Normal file
206
scripts/cli-browser-login-flow-smoke.js
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const tmp = path.join(repo, "target", "acceptance", "tmp", "cli-browser-login-flow");
|
||||
fs.mkdirSync(tmp, { recursive: true });
|
||||
|
||||
function writeOpener() {
|
||||
const opener = path.join(tmp, "browser-opener.js");
|
||||
const trace = path.join(tmp, "browser-opener.log");
|
||||
fs.writeFileSync(
|
||||
opener,
|
||||
`#!/usr/bin/env node
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
|
||||
const trace = ${JSON.stringify(trace)};
|
||||
fs.appendFileSync(trace, "started " + process.argv.slice(2).join(" ") + "\\n");
|
||||
const loginUrl = new URL(process.argv[2]);
|
||||
const state = loginUrl.searchParams.get("state");
|
||||
const redirect = new URL(loginUrl.searchParams.get("redirect_uri"));
|
||||
if (!state || !redirect) {
|
||||
fs.appendFileSync(trace, "missing state or redirect\\n");
|
||||
console.error("browser opener did not receive state and redirect_uri");
|
||||
process.exit(1);
|
||||
}
|
||||
redirect.searchParams.set("code", "browser-smoke-code");
|
||||
redirect.searchParams.set("state", state);
|
||||
fs.appendFileSync(trace, "callback " + redirect.toString() + "\\n");
|
||||
|
||||
http.get(redirect, (response) => {
|
||||
response.resume();
|
||||
response.on("end", () => {
|
||||
fs.appendFileSync(trace, "status " + response.statusCode + "\\n");
|
||||
process.exit(response.statusCode === 200 ? 0 : 1);
|
||||
});
|
||||
}).on("error", (error) => {
|
||||
fs.appendFileSync(trace, "error " + (error.stack || error.message) + "\\n");
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
`
|
||||
);
|
||||
fs.chmodSync(opener, 0o755);
|
||||
return opener;
|
||||
}
|
||||
|
||||
function listen(server, host = "127.0.0.1") {
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, host, () => {
|
||||
server.off("error", reject);
|
||||
resolve(server.address());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startCoordinator() {
|
||||
const requests = [];
|
||||
const server = net.createServer((socket) => {
|
||||
let buffered = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffered += chunk.toString("utf8");
|
||||
if (!buffered.includes("\n")) return;
|
||||
const [line] = buffered.split(/\r?\n/);
|
||||
const request = JSON.parse(line);
|
||||
requests.push(request);
|
||||
assert.strictEqual(request.type, "oidc_browser_login");
|
||||
assert.strictEqual(request.authorization_code, "browser-smoke-code");
|
||||
assert.strictEqual(request.issuer_url, "http://127.0.0.1:1");
|
||||
assert.strictEqual(request.client_id, "disasmer-smoke");
|
||||
assert.match(request.redirect_path, /^http:\/\/127\.0\.0\.1:45173\/callback$/);
|
||||
assert.match(request.state, /^sha256:[a-f0-9]{64}$/);
|
||||
socket.end(
|
||||
JSON.stringify({
|
||||
type: "oidc_browser_session",
|
||||
session: {
|
||||
tenant: request.tenant,
|
||||
project: request.project,
|
||||
user: request.user,
|
||||
browser_credential_kind: "BrowserSession",
|
||||
cli_session_credential_kind: "CliDeviceSession",
|
||||
provider_tokens_sent_to_nodes: false,
|
||||
flow: {
|
||||
authorization_url: "http://127.0.0.1:1/application/o/authorize/",
|
||||
callback_path: request.redirect_path,
|
||||
state: request.state,
|
||||
},
|
||||
oidc_token_exchange: {
|
||||
token_endpoint: "http://127.0.0.1:1/application/o/token/",
|
||||
token_type: "Bearer",
|
||||
received_access_token: true,
|
||||
received_id_token: true,
|
||||
retained_provider_tokens: false,
|
||||
},
|
||||
},
|
||||
}) + "\n"
|
||||
);
|
||||
server.close();
|
||||
});
|
||||
});
|
||||
const address = await listen(server);
|
||||
return {
|
||||
url: `${address.address}:${address.port}`,
|
||||
requests,
|
||||
close: () => new Promise((resolve) => server.close(() => resolve())),
|
||||
};
|
||||
}
|
||||
|
||||
function runDisasmer(args, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = cp.spawn("cargo", args, {
|
||||
cwd: repo,
|
||||
env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const timeout = setTimeout(() => {
|
||||
child.kill();
|
||||
reject(new Error(`disasmer command timed out\n${stderr}`));
|
||||
}, 30000);
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString("utf8");
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString("utf8");
|
||||
});
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code, signal) => {
|
||||
clearTimeout(timeout);
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new Error(
|
||||
`disasmer command failed with ${signal || code}\nSTDERR:\n${stderr}\nSTDOUT:\n${stdout}`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const opener = writeOpener();
|
||||
const coordinator = await startCoordinator();
|
||||
try {
|
||||
const stdout = await runDisasmer(
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-cli",
|
||||
"--bin",
|
||||
"disasmer",
|
||||
"--",
|
||||
"login",
|
||||
"--browser",
|
||||
"--json",
|
||||
"--coordinator",
|
||||
coordinator.url,
|
||||
"--oidc-issuer-url",
|
||||
"http://127.0.0.1:1",
|
||||
"--oidc-client-id",
|
||||
"disasmer-smoke",
|
||||
"--tenant",
|
||||
"tenant-smoke",
|
||||
"--project-id",
|
||||
"project-smoke",
|
||||
"--user",
|
||||
"user-smoke",
|
||||
],
|
||||
{
|
||||
...process.env,
|
||||
DISASMER_BROWSER_OPEN_COMMAND: opener,
|
||||
DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS: "5",
|
||||
}
|
||||
);
|
||||
const report = JSON.parse(stdout);
|
||||
assert.strictEqual(report.plan.coordinator, coordinator.url);
|
||||
assert.strictEqual(report.boundary.cli_contacted_coordinator, true);
|
||||
assert.strictEqual(report.boundary.scoped_cli_session_received, true);
|
||||
assert.strictEqual(report.boundary.provider_tokens_exposed_to_cli, false);
|
||||
assert.strictEqual(report.boundary.provider_tokens_sent_to_nodes, false);
|
||||
assert.strictEqual(report.boundary.coordinator_session_requests, 1);
|
||||
assert.strictEqual(coordinator.requests.length, 1);
|
||||
} finally {
|
||||
if (coordinator.requests.length === 0) {
|
||||
await coordinator.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
console.log("CLI browser login flow smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
58
scripts/cli-install-smoke.js
Executable file
58
scripts/cli-install-smoke.js
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const temp = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-install-"));
|
||||
const installRoot = path.join(temp, "install");
|
||||
const targetDir = path.join(temp, "target");
|
||||
const project = path.join(repo, "examples/launch-build-demo");
|
||||
const binName = process.platform === "win32" ? "disasmer.exe" : "disasmer";
|
||||
const installedBin = path.join(installRoot, "bin", binName);
|
||||
|
||||
try {
|
||||
cp.execFileSync(
|
||||
"cargo",
|
||||
[
|
||||
"install",
|
||||
"--path",
|
||||
"crates/disasmer-cli",
|
||||
"--bin",
|
||||
"disasmer",
|
||||
"--root",
|
||||
installRoot,
|
||||
"--debug"
|
||||
],
|
||||
{
|
||||
cwd: repo,
|
||||
env: {
|
||||
...process.env,
|
||||
CARGO_TARGET_DIR: targetDir
|
||||
},
|
||||
stdio: "inherit"
|
||||
}
|
||||
);
|
||||
|
||||
assert(fs.existsSync(installedBin), "installed disasmer binary must exist");
|
||||
|
||||
const inspection = JSON.parse(
|
||||
cp.execFileSync(
|
||||
installedBin,
|
||||
["bundle", "inspect", "--project", project],
|
||||
{ cwd: repo, encoding: "utf8" }
|
||||
)
|
||||
);
|
||||
|
||||
assert.strictEqual(inspection.project, project);
|
||||
assert.strictEqual(inspection.metadata.embeds_full_container_images, false);
|
||||
assert(inspection.metadata.environments.some((env) => env.name === "linux"));
|
||||
assert(inspection.metadata.selected_inputs.some((input) => input.path === "src/build.rs"));
|
||||
} finally {
|
||||
fs.rmSync(temp, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log("CLI install smoke passed");
|
||||
202
scripts/cli-local-run-smoke.js
Executable file
202
scripts/cli-local-run-smoke.js
Executable file
|
|
@ -0,0 +1,202 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const crypto = require("crypto");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const project = path.join(repo, "examples/launch-build-demo");
|
||||
const agentPublicKey = "agent-cli-smoke-public-key";
|
||||
|
||||
function sha256(value) {
|
||||
return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`;
|
||||
}
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function runCli(args, env = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args],
|
||||
{
|
||||
cwd: repo,
|
||||
env: {
|
||||
...process.env,
|
||||
...env
|
||||
}
|
||||
}
|
||||
);
|
||||
const cliPid = child.pid;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`CLI run failed with code ${code}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve({ pid: cliPid, report: JSON.parse(stdout) });
|
||||
} catch (error) {
|
||||
reject(new Error(`CLI output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
assert(Number.isInteger(coordinator.pid));
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
const { pid: cliPid, report } = await runCli(
|
||||
["run", "--coordinator", `${addr.host}:${addr.port}`, "--project", project],
|
||||
{ DISASMER_AGENT_PUBLIC_KEY: agentPublicKey }
|
||||
);
|
||||
assert(Number.isInteger(cliPid));
|
||||
assert.notStrictEqual(cliPid, coordinator.pid);
|
||||
assert.strictEqual(report.plan.entry, "build");
|
||||
assert.deepStrictEqual(report.plan.session, {
|
||||
AgentPublicKey: {
|
||||
public_key_fingerprint: sha256(agentPublicKey),
|
||||
browser_interaction_required: false
|
||||
}
|
||||
});
|
||||
assert.strictEqual(report.boundary.cli_process_started_node_process, true);
|
||||
assert.strictEqual(report.boundary.cli_process_started_coordinator_process, false);
|
||||
assert(Number.isInteger(report.boundary.spawned_node_process_id));
|
||||
assert.notStrictEqual(report.boundary.spawned_node_process_id, cliPid);
|
||||
assert.notStrictEqual(report.boundary.spawned_node_process_id, coordinator.pid);
|
||||
assert.strictEqual(report.boundary.node_session_requests, 10);
|
||||
assert.strictEqual(report.node_report.node_status, "completed");
|
||||
assert.strictEqual(report.node_report.status_code, 0);
|
||||
assert.strictEqual(report.node_report.large_bytes_uploaded, false);
|
||||
assert.strictEqual(report.node_report.capability_response.type, "node_capabilities_recorded");
|
||||
assert.strictEqual(report.node_report.task_assignment_response.type, "task_placement");
|
||||
assert.strictEqual(report.node_report.debug_command_response.type, "debug_command");
|
||||
assert.strictEqual(report.node_report.log_event_response.type, "task_log_recorded");
|
||||
assert.strictEqual(report.node_report.vfs_metadata_response.type, "vfs_metadata_recorded");
|
||||
assert.strictEqual(report.node_report.staged_artifact.path, "/vfs/artifacts/cli-run-output.txt");
|
||||
|
||||
const events = await send(addr, {
|
||||
type: "list_task_events",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
process: "vp-cli-local"
|
||||
});
|
||||
assert.strictEqual(events.type, "task_events");
|
||||
assert.strictEqual(events.events.length, 1);
|
||||
assert.strictEqual(events.events[0].node, "node-cli-local");
|
||||
assert.strictEqual(events.events[0].process, "vp-cli-local");
|
||||
assert.strictEqual(events.events[0].task, "compile-linux");
|
||||
assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/cli-run-output.txt");
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
const { pid: autoCliPid, report: autoReport } = await runCli([
|
||||
"run",
|
||||
"--local",
|
||||
"--project",
|
||||
project
|
||||
]);
|
||||
assert(Number.isInteger(autoCliPid));
|
||||
assert.strictEqual(autoReport.plan.entry, "build");
|
||||
assert.deepStrictEqual(autoReport.plan.coordinator, "LocalOnly");
|
||||
assert.deepStrictEqual(autoReport.plan.session, "Anonymous");
|
||||
assert.strictEqual(autoReport.boundary.cli_process_started_node_process, true);
|
||||
assert.strictEqual(autoReport.boundary.cli_process_started_coordinator_process, true);
|
||||
assert.match(autoReport.boundary.coordinator_address, /^127\.0\.0\.1:\d+$/);
|
||||
assert(Number.isInteger(autoReport.boundary.coordinator_process_id));
|
||||
assert(Number.isInteger(autoReport.boundary.spawned_node_process_id));
|
||||
assert.notStrictEqual(autoReport.boundary.coordinator_process_id, autoCliPid);
|
||||
assert.notStrictEqual(autoReport.boundary.spawned_node_process_id, autoCliPid);
|
||||
assert.notStrictEqual(
|
||||
autoReport.boundary.spawned_node_process_id,
|
||||
autoReport.boundary.coordinator_process_id
|
||||
);
|
||||
assert.strictEqual(autoReport.boundary.node_session_requests, 10);
|
||||
assert.strictEqual(autoReport.node_report.node_status, "completed");
|
||||
assert.strictEqual(autoReport.node_report.status_code, 0);
|
||||
assert.strictEqual(autoReport.node_report.large_bytes_uploaded, false);
|
||||
assert.strictEqual(autoReport.node_report.capability_response.type, "node_capabilities_recorded");
|
||||
assert.strictEqual(autoReport.node_report.task_assignment_response.type, "task_placement");
|
||||
assert.strictEqual(autoReport.node_report.debug_command_response.type, "debug_command");
|
||||
assert.strictEqual(autoReport.node_report.log_event_response.type, "task_log_recorded");
|
||||
assert.strictEqual(autoReport.node_report.vfs_metadata_response.type, "vfs_metadata_recorded");
|
||||
assert.strictEqual(
|
||||
autoReport.node_report.staged_artifact.path,
|
||||
"/vfs/artifacts/cli-run-output.txt"
|
||||
);
|
||||
|
||||
console.log("CLI local run smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
47
scripts/cli-login-smoke.js
Normal file
47
scripts/cli-login-smoke.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const coordinator = "https://coord.example.test";
|
||||
const defaultOperatorEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||
|
||||
function disasmer(args) {
|
||||
return JSON.parse(
|
||||
cp.execFileSync(
|
||||
"cargo",
|
||||
["run", "-q", "-p", "disasmer-cli", "--bin", "disasmer", "--", ...args],
|
||||
{ cwd: repo, encoding: "utf8" }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const device = disasmer(["login", "--coordinator", coordinator]);
|
||||
assert.strictEqual(device.coordinator, coordinator);
|
||||
assert(device.human_flow.Device, "default human login should use device flow");
|
||||
assert.strictEqual(device.human_flow.Device.verification_url, `${coordinator}/auth/device`);
|
||||
assert.match(device.human_flow.Device.user_code, /^DISASMER-[A-F0-9]{4}-[A-F0-9]{4}$/);
|
||||
assert.match(device.human_flow.Device.device_code, /^sha256:[a-f0-9]{64}$/);
|
||||
assert.strictEqual(device.human_flow.Device.expires_in_seconds, 900);
|
||||
assert.strictEqual(device.human_flow.Device.yields_long_lived_secret_directly, false);
|
||||
|
||||
const defaultDevice = disasmer(["login"]);
|
||||
assert.strictEqual(defaultDevice.coordinator, defaultOperatorEndpoint);
|
||||
assert.strictEqual(
|
||||
defaultDevice.human_flow.Device.verification_url,
|
||||
`${defaultOperatorEndpoint}/auth/device`
|
||||
);
|
||||
|
||||
const browser = disasmer(["login", "--browser", "--plan", "--coordinator", coordinator]);
|
||||
assert.strictEqual(browser.coordinator, coordinator);
|
||||
assert(browser.human_flow.Browser, "browser login should be available for human users");
|
||||
assert.match(
|
||||
browser.human_flow.Browser.authorization_url,
|
||||
new RegExp(`^${coordinator.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/auth/browser/start\\?`)
|
||||
);
|
||||
assert.match(browser.human_flow.Browser.callback_path, /^http:\/\/127\.0\.0\.1:\d+\/callback$/);
|
||||
assert.match(browser.human_flow.Browser.state, /^sha256:[a-f0-9]{64}$/);
|
||||
|
||||
console.log("CLI login smoke passed");
|
||||
948
scripts/dap-smoke.js
Executable file
948
scripts/dap-smoke.js
Executable file
|
|
@ -0,0 +1,948 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const cp = require("child_process");
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
class DapClient {
|
||||
constructor() {
|
||||
this.child = cp.spawn(
|
||||
"cargo",
|
||||
["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"],
|
||||
{ cwd: process.cwd() }
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
async failure(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} unexpectedly succeeded`);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
waitFor(predicate, timeoutMs = 120000) {
|
||||
const existing = this.messages.find(predicate);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.child.kill("SIGKILL");
|
||||
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() {
|
||||
const seq = this.send("disconnect");
|
||||
await this.response(seq, "disconnect");
|
||||
this.child.stdin.end();
|
||||
}
|
||||
}
|
||||
|
||||
function createFailingProject() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-dap-failing-"));
|
||||
fs.mkdirSync(path.join(root, "src"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(root, "Cargo.toml"),
|
||||
`[package]
|
||||
name = "dap-failing-demo"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(root, "src/lib.rs"),
|
||||
`#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn fails_for_restart_smoke() {
|
||||
assert_eq!(1, 2);
|
||||
}
|
||||
}
|
||||
`
|
||||
);
|
||||
return root;
|
||||
}
|
||||
|
||||
function waitForJsonLine(child, description) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(new Error(`${description} did not emit JSON: ${buffer}\n${error.stack || error.message}`));
|
||||
}
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.once("exit", (code, signal) => {
|
||||
reject(new Error(`${description} exited before JSON line with code ${code} signal ${signal}\n${stderr}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function startCoordinator() {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0"
|
||||
],
|
||||
{ cwd: process.cwd() }
|
||||
);
|
||||
const ready = await waitForJsonLine(child, "coordinator");
|
||||
return { child, listen: ready.listen };
|
||||
}
|
||||
|
||||
async function startExplicitWorker(listen) {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-node",
|
||||
"--",
|
||||
"--coordinator",
|
||||
listen,
|
||||
"--tenant",
|
||||
"tenant",
|
||||
"--project-id",
|
||||
"project",
|
||||
"--node",
|
||||
"dap-live-worker",
|
||||
"--worker",
|
||||
"--assignment-poll-ms",
|
||||
"50",
|
||||
"--emit-ready"
|
||||
],
|
||||
{ cwd: process.cwd() }
|
||||
);
|
||||
const ready = await waitForJsonLine(child, "explicit worker");
|
||||
assert.strictEqual(ready.node_status, "ready");
|
||||
assert.strictEqual(ready.mode, "worker");
|
||||
assert.strictEqual(ready.node, "dap-live-worker");
|
||||
return child;
|
||||
}
|
||||
|
||||
function killChild(child) {
|
||||
if (child && child.exitCode === null) {
|
||||
child.kill("SIGTERM");
|
||||
}
|
||||
}
|
||||
|
||||
async function launchToBreakpoint({
|
||||
runtimeBackend = "simulated",
|
||||
breakpointLine,
|
||||
breakpointLines,
|
||||
project = path.join(process.cwd(), "examples/launch-build-demo"),
|
||||
operatorEndpoint,
|
||||
tenant = "tenant",
|
||||
projectId = "project",
|
||||
actorUser = "dap"
|
||||
}) {
|
||||
const client = new DapClient();
|
||||
|
||||
const initialize = client.send("initialize", {
|
||||
adapterID: "disasmer",
|
||||
linesStartAt1: true,
|
||||
columnsStartAt1: true
|
||||
});
|
||||
await client.response(initialize, "initialize");
|
||||
|
||||
const launchArgs = {
|
||||
entry: "build",
|
||||
project,
|
||||
runtimeBackend,
|
||||
tenant,
|
||||
projectId,
|
||||
actorUser
|
||||
};
|
||||
if (operatorEndpoint) {
|
||||
launchArgs.operatorEndpoint = operatorEndpoint;
|
||||
}
|
||||
const launch = client.send("launch", launchArgs);
|
||||
await client.response(launch, "launch");
|
||||
await client.waitFor((message) => message.type === "event" && message.event === "initialized");
|
||||
|
||||
const breakpoints = client.send("setBreakpoints", {
|
||||
source: { path: path.join(project, "src/build.rs") },
|
||||
breakpoints: (breakpointLines || [breakpointLine]).map((line) => ({ line }))
|
||||
});
|
||||
const breakpointResponse = await client.response(breakpoints, "setBreakpoints");
|
||||
assert.deepStrictEqual(
|
||||
breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.verified),
|
||||
(breakpointLines || [breakpointLine]).map(() => true)
|
||||
);
|
||||
|
||||
const exceptions = client.send("setExceptionBreakpoints", { filters: [] });
|
||||
await client.response(exceptions, "setExceptionBreakpoints");
|
||||
|
||||
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.allThreadsStopped, true);
|
||||
assert.strictEqual(stopped.body.reason, "breakpoint");
|
||||
|
||||
return { client, stopped };
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const launchProject = path.join(process.cwd(), "examples/launch-build-demo");
|
||||
const launchSource = fs.realpathSync(path.join(launchProject, "src/build.rs"));
|
||||
const dapSource = fs.readFileSync(
|
||||
path.join(process.cwd(), "crates/disasmer-dap/src/main.rs"),
|
||||
"utf8"
|
||||
);
|
||||
const liveStart = dapSource.indexOf("fn run_live_services_runtime");
|
||||
const liveEnd = dapSource.indexOf("fn run_with_coordinator", liveStart);
|
||||
const liveRuntimeSource = dapSource.slice(liveStart, liveEnd);
|
||||
assert.doesNotMatch(liveRuntimeSource, /run_node_against_coordinator|Command::new|project_binary/);
|
||||
|
||||
const { client, stopped } = await launchToBreakpoint({
|
||||
runtimeBackend: "local-services",
|
||||
project: launchProject,
|
||||
breakpointLine: 22
|
||||
});
|
||||
assert.strictEqual(stopped.body.threadId, 2);
|
||||
|
||||
const threadsRequest = client.send("threads");
|
||||
const threads = (await client.response(threadsRequest, "threads")).body.threads;
|
||||
assert.deepStrictEqual(
|
||||
threads.map((thread) => thread.id),
|
||||
[1, 2, 3, 4]
|
||||
);
|
||||
assert(threads.some((thread) => thread.name.includes("compile linux")));
|
||||
assert(threads.some((thread) => thread.name.includes("compile windows")));
|
||||
assert(threads.some((thread) => thread.name.includes("package artifacts")));
|
||||
|
||||
const stackRequest = client.send("stackTrace", { threadId: 2, startFrame: 0, levels: 1 });
|
||||
const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames;
|
||||
assert.strictEqual(stack.length, 1);
|
||||
assert.match(stack[0].name, /compile linux::run/);
|
||||
assert.strictEqual(stack[0].line, 22);
|
||||
assert.strictEqual(stack[0].source.path, launchSource);
|
||||
assert.strictEqual(stack[0].source.sourceReference || 0, 0);
|
||||
assert.doesNotMatch(stack[0].name, /podman|cmd\.exe|powershell|pid|native child/i);
|
||||
|
||||
const sourceRequest = client.send("source", { source: stack[0].source });
|
||||
const source = (await client.response(sourceRequest, "source")).body;
|
||||
assert.match(source.content, /compile_linux/);
|
||||
assert.match(source.mimeType, /rust/);
|
||||
|
||||
const nextRequest = client.send("next", { threadId: 2 });
|
||||
await client.response(nextRequest, "next");
|
||||
const stepped = await client.waitFor(
|
||||
(message) => message.type === "event" && message.event === "stopped" && message.body.reason === "step"
|
||||
);
|
||||
assert.strictEqual(stepped.body.threadId, 2);
|
||||
assert.strictEqual(stepped.body.allThreadsStopped, true);
|
||||
|
||||
const steppedStackRequest = client.send("stackTrace", { threadId: 2, startFrame: 0, levels: 1 });
|
||||
const steppedStack = (await client.response(steppedStackRequest, "stackTrace")).body.stackFrames;
|
||||
assert.strictEqual(steppedStack[0].line, 23);
|
||||
const steppedSourceRequest = client.send("source", { source: steppedStack[0].source });
|
||||
const steppedSource = (await client.response(steppedSourceRequest, "source")).body;
|
||||
assert.match(steppedSource.content, /compile_linux/);
|
||||
|
||||
const scopesRequest = client.send("scopes", { frameId: steppedStack[0].id });
|
||||
const scopes = (await client.response(scopesRequest, "scopes")).body.scopes;
|
||||
const localsRef = scopes.find((scope) => scope.name === "Source Locals").variablesReference;
|
||||
const argsRef = scopes.find((scope) => scope.name === "Task Args and Handles").variablesReference;
|
||||
const runtimeRef = scopes.find((scope) => scope.name === "Disasmer Runtime").variablesReference;
|
||||
const outputRef = scopes.find((scope) => scope.name === "Recent Output").variablesReference;
|
||||
|
||||
const localsRequest = client.send("variables", { variablesReference: localsRef });
|
||||
const locals = (await client.response(localsRequest, "variables")).body.variables;
|
||||
assert(
|
||||
locals.some(
|
||||
(variable) =>
|
||||
variable.name === "unavailable-local-diagnostic" &&
|
||||
String(variable.value).includes("cannot be inspected")
|
||||
)
|
||||
);
|
||||
|
||||
const argsRequest = client.send("variables", { variablesReference: argsRef });
|
||||
const args = (await client.response(argsRequest, "variables")).body.variables;
|
||||
const target = args.find((variable) => variable.name === "target");
|
||||
const artifact = args.find((variable) => variable.name === "artifact");
|
||||
const sourceSnapshot = args.find((variable) => variable.name === "source_snapshot");
|
||||
const returnValue = args.find((variable) => variable.name === "return_value");
|
||||
const blob = args.find((variable) => variable.name === "blob");
|
||||
const vfsMounts = args.find((variable) => variable.name === "vfs_mounts");
|
||||
assert(target);
|
||||
assert(target.variablesReference > 0);
|
||||
assert(artifact);
|
||||
assert.strictEqual(artifact.value, 'Artifact { id = "/vfs/artifacts/dap-output.txt" }');
|
||||
assert(sourceSnapshot);
|
||||
assert.match(sourceSnapshot.value, /^SourceSnapshot \{ digest = "source:\/\/local-checkout\/[a-f0-9]{12}" \}$/);
|
||||
assert(returnValue);
|
||||
assert.match(returnValue.value, /Artifact/);
|
||||
assert(blob);
|
||||
assert.match(blob.value, /^Blob \{ digest = "sha256:[a-f0-9]{64}" \}$/);
|
||||
assert(vfsMounts);
|
||||
assert(vfsMounts.variablesReference > 0);
|
||||
|
||||
const targetRequest = client.send("variables", { variablesReference: target.variablesReference });
|
||||
const targetFields = (await client.response(targetRequest, "variables")).body.variables;
|
||||
assert(targetFields.some((variable) => variable.name === "environment" && variable.value === "linux"));
|
||||
assert(
|
||||
targetFields.some(
|
||||
(variable) => variable.name === "required_capability" && variable.value === "Command"
|
||||
)
|
||||
);
|
||||
|
||||
const vfsRequest = client.send("variables", { variablesReference: vfsMounts.variablesReference });
|
||||
const vfs = (await client.response(vfsRequest, "variables")).body.variables;
|
||||
assert(vfs.some((variable) => variable.name === "/vfs/artifacts"));
|
||||
assert(vfs.some((variable) => variable.name === "/vfs/sources"));
|
||||
assert(vfs.some((variable) => variable.name === "/vfs/blobs"));
|
||||
|
||||
const runtimeRequest = client.send("variables", { variablesReference: runtimeRef });
|
||||
const runtime = (await client.response(runtimeRequest, "variables")).body.variables;
|
||||
const processId = runtime.find((variable) => variable.name === "virtual_process_id");
|
||||
const commandSpec = runtime.find((variable) => variable.name === "command_spec");
|
||||
assert(processId);
|
||||
assert.match(processId.value, /^vp-[a-f0-9]{12}$/);
|
||||
assert(runtime.some((variable) => variable.name === "debug_epoch" && variable.value === 2));
|
||||
assert(runtime.some((variable) => variable.name === "runtime_backend" && variable.value === "LocalServices"));
|
||||
assert(runtime.some((variable) => variable.name === "coordinator_task_events" && variable.value === 1));
|
||||
assert(runtime.some((variable) => variable.name === "command_status" && String(variable.value).includes("completed through local services")));
|
||||
assert(commandSpec);
|
||||
assert(commandSpec.variablesReference > 0);
|
||||
assert(runtime.some((variable) => variable.name === "stdout_tail"));
|
||||
assert(runtime.some((variable) => variable.name === "stderr_tail"));
|
||||
|
||||
const commandRequest = client.send("variables", {
|
||||
variablesReference: commandSpec.variablesReference
|
||||
});
|
||||
const commandVariables = (await client.response(commandRequest, "variables")).body.variables;
|
||||
assert(commandVariables.some((variable) => variable.name === "program" && variable.value === "cargo"));
|
||||
assert(
|
||||
commandVariables.some(
|
||||
(variable) => variable.name === "required_capability" && variable.value === "Command"
|
||||
)
|
||||
);
|
||||
|
||||
const outputRequest = client.send("variables", { variablesReference: outputRef });
|
||||
const output = (await client.response(outputRequest, "variables")).body.variables;
|
||||
assert(output.some((variable) => variable.name === "stdout_tail"));
|
||||
assert(output.some((variable) => variable.name === "stderr_tail"));
|
||||
assert(output.some((variable) => String(variable.value).includes("all-stop")));
|
||||
assert(output.some((variable) => String(variable.value).includes("attached node completed task")));
|
||||
assert(output.some((variable) => String(variable.value).includes("coordinator recorded 1 task event")));
|
||||
|
||||
const continueRequest = client.send("continue", { threadId: 2 });
|
||||
await client.response(continueRequest, "continue");
|
||||
await client.waitFor((message) => message.type === "event" && message.event === "continued");
|
||||
|
||||
const pauseRequest = client.send("pause", { threadId: 2 });
|
||||
await client.response(pauseRequest, "pause");
|
||||
const paused = await client.waitFor(
|
||||
(message) => message.type === "event" && message.event === "stopped" && message.body.reason === "pause"
|
||||
);
|
||||
assert.strictEqual(paused.body.allThreadsStopped, true);
|
||||
|
||||
const restartRequest = client.send("restartFrame", { frameId: stack[0].id });
|
||||
await client.response(restartRequest, "restartFrame");
|
||||
await client.waitFor(
|
||||
(message) =>
|
||||
message.type === "event" &&
|
||||
message.event === "output" &&
|
||||
String(message.body.output).includes("Restarted selected task")
|
||||
);
|
||||
|
||||
const incompatibleRestartRequest = client.send("restartFrame", {
|
||||
frameId: stack[0].id,
|
||||
sourceCompatibility: "incompatible"
|
||||
});
|
||||
const incompatibleRestart = await client.failure(incompatibleRestartRequest, "restartFrame");
|
||||
assert.match(incompatibleRestart.message, /incompatible source edit/i);
|
||||
assert.match(incompatibleRestart.message, /whole virtual-process restart/i);
|
||||
|
||||
const continueAfterRestartRequest = client.send("continue", { threadId: 2 });
|
||||
await client.response(continueAfterRestartRequest, "continue");
|
||||
await client.waitFor((message) => message.type === "event" && message.event === "continued");
|
||||
|
||||
const freezeFailureRequest = client.send("pause", {
|
||||
threadId: 2,
|
||||
simulateFreezeFailure: true
|
||||
});
|
||||
const freezeFailure = await client.failure(freezeFailureRequest, "pause");
|
||||
assert.match(freezeFailure.message, /all-stop failed/i);
|
||||
assert.match(freezeFailure.message, /could not freeze/i);
|
||||
|
||||
await client.close();
|
||||
|
||||
const attachClient = new DapClient();
|
||||
try {
|
||||
const attachInitialize = attachClient.send("initialize", {
|
||||
adapterID: "disasmer",
|
||||
linesStartAt1: true,
|
||||
columnsStartAt1: true
|
||||
});
|
||||
await attachClient.response(attachInitialize, "initialize");
|
||||
|
||||
const attachRequest = attachClient.send("attach", {
|
||||
entry: "build",
|
||||
project: launchProject,
|
||||
runtimeBackend: "live-services",
|
||||
operatorEndpoint: "127.0.0.1:1"
|
||||
});
|
||||
await attachClient.response(attachRequest, "attach");
|
||||
await attachClient.waitFor(
|
||||
(message) => message.type === "event" && message.event === "initialized"
|
||||
);
|
||||
|
||||
const attachBreakpoints = attachClient.send("setBreakpoints", {
|
||||
source: { path: path.join(launchProject, "src/build.rs") },
|
||||
breakpoints: [{ line: 12 }]
|
||||
});
|
||||
await attachClient.response(attachBreakpoints, "setBreakpoints");
|
||||
|
||||
const attachDone = attachClient.send("configurationDone");
|
||||
await attachClient.response(attachDone, "configurationDone");
|
||||
const attachStopped = await attachClient.waitFor(
|
||||
(message) => message.type === "event" && message.event === "stopped"
|
||||
);
|
||||
assert.strictEqual(attachStopped.body.allThreadsStopped, true);
|
||||
|
||||
const attachStackRequest = attachClient.send("stackTrace", {
|
||||
threadId: 1,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const attachStack = (await attachClient.response(attachStackRequest, "stackTrace")).body
|
||||
.stackFrames;
|
||||
const attachScopesRequest = attachClient.send("scopes", { frameId: attachStack[0].id });
|
||||
const attachScopes = (await attachClient.response(attachScopesRequest, "scopes")).body.scopes;
|
||||
const attachRuntimeRef = attachScopes.find((scope) => scope.name === "Disasmer Runtime")
|
||||
.variablesReference;
|
||||
const attachRuntimeRequest = attachClient.send("variables", {
|
||||
variablesReference: attachRuntimeRef
|
||||
});
|
||||
const attachRuntime = (await attachClient.response(attachRuntimeRequest, "variables")).body
|
||||
.variables;
|
||||
assert(
|
||||
attachRuntime.some(
|
||||
(variable) =>
|
||||
variable.name === "command_status" &&
|
||||
String(variable.value).includes("attached to existing virtual process")
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
await attachClient.close().catch(() => {});
|
||||
}
|
||||
|
||||
let sourceLocalsSession;
|
||||
try {
|
||||
sourceLocalsSession = await launchToBreakpoint({
|
||||
runtimeBackend: "simulated",
|
||||
project: launchProject,
|
||||
breakpointLine: 60
|
||||
});
|
||||
assert.strictEqual(sourceLocalsSession.stopped.body.threadId, 1);
|
||||
|
||||
const localsStackRequest = sourceLocalsSession.client.send("stackTrace", {
|
||||
threadId: 1,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const localsStack = (await sourceLocalsSession.client.response(localsStackRequest, "stackTrace"))
|
||||
.body.stackFrames;
|
||||
assert.strictEqual(localsStack[0].line, 60);
|
||||
const localsScopesRequest = sourceLocalsSession.client.send("scopes", {
|
||||
frameId: localsStack[0].id
|
||||
});
|
||||
const localsScopes = (await sourceLocalsSession.client.response(localsScopesRequest, "scopes"))
|
||||
.body.scopes;
|
||||
const sourceLocalsRef = localsScopes.find((scope) => scope.name === "Source Locals")
|
||||
.variablesReference;
|
||||
const sourceLocalsRequest = sourceLocalsSession.client.send("variables", {
|
||||
variablesReference: sourceLocalsRef
|
||||
});
|
||||
const sourceLocals = (
|
||||
await sourceLocalsSession.client.response(sourceLocalsRequest, "variables")
|
||||
).body.variables;
|
||||
assert(
|
||||
sourceLocals.some(
|
||||
(variable) =>
|
||||
variable.name === "linux" &&
|
||||
String(variable.value).includes("TaskHandle") &&
|
||||
String(variable.value).includes("compile-linux") &&
|
||||
String(variable.value).includes("virtual_thread_id = 2")
|
||||
)
|
||||
);
|
||||
assert(
|
||||
sourceLocals.some((variable) => variable.name === "linux_thread" && variable.value === "2")
|
||||
);
|
||||
assert(
|
||||
sourceLocals.some(
|
||||
(variable) =>
|
||||
variable.name === "linux_artifact" && String(variable.value).includes("Artifact")
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
if (sourceLocalsSession) {
|
||||
await sourceLocalsSession.client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
let wasmLocalsSession;
|
||||
try {
|
||||
wasmLocalsSession = await launchToBreakpoint({
|
||||
runtimeBackend: "simulated",
|
||||
project: launchProject,
|
||||
breakpointLine: 31
|
||||
});
|
||||
assert.strictEqual(wasmLocalsSession.stopped.body.threadId, 1);
|
||||
|
||||
const wasmStackRequest = wasmLocalsSession.client.send("stackTrace", {
|
||||
threadId: 1,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const wasmStack = (await wasmLocalsSession.client.response(wasmStackRequest, "stackTrace"))
|
||||
.body.stackFrames;
|
||||
assert.strictEqual(wasmStack[0].line, 31);
|
||||
const wasmScopesRequest = wasmLocalsSession.client.send("scopes", {
|
||||
frameId: wasmStack[0].id
|
||||
});
|
||||
const wasmScopes = (await wasmLocalsSession.client.response(wasmScopesRequest, "scopes")).body
|
||||
.scopes;
|
||||
const wasmLocalsRef = wasmScopes.find((scope) => scope.name === "Wasm Frame Locals")
|
||||
.variablesReference;
|
||||
const wasmLocalsRequest = wasmLocalsSession.client.send("variables", {
|
||||
variablesReference: wasmLocalsRef
|
||||
});
|
||||
const wasmLocals = (await wasmLocalsSession.client.response(wasmLocalsRequest, "variables"))
|
||||
.body.variables;
|
||||
assert(
|
||||
wasmLocals.some(
|
||||
(variable) =>
|
||||
variable.name === "wasm_local_0" &&
|
||||
String(variable.value).includes("41") &&
|
||||
variable.type === "wasm-frame-local"
|
||||
),
|
||||
"DAP variables must expose Wasmtime frame-local values from the product node runtime"
|
||||
);
|
||||
} finally {
|
||||
if (wasmLocalsSession) {
|
||||
await wasmLocalsSession.client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
let liveCoordinator;
|
||||
let liveWorker;
|
||||
let liveSession;
|
||||
try {
|
||||
liveCoordinator = await startCoordinator();
|
||||
liveSession = await launchToBreakpoint({
|
||||
runtimeBackend: "live-services",
|
||||
operatorEndpoint: liveCoordinator.listen,
|
||||
project: launchProject,
|
||||
breakpointLines: [12, 22]
|
||||
});
|
||||
assert.strictEqual(liveSession.stopped.body.threadId, 1);
|
||||
|
||||
const liveStackRequest = liveSession.client.send("stackTrace", {
|
||||
threadId: 1,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const liveStack = (await liveSession.client.response(liveStackRequest, "stackTrace")).body
|
||||
.stackFrames;
|
||||
assert.strictEqual(liveStack[0].line, 12);
|
||||
assert.strictEqual(liveStack[0].source.path, launchSource);
|
||||
|
||||
const liveScopesRequest = liveSession.client.send("scopes", { frameId: liveStack[0].id });
|
||||
const liveScopes = (await liveSession.client.response(liveScopesRequest, "scopes")).body.scopes;
|
||||
const liveRuntimeRef = liveScopes.find((scope) => scope.name === "Disasmer Runtime")
|
||||
.variablesReference;
|
||||
const liveOutputRef = liveScopes.find((scope) => scope.name === "Recent Output")
|
||||
.variablesReference;
|
||||
|
||||
const liveRuntimeRequest = liveSession.client.send("variables", {
|
||||
variablesReference: liveRuntimeRef
|
||||
});
|
||||
const liveRuntime = (await liveSession.client.response(liveRuntimeRequest, "variables")).body
|
||||
.variables;
|
||||
assert(
|
||||
liveRuntime.some(
|
||||
(variable) => variable.name === "runtime_backend" && variable.value === "LiveServices"
|
||||
)
|
||||
);
|
||||
assert(
|
||||
liveRuntime.some(
|
||||
(variable) => variable.name === "coordinator_task_events" && variable.value === 0
|
||||
)
|
||||
);
|
||||
assert(
|
||||
liveRuntime.some(
|
||||
(variable) =>
|
||||
variable.name === "command_status" &&
|
||||
String(variable.value).includes("coordinator-side virtual process started")
|
||||
)
|
||||
);
|
||||
|
||||
const liveOutputRequest = liveSession.client.send("variables", {
|
||||
variablesReference: liveOutputRef
|
||||
});
|
||||
const liveOutput = (await liveSession.client.response(liveOutputRequest, "variables")).body
|
||||
.variables;
|
||||
assert(
|
||||
liveOutput.some((variable) =>
|
||||
String(variable.value).includes("Command-capability virtual task")
|
||||
)
|
||||
);
|
||||
|
||||
liveWorker = await startExplicitWorker(liveCoordinator.listen);
|
||||
const liveContinueRequest = liveSession.client.send("continue", { threadId: 1 });
|
||||
await liveSession.client.response(liveContinueRequest, "continue");
|
||||
await liveSession.client.waitFor(
|
||||
(message) => message.type === "event" && message.event === "continued"
|
||||
);
|
||||
const liveTaskStopped = await liveSession.client.waitFor(
|
||||
(message) =>
|
||||
message.type === "event" &&
|
||||
message.event === "stopped" &&
|
||||
message.body.reason === "breakpoint" &&
|
||||
message.body.threadId === 2
|
||||
);
|
||||
assert.strictEqual(liveTaskStopped.body.threadId, 2);
|
||||
|
||||
const liveTaskStackRequest = liveSession.client.send("stackTrace", {
|
||||
threadId: 2,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const liveTaskStack = (await liveSession.client.response(liveTaskStackRequest, "stackTrace"))
|
||||
.body.stackFrames;
|
||||
assert.strictEqual(liveTaskStack[0].line, 22);
|
||||
|
||||
const liveTaskScopesRequest = liveSession.client.send("scopes", {
|
||||
frameId: liveTaskStack[0].id
|
||||
});
|
||||
const liveTaskScopes = (await liveSession.client.response(liveTaskScopesRequest, "scopes"))
|
||||
.body.scopes;
|
||||
const liveTaskRuntimeRef = liveTaskScopes.find((scope) => scope.name === "Disasmer Runtime")
|
||||
.variablesReference;
|
||||
const liveTaskOutputRef = liveTaskScopes.find((scope) => scope.name === "Recent Output")
|
||||
.variablesReference;
|
||||
const liveTaskRuntimeRequest = liveSession.client.send("variables", {
|
||||
variablesReference: liveTaskRuntimeRef
|
||||
});
|
||||
const liveTaskRuntime = (await liveSession.client.response(liveTaskRuntimeRequest, "variables"))
|
||||
.body.variables;
|
||||
assert(
|
||||
liveTaskRuntime.some(
|
||||
(variable) => variable.name === "coordinator_task_events" && variable.value === 1
|
||||
)
|
||||
);
|
||||
assert(
|
||||
liveTaskRuntime.some(
|
||||
(variable) =>
|
||||
variable.name === "command_status" &&
|
||||
String(variable.value).includes("completed through live services")
|
||||
)
|
||||
);
|
||||
assert(liveTaskRuntime.some((variable) => variable.name === "stdout_tail"));
|
||||
assert(liveTaskRuntime.some((variable) => variable.name === "stderr_tail"));
|
||||
const liveTaskOutputRequest = liveSession.client.send("variables", {
|
||||
variablesReference: liveTaskOutputRef
|
||||
});
|
||||
const liveTaskOutput = (await liveSession.client.response(liveTaskOutputRequest, "variables"))
|
||||
.body.variables;
|
||||
assert(liveTaskOutput.some((variable) => variable.name === "stdout_tail"));
|
||||
assert(liveTaskOutput.some((variable) => variable.name === "stderr_tail"));
|
||||
assert(
|
||||
liveTaskOutput.some((variable) =>
|
||||
String(variable.value).includes("attached node completed task")
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
if (liveSession) {
|
||||
await liveSession.client.close().catch(() => {});
|
||||
}
|
||||
killChild(liveWorker);
|
||||
killChild(liveCoordinator && liveCoordinator.child);
|
||||
}
|
||||
|
||||
const failingProject = createFailingProject();
|
||||
let failedSession;
|
||||
try {
|
||||
failedSession = await launchToBreakpoint({
|
||||
runtimeBackend: "local-services",
|
||||
breakpointLine: 42,
|
||||
project: failingProject
|
||||
});
|
||||
assert.strictEqual(failedSession.stopped.body.threadId, 2);
|
||||
|
||||
const failedStackRequest = failedSession.client.send("stackTrace", {
|
||||
threadId: 2,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const failedStack = (await failedSession.client.response(failedStackRequest, "stackTrace")).body
|
||||
.stackFrames;
|
||||
assert.strictEqual(failedStack.length, 1);
|
||||
assert.match(failedStack[0].name, /compile linux::run/);
|
||||
|
||||
const failedScopesRequest = failedSession.client.send("scopes", { frameId: failedStack[0].id });
|
||||
const failedScopes = (await failedSession.client.response(failedScopesRequest, "scopes")).body
|
||||
.scopes;
|
||||
const failedRuntimeRef = failedScopes.find((scope) => scope.name === "Disasmer Runtime")
|
||||
.variablesReference;
|
||||
const failedOutputRef = failedScopes.find((scope) => scope.name === "Recent Output")
|
||||
.variablesReference;
|
||||
|
||||
const failedRuntimeRequest = failedSession.client.send("variables", {
|
||||
variablesReference: failedRuntimeRef
|
||||
});
|
||||
const failedRuntime = (await failedSession.client.response(failedRuntimeRequest, "variables"))
|
||||
.body.variables;
|
||||
assert(
|
||||
failedRuntime.some(
|
||||
(variable) =>
|
||||
variable.name === "command_status" &&
|
||||
String(variable.value).includes("failed through local services")
|
||||
)
|
||||
);
|
||||
assert(
|
||||
failedRuntime.some(
|
||||
(variable) => variable.name === "state" && String(variable.value).includes("Failed")
|
||||
)
|
||||
);
|
||||
|
||||
const failedOutputRequest = failedSession.client.send("variables", {
|
||||
variablesReference: failedOutputRef
|
||||
});
|
||||
const failedOutput = (await failedSession.client.response(failedOutputRequest, "variables")).body
|
||||
.variables;
|
||||
assert(
|
||||
failedOutput.some((variable) => String(variable.value).includes("attached node failed task"))
|
||||
);
|
||||
|
||||
const failedRestartRequest = failedSession.client.send("restartFrame", {
|
||||
frameId: failedStack[0].id,
|
||||
sourceEdit: { compatibility: "compatible" }
|
||||
});
|
||||
await failedSession.client.response(failedRestartRequest, "restartFrame");
|
||||
await failedSession.client.waitFor(
|
||||
(message) =>
|
||||
message.type === "event" &&
|
||||
message.event === "output" &&
|
||||
String(message.body.output).includes("Restarted failed task")
|
||||
);
|
||||
|
||||
const failedRuntimeAfterRestartRequest = failedSession.client.send("variables", {
|
||||
variablesReference: failedRuntimeRef
|
||||
});
|
||||
const failedRuntimeAfterRestart = (
|
||||
await failedSession.client.response(failedRuntimeAfterRestartRequest, "variables")
|
||||
).body.variables;
|
||||
assert(
|
||||
failedRuntimeAfterRestart.some(
|
||||
(variable) =>
|
||||
variable.name === "command_status" &&
|
||||
String(variable.value).includes("failed task restarted")
|
||||
)
|
||||
);
|
||||
|
||||
const failedOutputAfterRestartRequest = failedSession.client.send("variables", {
|
||||
variablesReference: failedOutputRef
|
||||
});
|
||||
const failedOutputAfterRestart = (
|
||||
await failedSession.client.response(failedOutputAfterRestartRequest, "variables")
|
||||
).body.variables;
|
||||
assert(
|
||||
failedOutputAfterRestart.some((variable) =>
|
||||
String(variable.value).includes("task restarted from VFS checkpoint")
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
if (failedSession) {
|
||||
await failedSession.client.close().catch(() => {});
|
||||
}
|
||||
fs.rmSync(failingProject, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const mainSession = await launchToBreakpoint({
|
||||
runtimeBackend: "local-services",
|
||||
project: launchProject,
|
||||
breakpointLine: 42
|
||||
});
|
||||
assert.strictEqual(mainSession.stopped.body.threadId, 1);
|
||||
|
||||
const mainStackRequest = mainSession.client.send("stackTrace", {
|
||||
threadId: 1,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const mainStack = (await mainSession.client.response(mainStackRequest, "stackTrace"))
|
||||
.body.stackFrames;
|
||||
assert.strictEqual(mainStack.length, 1);
|
||||
assert.match(mainStack[0].name, /build virtual process::run/);
|
||||
assert.strictEqual(mainStack[0].line, 42);
|
||||
assert.strictEqual(mainStack[0].source.path, launchSource);
|
||||
assert.strictEqual(mainStack[0].source.sourceReference || 0, 0);
|
||||
|
||||
const mainSourceRequest = mainSession.client.send("source", { source: mainStack[0].source });
|
||||
const mainSource = (await mainSession.client.response(mainSourceRequest, "source")).body;
|
||||
assert.match(mainSource.content, /build/);
|
||||
|
||||
const mainScopesRequest = mainSession.client.send("scopes", { frameId: mainStack[0].id });
|
||||
const mainScopes = (await mainSession.client.response(mainScopesRequest, "scopes")).body.scopes;
|
||||
const mainRuntimeRef = mainScopes.find((scope) => scope.name === "Disasmer Runtime")
|
||||
.variablesReference;
|
||||
const mainRuntimeRequest = mainSession.client.send("variables", {
|
||||
variablesReference: mainRuntimeRef
|
||||
});
|
||||
const mainRuntime = (await mainSession.client.response(mainRuntimeRequest, "variables")).body
|
||||
.variables;
|
||||
assert(
|
||||
mainRuntime.some(
|
||||
(variable) => variable.name === "runtime_backend" && variable.value === "LocalServices"
|
||||
)
|
||||
);
|
||||
assert(
|
||||
mainRuntime.some(
|
||||
(variable) => variable.name === "coordinator_task_events" && variable.value === 1
|
||||
)
|
||||
);
|
||||
|
||||
await mainSession.client.close();
|
||||
|
||||
const multiMainSession = await launchToBreakpoint({
|
||||
runtimeBackend: "local-services",
|
||||
project: launchProject,
|
||||
breakpointLines: [42, 43]
|
||||
});
|
||||
assert.strictEqual(multiMainSession.stopped.body.threadId, 1);
|
||||
const firstMainStackRequest = multiMainSession.client.send("stackTrace", {
|
||||
threadId: 1,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const firstMainStack = (await multiMainSession.client.response(firstMainStackRequest, "stackTrace"))
|
||||
.body.stackFrames;
|
||||
assert.strictEqual(firstMainStack[0].line, 42);
|
||||
|
||||
const continueMainRequest = multiMainSession.client.send("continue", { threadId: 1 });
|
||||
await multiMainSession.client.response(continueMainRequest, "continue");
|
||||
await multiMainSession.client.waitFor(
|
||||
(message) => message.type === "event" && message.event === "continued"
|
||||
);
|
||||
const secondMainStop = await multiMainSession.client.waitFor(
|
||||
(message) =>
|
||||
message.type === "event" &&
|
||||
message.event === "stopped" &&
|
||||
message.body.reason === "breakpoint"
|
||||
);
|
||||
assert.strictEqual(secondMainStop.body.threadId, 1);
|
||||
const secondMainStackRequest = multiMainSession.client.send("stackTrace", {
|
||||
threadId: 1,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const secondMainStack = (
|
||||
await multiMainSession.client.response(secondMainStackRequest, "stackTrace")
|
||||
).body.stackFrames;
|
||||
assert.strictEqual(secondMainStack[0].line, 43);
|
||||
await multiMainSession.client.close();
|
||||
|
||||
console.log("DAP smoke passed");
|
||||
})().catch((err) => {
|
||||
console.error(err.stack || err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
333
scripts/docs-smoke.js
Normal file
333
scripts/docs-smoke.js
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const readme = fs.readFileSync(path.join(repo, "README.md"), "utf8");
|
||||
const userFacingDocs = [
|
||||
"README.md",
|
||||
"MVP.md",
|
||||
"acceptance_criteria.md",
|
||||
"acceptance_criteria_phase2.md",
|
||||
].map((file) => [file, fs.readFileSync(path.join(repo, file), "utf8")]);
|
||||
const publicAcceptance = fs.readFileSync(
|
||||
path.join(repo, "scripts/acceptance-public.sh"),
|
||||
"utf8"
|
||||
);
|
||||
const publicSplit = fs.readFileSync(
|
||||
path.join(repo, "scripts/verify-public-split.sh"),
|
||||
"utf8"
|
||||
);
|
||||
const privateAcceptance = fs.readFileSync(
|
||||
path.join(repo, "scripts/acceptance-private.sh"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const requiredReadmePatterns = [
|
||||
["quickstart heading", /## Quickstart/],
|
||||
["workspace build", /cargo build --workspace/],
|
||||
["CLI install", /cargo install --path crates\/disasmer-cli --bin disasmer/],
|
||||
["node install", /cargo install --path crates\/disasmer-node --bin disasmer-node/],
|
||||
[
|
||||
"coordinator install",
|
||||
/cargo install --path crates\/disasmer-coordinator --bin disasmer-coordinator/,
|
||||
],
|
||||
["DAP install", /cargo install --path crates\/disasmer-dap --bin disasmer-debug-dap/],
|
||||
["VS Code local extension", /code --extensionDevelopmentPath/],
|
||||
["local coordinator", /disasmer-coordinator --listen/],
|
||||
["node attach", /disasmer node attach --coordinator/],
|
||||
["automatic local run", /disasmer run --local --project examples\/launch-build-demo build/],
|
||||
["demo run", /disasmer run --local --coordinator/],
|
||||
["entrypoint selection", /disasmer run \[entry\]/],
|
||||
["implicit hosted mode", /uses the hosted coordinator/],
|
||||
["local override", /force local coordinator mode/],
|
||||
["project override", /--project[\s\S]*overrides the project\s+directory/],
|
||||
["VS Code debug", /Disasmer: Launch\s+Virtual Process/],
|
||||
["artifact download smoke", /node scripts\/artifact-download-smoke\.js/],
|
||||
["artifact export smoke", /node scripts\/artifact-export-smoke\.js/],
|
||||
["acceptance report smoke", /node scripts\/acceptance-report-smoke\.js/],
|
||||
["public private boundary smoke", /node scripts\/public-private-boundary-smoke\.js/],
|
||||
["release blocker smoke", /node scripts\/release-blocker-smoke\.js/],
|
||||
["explicit export", /attached receiver node or user-provided\s+storage integration/],
|
||||
["cleanup", /Cleanup for the local quickstart/],
|
||||
["flush docs", /`flush\(\)` publishes metadata/],
|
||||
["sync docs", /`sync\(\)` is explicit/],
|
||||
["storage integration is user code", /User-provided storage\/export integrations are ordinary project code or external\s+commands/],
|
||||
["no managed artifact store feature", /does not provide\s+or manage an explicit artifact-store feature/],
|
||||
["best-effort retention", /best-effort retained on nodes/],
|
||||
[
|
||||
"secure downloads",
|
||||
/Download links are scoped to the tenant, project, process, artifact, actor, and policy context, expire after a bounded TTL, can be revoked/,
|
||||
],
|
||||
["node trust", /Users attach their own nodes for real work/],
|
||||
["self-hosted trusted teams", /self-hosted local clouds, trusted teams, or VPN deployments/],
|
||||
["self-hosted coordinator smoke", /node scripts\/self-hosted-coordinator-smoke\.js/],
|
||||
["wasmtime node smoke", /node scripts\/wasmtime-node-smoke\.js/],
|
||||
["wasmtime command host import", /node `disasmer\.cmd_run` host import/],
|
||||
["Windows sandbox limitation", /Production-grade managed Windows sandboxing is behind an explicit backend stub/],
|
||||
["hosted community limit", /community tier does not provide arbitrary hosted native commands or hosted containers/],
|
||||
["browser login", /disasmer login --browser/],
|
||||
["public-key agents", /disasmer agent enroll --public-key/],
|
||||
["noninteractive agent CLI", /DISASMER_AGENT_PUBLIC_KEY=<agent-public-key> disasmer run build/],
|
||||
["agent key lifecycle", /register, list, rotate, and revoke an agent key/],
|
||||
["capability auto-detect", /auto-detects OS, architecture/],
|
||||
["capability override", /--cap <name>/],
|
||||
["non-Git source provider", /Non-Git source providers can implement the public source-provider interface/],
|
||||
["first-run diagnostics", /## First-Run Diagnostics/],
|
||||
["missing nodes diagnostic", /Missing nodes/],
|
||||
["missing environment diagnostic", /Missing environments/],
|
||||
["quota diagnostic", /Quota limits/],
|
||||
["unavailable artifact diagnostic", /Unavailable artifacts/],
|
||||
["auth diagnostic", /Auth failures/],
|
||||
["debug freeze diagnostic", /Failed debug freezes/],
|
||||
["source-provider diagnostic", /Source-provider capability gaps/],
|
||||
["browser and VS Code report metadata", /browser\/VS Code harness metadata/],
|
||||
["Podman incomplete report", /Linux Podman backend behavior is marked `incomplete`/],
|
||||
["manual Windows validation workflow", /manual `Windows validation`\s+workflow/],
|
||||
["intermittent Forgejo Windows runner", /intermittent Windows runner/],
|
||||
["Windows validation env gate", /DISASMER_WINDOWS_VALIDATION = "forgejo-windows-runner"/],
|
||||
["Windows validation attach", /runs `disasmer node attach`/],
|
||||
["public dry-run operator endpoint", /https:\/\/disasmer\.michelpaulissen\.com:9443/],
|
||||
["public dry-run DNS record", /record is live[\s\S]*no resolver override is required|DNS record is deployed[\s\S]*no resolver override is required/],
|
||||
["public dry-run real deployment", /real externally reachable service/],
|
||||
["public dry-run selected users", /shared with selected users/],
|
||||
["public dry-run Forgejo repo", /git\.michelpaulissen\.com/],
|
||||
["public dry-run Forgejo Release assets", /Forgejo Release publishes compiled\s+assets/],
|
||||
["public dry-run selected-user quickstart", /DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-\*\.md/],
|
||||
["public dry-run selected-user invite", /DISASMER_PUBLIC_DRYRUN_INVITE-\*\.md/],
|
||||
["public dry-run resolver instructions env", /DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS=<instructions>/],
|
||||
["public dry-run fallback hosts entry env", /DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY="<ip-address> disasmer\.michelpaulissen\.com"/],
|
||||
["public dry-run deployment IP env", /DISASMER_PUBLIC_RELEASE_DRYRUN_IP=<ip-address>/],
|
||||
["public dry-run prep script", /node scripts\/prepare-public-release-dryrun\.js/],
|
||||
["public dry-run publish opt-in", /DISASMER_PUBLISH_PUBLIC_TREE=1/],
|
||||
["public dry-run public repo remote", /DISASMER_PUBLIC_REPO_REMOTE=ssh:\/\/git\.michelpaulissen\.com/],
|
||||
["public dry-run Forgejo workflow", /Public release dry run assets/],
|
||||
["public dry-run manifest", /public-release-manifest\.json/],
|
||||
["public dry-run release publisher", /node scripts\/publish-public-release-dryrun\.js/],
|
||||
["public dry-run non-e2e preflight", /node scripts\/public-release-dryrun-preflight\.js/],
|
||||
["public dry-run Forgejo token", /DISASMER_FORGEJO_TOKEN=<token>/],
|
||||
["public dry-run publisher infers repo", /publisher infers the Forgejo owner and repository name/],
|
||||
["public dry-run Forgejo repo owner override", /DISASMER_PUBLIC_REPO_OWNER=<owner>/],
|
||||
["public dry-run Forgejo repo name override", /DISASMER_PUBLIC_REPO_NAME=<public-repo>/],
|
||||
["public dry-run service smoke", /public-release-dryrun-service-smoke\.js/],
|
||||
["public dry-run service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer\.michelpaulissen\.com:9443/],
|
||||
["public dry-run private hosted coordinator", /default operator at `disasmer\.michelpaulissen\.com:9443` is the private hosted\s+coordinator from `private\/hosted-policy`/],
|
||||
["public dry-run not standalone public coordinator", /does not mean deploying the standalone\s+open-source\/public coordinator as the hosted operator/],
|
||||
["public dry-run validates both coordinators", /dry-run acceptance validates both coordinator implementations/],
|
||||
["public dry-run validates public coordinator separately", /standalone public\/open-source\s+coordinator is validated separately/],
|
||||
["public browser login site", /disasmer\.michelpaulissen\.com\/auth\/browser\/start/],
|
||||
["public browser login local callback", /local callback/],
|
||||
["public dry-run barebones HTML no CSS", /barebones HTML with\s+no CSS/],
|
||||
["browser login plan diagnostic", /disasmer login --browser --plan/],
|
||||
["public dry-run deployment prep", /prepare-public-release-dryrun-deployment\.js/],
|
||||
["public dry-run systemd unit", /systemd unit that\s+binds `0\.0\.0\.0:9443`/],
|
||||
["public dry-run e2e runner", /public-release-dryrun-e2e\.js/],
|
||||
["public dry-run e2e gate env", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1/],
|
||||
["public dry-run e2e service address", /DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer\.michelpaulissen\.com:9443/],
|
||||
["public dry-run final verifier", /public-release-dryrun-final-evidence\.js/],
|
||||
["public dry-run final gate env", /DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL=1/],
|
||||
["public dry-run e2e evidence", /public-release-dryrun-e2e\.json/],
|
||||
["public dry-run e2e checks", /downloaded release assets[\s\S]*VS Code debugger behavior[\s\S]*artifact metadata/],
|
||||
["public operator compatibility smoke", /public-operator-compat-smoke\.js/],
|
||||
["public operator compatibility purpose", /public CLI and public\s+node runtime can attach, launch work through coordinator task assignment, and\s+publish debug\/log\/artifact metadata/],
|
||||
];
|
||||
|
||||
for (const [name, pattern] of requiredReadmePatterns) {
|
||||
assert.match(readme, pattern, `README missing ${name}`);
|
||||
}
|
||||
|
||||
for (const [file, contents] of userFacingDocs) {
|
||||
assert.doesNotMatch(
|
||||
contents,
|
||||
/community-tier/i,
|
||||
`${file} should use "community tier" in user-facing prose`
|
||||
);
|
||||
}
|
||||
|
||||
for (const script of [publicAcceptance, publicSplit]) {
|
||||
assert(
|
||||
script.includes("node scripts/docs-smoke.js"),
|
||||
"public acceptance gates must run docs-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/cli-install-smoke.js"),
|
||||
"public acceptance gates must run cli-install-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/cli-login-smoke.js"),
|
||||
"public acceptance gates must run cli-login-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/acceptance-report-smoke.js"),
|
||||
"public acceptance gates must run acceptance-report-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/acceptance-doc-contract-smoke.js"),
|
||||
"public acceptance gates must run acceptance-doc-contract-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/acceptance-environment-contract-smoke.js"),
|
||||
"public acceptance gates must run acceptance-environment-contract-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/acceptance-evidence-contract-smoke.js"),
|
||||
"public acceptance gates must run acceptance-evidence-contract-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/public-private-boundary-smoke.js"),
|
||||
"public acceptance gates must run public-private-boundary-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/release-blocker-smoke.js"),
|
||||
"public acceptance gates must run release-blocker-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/resource-metering-contract-smoke.js"),
|
||||
"public acceptance gates must run resource-metering-contract-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/hostile-input-contract-smoke.js"),
|
||||
"public acceptance gates must run hostile-input-contract-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/tenant-isolation-contract-smoke.js"),
|
||||
"public acceptance gates must run tenant-isolation-contract-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/public-story-contract-smoke.js"),
|
||||
"public acceptance gates must run public-story-contract-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/public-release-dryrun-contract-smoke.js"),
|
||||
"public acceptance gates must run public-release-dryrun-contract-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/prepare-public-release-dryrun.js"),
|
||||
"public acceptance gates must run prepare-public-release-dryrun.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/self-hosted-coordinator-smoke.js"),
|
||||
"public acceptance gates must run self-hosted-coordinator-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/public-local-demo-matrix-smoke.js"),
|
||||
"public acceptance gates must run public-local-demo-matrix-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("scripts/release-source-scan.sh"),
|
||||
"public acceptance gates must run release-source-scan.sh"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/flagship-demo-smoke.js"),
|
||||
"public acceptance gates must run flagship-demo-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/cancellation-smoke.js"),
|
||||
"public acceptance gates must run cancellation-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/sdk-spawn-runtime-smoke.js"),
|
||||
"public acceptance gates must run sdk-spawn-runtime-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/node-lifecycle-contract-smoke.js"),
|
||||
"public acceptance gates must run node-lifecycle-contract-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/artifact-export-smoke.js"),
|
||||
"public acceptance gates must run artifact-export-smoke.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/windows-validation-contract-smoke.js"),
|
||||
"public acceptance gates must run windows-validation-contract-smoke.js"
|
||||
);
|
||||
}
|
||||
|
||||
assert(
|
||||
publicAcceptance.includes("node scripts/podman-backend-smoke.js"),
|
||||
"public acceptance must run the Linux Podman backend smoke when Podman is available"
|
||||
);
|
||||
|
||||
assert(
|
||||
publicAcceptance.includes("node scripts/wasmtime-node-smoke.js"),
|
||||
"public acceptance must run the Wasmtime node smoke"
|
||||
);
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node scripts/resource-metering-contract-smoke.js"),
|
||||
"private acceptance must run resource-metering-contract-smoke.js"
|
||||
);
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node scripts/hostile-input-contract-smoke.js"),
|
||||
"private acceptance must run hostile-input-contract-smoke.js"
|
||||
);
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node scripts/tenant-isolation-contract-smoke.js"),
|
||||
"private acceptance must run tenant-isolation-contract-smoke.js"
|
||||
);
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node scripts/acceptance-doc-contract-smoke.js"),
|
||||
"private acceptance must run acceptance-doc-contract-smoke.js"
|
||||
);
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node scripts/acceptance-environment-contract-smoke.js"),
|
||||
"private acceptance must run acceptance-environment-contract-smoke.js"
|
||||
);
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node scripts/acceptance-evidence-contract-smoke.js"),
|
||||
"private acceptance must run acceptance-evidence-contract-smoke.js"
|
||||
);
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node private/hosted-policy/scripts/hosted-deployment-smoke.js"),
|
||||
"private acceptance must run hosted-deployment-smoke.js"
|
||||
);
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node private/hosted-policy/scripts/public-operator-compat-smoke.js"),
|
||||
"private acceptance must run public-operator-compat-smoke.js"
|
||||
);
|
||||
assert(
|
||||
privateAcceptance.includes("node scripts/self-hosted-coordinator-smoke.js"),
|
||||
"private acceptance must run self-hosted-coordinator-smoke.js before final dry-run evidence"
|
||||
);
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js"),
|
||||
"private acceptance must prepare public release dry-run deployment bundle"
|
||||
);
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js"),
|
||||
"private acceptance must be able to run public-release-dryrun-service-smoke.js"
|
||||
);
|
||||
|
||||
for (const script of [publicAcceptance, privateAcceptance]) {
|
||||
assert(
|
||||
script.includes("node scripts/public-release-dryrun-final-evidence.js"),
|
||||
"acceptance must be able to run public-release-dryrun-final-evidence.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"),
|
||||
"acceptance must gate public-release-dryrun-final-evidence.js"
|
||||
);
|
||||
}
|
||||
|
||||
assert(
|
||||
publicAcceptance.includes("node scripts/public-release-dryrun-e2e.js"),
|
||||
"public acceptance must be able to run public-release-dryrun-e2e.js"
|
||||
);
|
||||
|
||||
assert(
|
||||
publicAcceptance.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E"),
|
||||
"public acceptance must gate public-release-dryrun-e2e.js"
|
||||
);
|
||||
|
||||
console.log("Docs smoke passed");
|
||||
76
scripts/flagship-demo-smoke.js
Normal file
76
scripts/flagship-demo-smoke.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const extension = require("../vscode-extension/extension");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const project = path.join(repo, "examples/launch-build-demo");
|
||||
const source = fs.readFileSync(path.join(project, "src/build.rs"), "utf8");
|
||||
const forbiddenSourceAssumptions =
|
||||
/\b(?:std::fs|std::process|Command::new|git|podman|docker|localhost|127\.0\.0\.1)|\/home\/|\/Users\/|C:\\Users\\/i;
|
||||
|
||||
const envs = extension.discoverEnvironmentNames(project);
|
||||
assert.deepStrictEqual(envs, ["linux", "windows"]);
|
||||
assert.deepStrictEqual(extension.diagnoseEnvReferences(source, envs), []);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
forbiddenSourceAssumptions,
|
||||
"flagship build source must not rely on coordinator-side filesystem, Git, shell, container, or machine-local assumptions"
|
||||
);
|
||||
|
||||
const inspection = JSON.parse(
|
||||
cp.execFileSync(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-cli",
|
||||
"--bin",
|
||||
"disasmer",
|
||||
"--",
|
||||
"bundle",
|
||||
"inspect",
|
||||
"--project",
|
||||
project
|
||||
],
|
||||
{ cwd: repo, encoding: "utf8" }
|
||||
)
|
||||
);
|
||||
|
||||
assert.strictEqual(inspection.project, project);
|
||||
assert.strictEqual(
|
||||
inspection.source_provider_manifest.coordinator_requires_checkout_access,
|
||||
false
|
||||
);
|
||||
assert.strictEqual(
|
||||
inspection.source_provider_manifest.transfer_policy.local_source_bytes_remain_node_local,
|
||||
true
|
||||
);
|
||||
assert.strictEqual(
|
||||
inspection.source_provider_manifest.transfer_policy.coordinator_receives_source_bytes_by_default,
|
||||
false
|
||||
);
|
||||
assert.strictEqual(
|
||||
inspection.source_provider_manifest.transfer_policy.default_full_repo_tarball,
|
||||
false
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
inspection.source_provider_manifest.transfer_policy.allowed_remote_transfer.sort(),
|
||||
["ExplicitSnapshotChunks", "RequiredContent"]
|
||||
);
|
||||
assert.strictEqual(inspection.metadata.embeds_full_container_images, false);
|
||||
assert(inspection.metadata.environments.some((env) => env.name === "linux"));
|
||||
assert(inspection.metadata.environments.some((env) => env.name === "windows"));
|
||||
assert(inspection.metadata.selected_inputs.some((input) => input.path === "src/build.rs"));
|
||||
|
||||
cp.execFileSync("cargo", ["test", "-p", "launch-build-demo"], {
|
||||
cwd: repo,
|
||||
stdio: "inherit"
|
||||
});
|
||||
|
||||
console.log("Flagship demo smoke passed");
|
||||
176
scripts/hostile-input-contract-smoke.js
Normal file
176
scripts/hostile-input-contract-smoke.js
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function maybeRead(segments) {
|
||||
const fullPath = path.join(repo, ...segments);
|
||||
if (!fs.existsSync(fullPath)) return null;
|
||||
return fs.readFileSync(fullPath, "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing hostile-input evidence: ${name}`);
|
||||
}
|
||||
|
||||
function expectGate(script, gateName) {
|
||||
assert(
|
||||
script.includes("node scripts/hostile-input-contract-smoke.js"),
|
||||
`${gateName} must run hostile-input-contract-smoke.js`
|
||||
);
|
||||
}
|
||||
|
||||
const coreSource = read("crates/disasmer-core/src/source.rs");
|
||||
const coreCapabilities = read("crates/disasmer-core/src/capability.rs");
|
||||
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||
const operatorPanelSmoke = read("scripts/operator-panel-smoke.js");
|
||||
const schedulerSmoke = read("scripts/scheduler-placement-smoke.js");
|
||||
const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js");
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["source manifests validate shape", /pub fn validate_public_mvp\(&self\)[\s\S]*self\.validate_shape\(\)\?/],
|
||||
["source manifests reject invalid digests", /SourceManifestError::InvalidDigest/],
|
||||
["source manifests reject invalid custom providers", /SourceManifestError::InvalidProviderId/],
|
||||
["source manifests reject control characters", /DescriptionControlCharacter/],
|
||||
["source manifests reject coordinator checkout access", /CoordinatorCheckoutAccess/],
|
||||
["source manifests reject default source-byte upload", /CoordinatorReceivesSourceBytes/],
|
||||
]) {
|
||||
expect(coreSource, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["capability reports validate public shape", /pub fn validate_public_report\(&self\)/],
|
||||
["capability reports validate architecture labels", /InvalidArchitecture/],
|
||||
["capability reports validate OS labels", /InvalidOsLabel/],
|
||||
["capability reports validate source providers", /InvalidSourceProvider/],
|
||||
["source provider ids reject path traversal", /valid_source_provider_id/],
|
||||
]) {
|
||||
expect(coreCapabilities, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["coordinator task log tails are bounded", /MAX_TASK_LOG_TAIL_BYTES: usize = 256 \* 1024/],
|
||||
["coordinator validates reported stdout tails", /ReportTaskLog[\s\S]*validate_task_log_tail\("stdout_tail", &stdout_tail\)\?/],
|
||||
["coordinator validates completed task stdout tails", /TaskCompleted[\s\S]*validate_task_log_tail\("stdout_tail", &stdout_tail\)\?/],
|
||||
["coordinator rejects oversized log tail in unit coverage", /"x"\.repeat\(MAX_TASK_LOG_TAIL_BYTES \+ 1\)/],
|
||||
]) {
|
||||
expect(coordinatorService, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["service rejects malformed node capability report", /fn service_rejects_malformed_node_capability_report\(\)/],
|
||||
["capability report rejection leaves descriptors empty", /assert!\(service\.node_descriptors\.is_empty\(\)\)/],
|
||||
["node capability report rejects cross-scope writes", /fn service_rejects_node_capability_report_outside_enrollment_scope\(\)/],
|
||||
["task completion rejects cross-scope writes", /task completion outside node scope|outside/],
|
||||
]) {
|
||||
expect(coordinatorService, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, source, patterns] of [
|
||||
[
|
||||
"artifact download smoke",
|
||||
artifactDownloadSmoke,
|
||||
[
|
||||
/const crossTenant = await send/,
|
||||
/const crossProject = await send/,
|
||||
/const guessed = await send/,
|
||||
/const crossActorOpen = await send/,
|
||||
/token is invalid/,
|
||||
/tenant mismatch/,
|
||||
/project mismatch/,
|
||||
],
|
||||
],
|
||||
[
|
||||
"operator panel smoke",
|
||||
operatorPanelSmoke,
|
||||
[
|
||||
/render_operator_panel/,
|
||||
/submit_panel_event/,
|
||||
/assert\(!JSON\.stringify\(panel\)\.includes\("<script"\)\)/,
|
||||
/assert\(!JSON\.stringify\(panel\)\.toLowerCase\(\)\.includes\("oauth"\)\)/,
|
||||
/rate limit/i,
|
||||
/exceeds download limit/,
|
||||
],
|
||||
],
|
||||
[
|
||||
"scheduler smoke",
|
||||
schedulerSmoke,
|
||||
[/const crossTenantReport = await send/, /report_node_capabilities/, /tenant\\\/project scope/],
|
||||
],
|
||||
[
|
||||
"source preparation smoke",
|
||||
sourcePreparationSmoke,
|
||||
[/const crossTenantCompletion = await send/, /complete_source_preparation/, /tenant\\\/project scope/i],
|
||||
],
|
||||
]) {
|
||||
for (const pattern of patterns) {
|
||||
expect(source, name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
expectGate(publicAcceptance, "public acceptance");
|
||||
expectGate(publicSplit, "public split acceptance");
|
||||
expectGate(privateAcceptance, "private acceptance");
|
||||
|
||||
const hostedService = maybeRead([
|
||||
"private",
|
||||
"hosted-policy",
|
||||
"src",
|
||||
"bin",
|
||||
"disasmer-hosted-service.rs",
|
||||
]);
|
||||
const hostedSmoke = maybeRead([
|
||||
"private",
|
||||
"hosted-policy",
|
||||
"scripts",
|
||||
"hosted-community-smoke.js",
|
||||
]);
|
||||
|
||||
if (hostedService && hostedSmoke) {
|
||||
for (const [name, pattern] of [
|
||||
["hosted service turns malformed JSON into error responses", /serde_json::from_str::<HostedRequest>[\s\S]*HostedResponse::Error/],
|
||||
["tenant ids are validated", /fn tenant_id\(value: String\)[\s\S]*validate_identifier\("tenant", &value\)\?/],
|
||||
["project ids are validated", /fn project_id\(value: String\)[\s\S]*validate_identifier\("project", &value\)\?/],
|
||||
["user ids are validated", /fn user_id\(value: String\)[\s\S]*validate_identifier\("user", &value\)\?/],
|
||||
["node ids are validated", /fn node_id\(value: String\)[\s\S]*validate_identifier\("node", &value\)\?/],
|
||||
["process ids are validated", /fn process_id\(value: String\)[\s\S]*validate_identifier\("process", &value\)\?/],
|
||||
["task ids are validated", /fn task_id\(value: String\)[\s\S]*validate_identifier\("task", &value\)\?/],
|
||||
["artifact ids are validated", /fn artifact_id\(value: String\)[\s\S]*validate_identifier\("artifact", &value\)\?/],
|
||||
["identifiers reject empty and control/path characters", /fn validate_identifier[\s\S]*trim\(\)\.is_empty\(\)[\s\S]*ch\.is_control\(\) \|\| ch == '\/' \|\| ch == '\\\\'/],
|
||||
["OIDC and agent text fields are bounded", /fn validate_text[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/],
|
||||
["tokens are bounded", /fn validate_token[\s\S]*value\.len\(\) > max_bytes[\s\S]*contains unsupported characters/],
|
||||
["logs are bounded at service boundary", /fn validate_log[\s\S]*512 \* 1024/],
|
||||
["debug requests validate process and task before inspection", /HostedRequest::DebugProcess[\s\S]*process_id\(process\)\?[\s\S]*task_id\(task\)\?[\s\S]*debug_process/],
|
||||
["download requests validate artifact before action", /HostedRequest::DownloadAction[\s\S]*artifact_id\(artifact\)\?[\s\S]*download_action/],
|
||||
["log completion requests validate task and stdout before recording", /HostedRequest::RecordUserNodeTaskCompletion[\s\S]*task_id\(task\)\?[\s\S]*validate_log\("task stdout", &stdout\)\?[\s\S]*record_user_node_task_completion/],
|
||||
]) {
|
||||
expect(hostedService, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["running service rejects malformed JSON", /sendRaw\(addr, "\{not-json"\)/],
|
||||
["running service rejects invalid tenant id", /tenant: " "/],
|
||||
["running service rejects invalid project id", /project: "project\\nbad"/],
|
||||
["running service rejects invalid task id", /task: "compile-linux\\nescape"/],
|
||||
["running service rejects oversized logs", /stdout: "x"\.repeat\(256 \* 1024 \+ 1\)/],
|
||||
["running service rejects cross-tenant debug", /const crossTenantDebug = await send/],
|
||||
["running service rejects cross-tenant metadata", /const crossTenantMetadata = await send/],
|
||||
["running service rejects cross-tenant download", /const crossTenantDownload = await send/],
|
||||
["foreign snapshots do not reveal objects", /foreignSnapshot[\s\S]*snapshot\.nodes, \[\][\s\S]*snapshot\.logs, \[\][\s\S]*snapshot\.artifacts, \[\]/],
|
||||
]) {
|
||||
expect(hostedSmoke, name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Hostile input contract smoke passed");
|
||||
188
scripts/local-services-smoke.js
Normal file
188
scripts/local-services-smoke.js
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const project = path.join(repo, "examples/launch-build-demo");
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
try {
|
||||
resolve(JSON.parse(line));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function runNode(addr, enrollmentGrant) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-node",
|
||||
"--",
|
||||
"--coordinator",
|
||||
`${addr.host}:${addr.port}`,
|
||||
"--tenant",
|
||||
"tenant",
|
||||
"--project-id",
|
||||
"project",
|
||||
"--node",
|
||||
"node-a",
|
||||
"--enrollment-grant",
|
||||
enrollmentGrant,
|
||||
"--public-key",
|
||||
"node-a-public-key",
|
||||
"--process",
|
||||
"vp-local",
|
||||
"--task",
|
||||
"compile-linux",
|
||||
"--project",
|
||||
project,
|
||||
"--artifact",
|
||||
"/vfs/artifacts/demo-test-output.txt"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
const nodePid = child.pid;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`node process failed with code ${code}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve({ pid: nodePid, report: JSON.parse(stdout.trim().split("\n").at(-1)) });
|
||||
} catch (error) {
|
||||
reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
assert(Number.isInteger(coordinator.pid));
|
||||
let coordinatorStderr = "";
|
||||
coordinator.stderr.on("data", (chunk) => {
|
||||
coordinatorStderr += chunk.toString();
|
||||
});
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
const grant = await send(addr, {
|
||||
type: "create_node_enrollment_grant",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
grant: "grant-local-services-node",
|
||||
now_epoch_seconds: 0,
|
||||
ttl_seconds: 900
|
||||
});
|
||||
assert.strictEqual(grant.type, "node_enrollment_grant_created");
|
||||
|
||||
const { pid: nodePid, report } = await runNode(addr, grant.grant);
|
||||
assert(Number.isInteger(nodePid));
|
||||
assert.notStrictEqual(nodePid, coordinator.pid);
|
||||
assert.strictEqual(report.node_status, "completed");
|
||||
assert.strictEqual(report.status_code, 0);
|
||||
assert.strictEqual(report.large_bytes_uploaded, false);
|
||||
assert.strictEqual(report.registration_response.type, "node_enrollment_exchanged");
|
||||
assert.strictEqual(report.heartbeat_response.type, "node_heartbeat");
|
||||
assert.strictEqual(report.capability_response.type, "node_capabilities_recorded");
|
||||
assert.strictEqual(report.task_assignment_response.type, "task_placement");
|
||||
assert.strictEqual(report.debug_command_response.type, "debug_command");
|
||||
assert.strictEqual(report.log_event_response.type, "task_log_recorded");
|
||||
assert.strictEqual(report.vfs_metadata_response.type, "vfs_metadata_recorded");
|
||||
assert.strictEqual(report.session_requests, 10);
|
||||
assert.strictEqual(report.staged_artifact.path, "/vfs/artifacts/demo-test-output.txt");
|
||||
assert.strictEqual(report.coordinator_response.type, "task_recorded");
|
||||
|
||||
const events = await send(addr, {
|
||||
type: "list_task_events",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
process: "vp-local"
|
||||
});
|
||||
assert.strictEqual(events.type, "task_events");
|
||||
assert.strictEqual(events.events.length, 1);
|
||||
assert.strictEqual(events.events[0].node, "node-a");
|
||||
assert.strictEqual(events.events[0].process, "vp-local");
|
||||
assert.strictEqual(events.events[0].task, "compile-linux");
|
||||
assert.strictEqual(events.events[0].status_code, 0);
|
||||
assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/demo-test-output.txt");
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("Local services smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
270
scripts/node-attach-smoke.js
Executable file
270
scripts/node-attach-smoke.js
Executable file
|
|
@ -0,0 +1,270 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const demoProject = path.join(repo, "examples/launch-build-demo");
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function runAttach(addr, grant) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-cli",
|
||||
"--bin",
|
||||
"disasmer",
|
||||
"--",
|
||||
"node",
|
||||
"attach",
|
||||
"--coordinator",
|
||||
`${addr.host}:${addr.port}`,
|
||||
"--tenant",
|
||||
"tenant",
|
||||
"--project-id",
|
||||
"project",
|
||||
"--node",
|
||||
"node-attach",
|
||||
"--public-key",
|
||||
"node-attach-public-key",
|
||||
"--enrollment-grant",
|
||||
grant,
|
||||
"--cap",
|
||||
"quic-direct",
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`node attach failed with code ${code}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch (error) {
|
||||
reject(
|
||||
new Error(`node attach output was not JSON: ${stdout}\n${error.stack || error.message}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runAttachedNodeWork(addr) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-node",
|
||||
"--",
|
||||
"--coordinator",
|
||||
`${addr.host}:${addr.port}`,
|
||||
"--tenant",
|
||||
"tenant",
|
||||
"--project-id",
|
||||
"project",
|
||||
"--node",
|
||||
"node-attach",
|
||||
"--process",
|
||||
"vp-node-attach",
|
||||
"--task",
|
||||
"compile-linux",
|
||||
"--project",
|
||||
demoProject,
|
||||
"--artifact",
|
||||
"/vfs/artifacts/node-attach-output.txt",
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`attached node work failed with code ${code}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
|
||||
} catch (error) {
|
||||
reject(
|
||||
new Error(
|
||||
`attached node work output was not JSON: ${stdout}\n${error.stack || error.message}`
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0",
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
const grant = await send(addr, {
|
||||
type: "create_node_enrollment_grant",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "operator",
|
||||
grant: "grant-node-attach",
|
||||
now_epoch_seconds: 0,
|
||||
ttl_seconds: 900
|
||||
});
|
||||
assert.strictEqual(grant.type, "node_enrollment_grant_created");
|
||||
assert.strictEqual(grant.tenant, "tenant");
|
||||
assert.strictEqual(grant.project, "project");
|
||||
assert.strictEqual(grant.grant, "grant-node-attach");
|
||||
assert.strictEqual(grant.scope, "node:attach");
|
||||
assert.strictEqual(grant.expires_at_epoch_seconds, 900);
|
||||
|
||||
const report = await runAttach(addr, grant.grant);
|
||||
assert.strictEqual(report.plan.node, "node-attach");
|
||||
assert.strictEqual(report.plan.coordinator, `${addr.host}:${addr.port}`);
|
||||
assert.strictEqual(report.plan.enrollment.grant, "grant-node-attach");
|
||||
assert.match(report.plan.enrollment.public_key_fingerprint, /^sha256:[0-9a-f]{64}$/);
|
||||
assert.strictEqual(
|
||||
report.plan.enrollment.exchanges_short_lived_grant_for_long_lived_node_identity,
|
||||
true
|
||||
);
|
||||
assert.ok(report.plan.capabilities.arch.length > 0);
|
||||
assert.ok(report.plan.capabilities.source_providers.includes("filesystem"));
|
||||
assert.ok(report.plan.capabilities.capabilities.includes("QuicDirect"));
|
||||
assert.strictEqual(report.boundary.cli_contacted_coordinator, true);
|
||||
assert.strictEqual(report.boundary.used_enrollment_exchange, true);
|
||||
assert.strictEqual(report.boundary.coordinator_session_requests, 3);
|
||||
assert.strictEqual(report.coordinator_response.type, "node_enrollment_exchanged");
|
||||
assert.strictEqual(report.coordinator_response.node, "node-attach");
|
||||
assert.strictEqual(report.coordinator_response.credential.node, "node-attach");
|
||||
assert.strictEqual(report.coordinator_response.credential.scope, "node:attach");
|
||||
assert.strictEqual(report.coordinator_response.credential.credential_kind, "NodeCredential");
|
||||
assert.match(
|
||||
report.coordinator_response.credential.capability_policy_digest,
|
||||
/^sha256:[0-9a-f]{64}$/
|
||||
);
|
||||
assert.strictEqual(report.heartbeat_response.type, "node_heartbeat");
|
||||
assert.strictEqual(report.capability_response.type, "node_capabilities_recorded");
|
||||
|
||||
const heartbeat = await send(addr, {
|
||||
type: "node_heartbeat",
|
||||
node: "node-attach",
|
||||
});
|
||||
assert.strictEqual(heartbeat.type, "node_heartbeat");
|
||||
assert.strictEqual(heartbeat.node, "node-attach");
|
||||
|
||||
const work = await runAttachedNodeWork(addr);
|
||||
assert.strictEqual(work.node_status, "completed");
|
||||
assert.strictEqual(work.virtual_thread, "compile-linux");
|
||||
assert.strictEqual(work.status_code, 0);
|
||||
assert.strictEqual(work.large_bytes_uploaded, false);
|
||||
assert.strictEqual(
|
||||
work.staged_artifact.path,
|
||||
"/vfs/artifacts/node-attach-output.txt"
|
||||
);
|
||||
assert.strictEqual(work.coordinator_response.type, "task_recorded");
|
||||
|
||||
const events = await send(addr, {
|
||||
type: "list_task_events",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "operator",
|
||||
process: "vp-node-attach",
|
||||
});
|
||||
assert.strictEqual(events.type, "task_events");
|
||||
assert.strictEqual(events.events.length, 1);
|
||||
assert.strictEqual(events.events[0].node, "node-attach");
|
||||
assert.strictEqual(events.events[0].task, "compile-linux");
|
||||
assert.strictEqual(
|
||||
events.events[0].artifact_path,
|
||||
"/vfs/artifacts/node-attach-output.txt"
|
||||
);
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("Node attach smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
138
scripts/node-lifecycle-contract-smoke.js
Executable file
138
scripts/node-lifecycle-contract-smoke.js
Executable file
|
|
@ -0,0 +1,138 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing node lifecycle evidence: ${name}`);
|
||||
}
|
||||
|
||||
const nodeMain = read("crates/disasmer-node/src/main.rs");
|
||||
const nodeLib = read("crates/disasmer-node/src/lib.rs");
|
||||
const coordinatorCore = read("crates/disasmer-coordinator/src/lib.rs");
|
||||
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||
const localServicesSmoke = read("scripts/local-services-smoke.js");
|
||||
const cancellationSmoke = read("scripts/cancellation-smoke.js");
|
||||
const wasmtimeSmoke = read("scripts/wasmtime-node-smoke.js");
|
||||
const debugCore = read("crates/disasmer-core/src/debug.rs");
|
||||
const readme = read("README.md");
|
||||
|
||||
assert.strictEqual(
|
||||
(nodeMain.match(/CoordinatorSession::connect/g) || []).length,
|
||||
1,
|
||||
"node runtime should open one coordinator session in the local process-boundary runtime"
|
||||
);
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["enrollment exchange over session", /"type": "exchange_node_enrollment_grant"/],
|
||||
["node attach over session", /"type": "attach_node"/],
|
||||
["heartbeat over session", /"type": "node_heartbeat"/],
|
||||
["capability report over session", /"type": "report_node_capabilities"/],
|
||||
["task placement over session", /"type": "schedule_task"/],
|
||||
["process start over session", /"type": "start_process"/],
|
||||
["reconnect over session", /"type": "reconnect_node"/],
|
||||
["debug command polling over session", /"type": "poll_debug_command"/],
|
||||
["log event over session", /"type": "report_task_log"/],
|
||||
["VFS metadata over session", /"type": "report_vfs_metadata"/],
|
||||
["task control polling over session", /"type": "poll_task_control"/],
|
||||
["completion over session", /"type": "task_completed"/],
|
||||
["cancellation uses same session", /wait_for_cancellation\(session, args, &task\)/],
|
||||
["request count is reported", /session\.requests\(\)/],
|
||||
]) {
|
||||
expect(nodeMain, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["local smoke asserts persistent request count", /assert\.strictEqual\(report\.session_requests, 10\)/],
|
||||
["local smoke uses enrollment exchange", /assert\.strictEqual\(report\.registration_response\.type, "node_enrollment_exchanged"\)/],
|
||||
["local smoke verifies capability report", /assert\.strictEqual\(report\.capability_response\.type, "node_capabilities_recorded"\)/],
|
||||
["local smoke verifies task placement", /assert\.strictEqual\(report\.task_assignment_response\.type, "task_placement"\)/],
|
||||
["local smoke verifies debug command channel", /assert\.strictEqual\(report\.debug_command_response\.type, "debug_command"\)/],
|
||||
["local smoke verifies log event", /assert\.strictEqual\(report\.log_event_response\.type, "task_log_recorded"\)/],
|
||||
["local smoke verifies VFS metadata", /assert\.strictEqual\(report\.vfs_metadata_response\.type, "vfs_metadata_recorded"\)/],
|
||||
["local smoke records task completion", /assert\.strictEqual\(report\.coordinator_response\.type, "task_recorded"\)/],
|
||||
["local smoke verifies artifact metadata", /assert\.strictEqual\(events\.events\[0\]\.artifact_path, "\/vfs\/artifacts\/demo-test-output\.txt"\)/],
|
||||
["local smoke verifies output accounting", /assert\.strictEqual\(events\.events\[0\]\.status_code, 0\)/],
|
||||
]) {
|
||||
expect(localServicesSmoke, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["cancellation request is external client request", /type: "cancel_task"/],
|
||||
["node reports cancelled terminal state", /assert\.strictEqual\(report\.terminal_state, "cancelled"\)/],
|
||||
["cancelled completion is recorded", /assert\.strictEqual\(report\.coordinator_response\.type, "task_recorded"\)/],
|
||||
["coordinator stores cancelled task event", /assert\.strictEqual\(events\.events\[0\]\.terminal_state, "cancelled"\)/],
|
||||
["control flag is cleared after terminal state", /assert\.strictEqual\(control\.cancel_requested, false\)/],
|
||||
]) {
|
||||
expect(cancellationSmoke, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["coordinator rejects stale process ownership", /fn node_reconnect_rejects_stale_process_epoch_after_restart\(\)/],
|
||||
["reconnect preserves enrolled node identity", /reconnect_node\(&NodeId::from\("node"\), None\)/],
|
||||
["stale process epoch is rejected", /CoordinatorError::StaleProcessEpoch/],
|
||||
]) {
|
||||
expect(coordinatorCore, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["coordinator delivers cancellation to connected node", /fn service_delivers_cancellation_to_connected_node_and_records_terminal_state\(\)/],
|
||||
["node polls task control", /CoordinatorRequest::PollTaskControl/],
|
||||
["cancelled terminal state is recorded", /TaskTerminalState::Cancelled/],
|
||||
]) {
|
||||
expect(coordinatorService, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["native lifecycle test exists", /fn linux_task_lifecycle_supports_cancel_and_all_stop_freeze_resume\(\)/],
|
||||
["native freeze succeeds when supported", /lifecycle\.freeze_for_debug_epoch\(\)\.unwrap\(\)/],
|
||||
["native resume succeeds", /lifecycle\.resume_after_debug_epoch\(\)/],
|
||||
["native cancel reaches lifecycle", /lifecycle\.cancel\(\)/],
|
||||
["unsupported freeze errors", /BackendError::DebugFreezeUnsupported/],
|
||||
["wasmtime runtime exposes freeze resume probe", /pub fn freeze_resume_i32_export_probe/],
|
||||
["wasmtime runtime captures Wasm frame locals", /debug_i32_export_snapshot[\s\S]*local_values/],
|
||||
["wasmtime runtime creates Wasm debug participant", /kind: DebugParticipantKind::WasmTask/],
|
||||
["wasmtime debug participant carries local values", /local_values: snapshot\.local_values\.clone\(\)/],
|
||||
["wasmtime runtime resumes after freeze", /epoch\.continue_all\(\)/],
|
||||
]) {
|
||||
expect(nodeLib, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["wasmtime smoke runs debug freeze resume mode", /--debug-freeze-resume/],
|
||||
["wasmtime smoke verifies frozen state", /debugReport\.frozen_state, "Frozen"/],
|
||||
["wasmtime smoke verifies resumed state", /debugReport\.resumed_state, "Running"/],
|
||||
["wasmtime smoke verifies frame local values", /debugReport\.local_values[\s\S]*wasm_local_0/],
|
||||
["wasmtime smoke proves node runtime reached wasm task", /node_runtime_reached_wasm_task/],
|
||||
["wasmtime smoke proves node captured locals", /node_runtime_captured_wasm_locals/],
|
||||
]) {
|
||||
expect(wasmtimeSmoke, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["debug model freezes wasm and command participants", /fn breakpoint_creates_all_stop_debug_epoch_for_wasm_and_command_tasks\(\)/],
|
||||
["debug model rejects unsupported freeze", /fn debug_epoch_reports_freeze_failure_instead_of_claiming_all_stop\(\)/],
|
||||
["debug model resumes frozen participants", /fn continue_resumes_every_frozen_participant\(\)/],
|
||||
["debug model includes captured locals", /local_values/],
|
||||
["wasm participants are modeled", /DebugParticipantKind::WasmTask/],
|
||||
["controlled native command participants are modeled", /DebugParticipantKind::ControlledNativeCommand/],
|
||||
]) {
|
||||
expect(debugCore, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["docs describe one node coordinator session", /keeps one node-to-coordinator JSON-line session open/],
|
||||
["docs describe cancellation terminal event", /records a cancelled terminal event/],
|
||||
["docs describe failed freeze diagnostic", /Failed debug freezes/],
|
||||
]) {
|
||||
expect(readme, name, pattern);
|
||||
}
|
||||
|
||||
console.log("Node lifecycle contract smoke passed");
|
||||
356
scripts/operator-panel-smoke.js
Executable file
356
scripts/operator-panel-smoke.js
Executable file
|
|
@ -0,0 +1,356 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const project = path.join(repo, "examples/launch-build-demo");
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function runNode(addr) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-node",
|
||||
"--",
|
||||
"--coordinator",
|
||||
`${addr.host}:${addr.port}`,
|
||||
"--tenant",
|
||||
"tenant",
|
||||
"--project-id",
|
||||
"project",
|
||||
"--node",
|
||||
"panel-node",
|
||||
"--process",
|
||||
"vp-panel",
|
||||
"--task",
|
||||
"compile-linux",
|
||||
"--project",
|
||||
project,
|
||||
"--artifact",
|
||||
"/vfs/artifacts/panel-output.txt"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`node process failed with code ${code}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split("\n").at(-1)));
|
||||
} catch (error) {
|
||||
reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function widget(panel, id) {
|
||||
const item = panel.widgets[id];
|
||||
assert(item, `missing panel widget ${id}`);
|
||||
return item;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let coordinatorStderr = "";
|
||||
coordinator.stderr.on("data", (chunk) => {
|
||||
coordinatorStderr += chunk.toString();
|
||||
});
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
const report = await runNode(addr);
|
||||
assert.strictEqual(report.node_status, "completed");
|
||||
assert.strictEqual(report.coordinator_response.type, "task_recorded");
|
||||
|
||||
const rendered = await send(addr, {
|
||||
type: "render_operator_panel",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-panel",
|
||||
actor_user: "user",
|
||||
max_download_bytes: 1024 * 1024,
|
||||
stopped: false
|
||||
});
|
||||
assert.strictEqual(rendered.type, "operator_panel");
|
||||
const panel = rendered.panel;
|
||||
assert.strictEqual(panel.tenant, "tenant");
|
||||
assert.strictEqual(panel.project, "project");
|
||||
assert.strictEqual(panel.process, "vp-panel");
|
||||
assert.strictEqual(panel.program_ui_events_enabled, true);
|
||||
|
||||
assert.deepStrictEqual(widget(panel, "process-status").kind, {
|
||||
Text: { value: "running" }
|
||||
});
|
||||
assert.deepStrictEqual(widget(panel, "task-progress").kind, {
|
||||
Progress: { current: 1, total: 1 }
|
||||
});
|
||||
assert.match(
|
||||
widget(panel, "task-summary").kind.Text.value,
|
||||
/compile-linux:Some\(0\):panel-node/
|
||||
);
|
||||
assert.match(widget(panel, "recent-logs").kind.Text.value, /stdout=\d+ stderr=\d+/);
|
||||
const downloadWidget = widget(panel, "download-artifact").kind;
|
||||
assert.deepStrictEqual(downloadWidget, {
|
||||
ArtifactDownload: { artifact: "panel-output.txt" }
|
||||
});
|
||||
assert(!JSON.stringify(downloadWidget).includes("url_path"));
|
||||
assert(!JSON.stringify(downloadWidget).includes("scoped_token_digest"));
|
||||
assert.deepStrictEqual(widget(panel, "debug-process").kind, {
|
||||
Button: { action: "debug-process" }
|
||||
});
|
||||
assert.deepStrictEqual(widget(panel, "cancel-process").kind, {
|
||||
Button: { action: "cancel-process" }
|
||||
});
|
||||
assert.deepStrictEqual(widget(panel, "restart-selected-task").kind, {
|
||||
Button: { action: "restart-task" }
|
||||
});
|
||||
assert(panel.control_plane_actions.includes("DebugProcess"));
|
||||
assert(panel.control_plane_actions.includes("CancelProcess"));
|
||||
assert(
|
||||
panel.control_plane_actions.some(
|
||||
(action) => action.RestartTask === "compile-linux"
|
||||
)
|
||||
);
|
||||
assert(
|
||||
panel.control_plane_actions.some(
|
||||
(action) => action.DownloadArtifact === "panel-output.txt"
|
||||
)
|
||||
);
|
||||
assert(!JSON.stringify(panel).includes("<script"));
|
||||
assert(!JSON.stringify(panel).toLowerCase().includes("oauth"));
|
||||
|
||||
const panelLink = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: downloadWidget.ArtifactDownload.artifact,
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "panel-button",
|
||||
now_epoch_seconds: 10,
|
||||
ttl_seconds: 60
|
||||
});
|
||||
assert.strictEqual(panelLink.type, "artifact_download_link");
|
||||
assert.strictEqual(panelLink.link.artifact, downloadWidget.ArtifactDownload.artifact);
|
||||
assert.match(panelLink.link.policy_context_digest, /^sha256:[0-9a-f]{64}$/);
|
||||
assert.deepStrictEqual(panelLink.link.source, { RetainedNode: "panel-node" });
|
||||
assert.match(panelLink.link.url_path, /\/artifacts\/tenant\/project\/vp-panel\/panel-output\.txt$/);
|
||||
|
||||
const panelStream = await send(addr, {
|
||||
type: "open_artifact_download_stream",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: downloadWidget.ArtifactDownload.artifact,
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "panel-button",
|
||||
token_digest: panelLink.link.scoped_token_digest,
|
||||
now_epoch_seconds: 11,
|
||||
chunk_bytes: 16
|
||||
});
|
||||
assert.strictEqual(panelStream.type, "artifact_download_stream");
|
||||
assert.strictEqual(panelStream.link.artifact, downloadWidget.ArtifactDownload.artifact);
|
||||
assert.deepStrictEqual(panelStream.link.source, { RetainedNode: "panel-node" });
|
||||
assert.strictEqual(panelStream.streamed_bytes, 16);
|
||||
|
||||
const apiTooLarge = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: downloadWidget.ArtifactDownload.artifact,
|
||||
max_bytes: 1,
|
||||
token_nonce: "too-small",
|
||||
now_epoch_seconds: 10,
|
||||
ttl_seconds: 60
|
||||
});
|
||||
assert.strictEqual(apiTooLarge.type, "error");
|
||||
assert.match(apiTooLarge.message, /exceeds download limit/);
|
||||
|
||||
const panelTooLarge = await send(addr, {
|
||||
type: "render_operator_panel",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-panel",
|
||||
actor_user: "user",
|
||||
max_download_bytes: 1,
|
||||
stopped: false
|
||||
});
|
||||
assert.strictEqual(panelTooLarge.type, "error");
|
||||
assert.match(panelTooLarge.message, /exceeds download limit/);
|
||||
|
||||
const accepted = await send(addr, {
|
||||
type: "submit_panel_event",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-panel",
|
||||
widget_id: "debug-process",
|
||||
kind: "ButtonClicked",
|
||||
max_events: 1
|
||||
});
|
||||
assert.strictEqual(accepted.type, "panel_event_accepted");
|
||||
assert.strictEqual(accepted.used_events, 1);
|
||||
assert.strictEqual(accepted.max_events, 1);
|
||||
|
||||
const rateLimited = await send(addr, {
|
||||
type: "submit_panel_event",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-panel",
|
||||
widget_id: "debug-process",
|
||||
kind: "ButtonClicked",
|
||||
max_events: 1
|
||||
});
|
||||
assert.strictEqual(rateLimited.type, "error");
|
||||
assert.match(rateLimited.message, /rate limit/i);
|
||||
|
||||
const stopped = await send(addr, {
|
||||
type: "render_operator_panel",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-panel",
|
||||
actor_user: "user",
|
||||
max_download_bytes: 1024 * 1024,
|
||||
stopped: true
|
||||
});
|
||||
assert.strictEqual(stopped.type, "operator_panel");
|
||||
assert.strictEqual(stopped.panel.program_ui_events_enabled, false);
|
||||
assert.deepStrictEqual(widget(stopped.panel, "process-status").kind, {
|
||||
Text: { value: "stopped" }
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
widget(stopped.panel, "task-progress").kind,
|
||||
widget(panel, "task-progress").kind
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
widget(stopped.panel, "task-summary").kind,
|
||||
widget(panel, "task-summary").kind
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
widget(stopped.panel, "recent-logs").kind,
|
||||
widget(panel, "recent-logs").kind
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
widget(stopped.panel, "download-artifact").kind,
|
||||
widget(panel, "download-artifact").kind
|
||||
);
|
||||
assert(stopped.panel.control_plane_actions.includes("DebugProcess"));
|
||||
assert(
|
||||
stopped.panel.control_plane_actions.some(
|
||||
(action) => action.DownloadArtifact === "panel-output.txt"
|
||||
)
|
||||
);
|
||||
|
||||
const frozenEvent = await send(addr, {
|
||||
type: "submit_panel_event",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-panel",
|
||||
widget_id: "debug-process",
|
||||
kind: "ButtonClicked",
|
||||
max_events: 10
|
||||
});
|
||||
assert.strictEqual(frozenEvent.type, "error");
|
||||
assert.match(frozenEvent.message, /program UI events are disabled/i);
|
||||
|
||||
const crossTenant = await send(addr, {
|
||||
type: "render_operator_panel",
|
||||
tenant: "other",
|
||||
project: "project",
|
||||
process: "vp-panel",
|
||||
actor_user: "user",
|
||||
max_download_bytes: 1024 * 1024,
|
||||
stopped: false
|
||||
});
|
||||
assert.strictEqual(crossTenant.type, "error");
|
||||
assert.match(crossTenant.message, /scope|tenant|project/i);
|
||||
} catch (error) {
|
||||
if (coordinatorStderr) {
|
||||
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("Operator panel smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
80
scripts/podman-backend-smoke.js
Executable file
80
scripts/podman-backend-smoke.js
Executable file
|
|
@ -0,0 +1,80 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const baseImage = "docker.io/library/alpine:3.20";
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
return cp.execFileSync(command, args, {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
stdio: options.stdio || ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
}
|
||||
|
||||
function incomplete(reason) {
|
||||
const error = new Error(`Linux Podman backend incomplete: ${reason}`);
|
||||
error.code = "DISASMER_PODMAN_INCOMPLETE";
|
||||
throw error;
|
||||
}
|
||||
|
||||
function ensurePodmanBaseImage() {
|
||||
try {
|
||||
run("podman", ["--version"]);
|
||||
} catch (error) {
|
||||
incomplete(`podman command is unavailable (${error.message})`);
|
||||
}
|
||||
|
||||
let rootless;
|
||||
try {
|
||||
rootless = run("podman", ["info", "--format", "{{.Host.Security.Rootless}}"]).trim();
|
||||
} catch (error) {
|
||||
incomplete(`podman info did not report rootless status (${error.message})`);
|
||||
}
|
||||
if (rootless !== "true") {
|
||||
incomplete(`podman is not running in rootless mode (reported ${JSON.stringify(rootless)})`);
|
||||
}
|
||||
|
||||
try {
|
||||
run("podman", ["image", "exists", baseImage]);
|
||||
} catch (_) {
|
||||
try {
|
||||
run("podman", ["pull", baseImage], { stdio: "inherit" });
|
||||
} catch (error) {
|
||||
incomplete(`unable to make ${baseImage} available (${error.message})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
ensurePodmanBaseImage();
|
||||
|
||||
const stdout = run("cargo", [
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-podman-smoke"
|
||||
]);
|
||||
const report = JSON.parse(stdout.trim().split("\n").at(-1));
|
||||
|
||||
assert.strictEqual(report.podman_status, "completed");
|
||||
assert.strictEqual(report.status_code, 0);
|
||||
assert.strictEqual(report.stdout, "podman-ok:node-local source\n");
|
||||
assert.strictEqual(report.large_bytes_uploaded, false);
|
||||
assert.strictEqual(report.uses_full_repo_tarball, false);
|
||||
assert.strictEqual(report.coordinator_routed_file_reads, false);
|
||||
assert.strictEqual(report.staged_artifact.path, "/vfs/artifacts/podman-smoke.txt");
|
||||
|
||||
console.log("Podman backend smoke passed");
|
||||
} catch (error) {
|
||||
if (error.code === "DISASMER_PODMAN_INCOMPLETE") {
|
||||
console.error(error.message);
|
||||
process.exit(2);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
562
scripts/prepare-public-release-dryrun.js
Executable file
562
scripts/prepare-public-release-dryrun.js
Executable file
|
|
@ -0,0 +1,562 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const crypto = require("crypto");
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const outputRoot = path.resolve(
|
||||
process.env.DISASMER_PUBLIC_RELEASE_DIR ||
|
||||
path.join(repo, "target/public-release-dryrun")
|
||||
);
|
||||
const publicTree = path.join(outputRoot, "public-tree");
|
||||
const assetsDir = path.join(outputRoot, "assets");
|
||||
const stagingDir = path.join(outputRoot, "staging");
|
||||
const defaultOperatorEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||
const forgejoHost = "git.michelpaulissen.com";
|
||||
const filteredOut = ["private", "experiments", ".git", "target"];
|
||||
const publicRepoBranch = process.env.DISASMER_PUBLIC_REPO_BRANCH || "main";
|
||||
const publicBinaries = [
|
||||
"disasmer",
|
||||
"disasmer-coordinator",
|
||||
"disasmer-node",
|
||||
"disasmer-debug-dap",
|
||||
];
|
||||
|
||||
function commandOutput(command, args, options = {}) {
|
||||
try {
|
||||
return cp
|
||||
.execFileSync(command, args, {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
...options,
|
||||
})
|
||||
.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
cp.execFileSync(command, args, {
|
||||
cwd: repo,
|
||||
stdio: "inherit",
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function copyFilteredTree(src, dest, relative = "") {
|
||||
ensureDir(dest);
|
||||
const entries = fs
|
||||
.readdirSync(src, { withFileTypes: true })
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
||||
const topLevel = childRelative.split(path.sep)[0];
|
||||
if (filteredOut.includes(topLevel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const from = path.join(src, entry.name);
|
||||
const to = path.join(dest, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
copyFilteredTree(from, to, childRelative);
|
||||
} else if (entry.isSymbolicLink()) {
|
||||
fs.symlinkSync(fs.readlinkSync(from), to);
|
||||
} else if (entry.isFile()) {
|
||||
fs.copyFileSync(from, to);
|
||||
fs.chmodSync(to, fs.statSync(from).mode & 0o777);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertFilteredTree() {
|
||||
for (const excluded of ["private", "experiments"]) {
|
||||
if (fs.existsSync(path.join(publicTree, excluded))) {
|
||||
throw new Error(`${excluded}/ leaked into the dry-run public tree`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function walkFiles(root, relative = "") {
|
||||
const dir = path.join(root, relative);
|
||||
const entries = fs
|
||||
.readdirSync(dir, { withFileTypes: true })
|
||||
.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()) {
|
||||
files.push(childRelative);
|
||||
} else if (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 sha256File(file) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
}
|
||||
|
||||
function tarGz(output, cwd, inputs) {
|
||||
run("tar", ["-czf", output, "-C", cwd, ...inputs]);
|
||||
}
|
||||
|
||||
function platformName() {
|
||||
return `${os.platform()}-${os.arch()}`;
|
||||
}
|
||||
|
||||
function binaryName(name) {
|
||||
return process.platform === "win32" ? `${name}.exe` : name;
|
||||
}
|
||||
|
||||
function buildPublicBinaries() {
|
||||
run("cargo", ["build", "--workspace", "--bins", "--release"], {
|
||||
cwd: publicTree,
|
||||
});
|
||||
}
|
||||
|
||||
function stageBinaryAssets(releaseName) {
|
||||
const stageRoot = path.join(stagingDir, "binaries");
|
||||
const binDir = path.join(stageRoot, "bin");
|
||||
fs.rmSync(stageRoot, { recursive: true, force: true });
|
||||
ensureDir(binDir);
|
||||
|
||||
for (const binary of publicBinaries) {
|
||||
const fileName = binaryName(binary);
|
||||
const built = path.join(publicTree, "target", "release", fileName);
|
||||
if (!fs.existsSync(built)) {
|
||||
throw new Error(`expected release binary ${built}`);
|
||||
}
|
||||
const staged = path.join(binDir, fileName);
|
||||
fs.copyFileSync(built, staged);
|
||||
fs.chmodSync(staged, 0o755);
|
||||
}
|
||||
|
||||
const archive = path.join(
|
||||
assetsDir,
|
||||
`disasmer-public-binaries-${releaseName}-${platformName()}.tar.gz`
|
||||
);
|
||||
tarGz(archive, stageRoot, ["."]);
|
||||
return archive;
|
||||
}
|
||||
|
||||
function stageSourceAsset(releaseName) {
|
||||
const archive = path.join(assetsDir, `disasmer-public-source-${releaseName}.tar.gz`);
|
||||
tarGz(archive, publicTree, ["."]);
|
||||
return archive;
|
||||
}
|
||||
|
||||
function stageExtensionAsset() {
|
||||
const packageJson = JSON.parse(
|
||||
fs.readFileSync(path.join(publicTree, "vscode-extension/package.json"), "utf8")
|
||||
);
|
||||
const archive = path.join(
|
||||
assetsDir,
|
||||
`${packageJson.name}-${packageJson.version}.vsix`
|
||||
);
|
||||
run(
|
||||
"npx",
|
||||
[
|
||||
"--yes",
|
||||
"@vscode/vsce",
|
||||
"package",
|
||||
"--allow-missing-repository",
|
||||
"--out",
|
||||
archive,
|
||||
],
|
||||
{ cwd: path.join(publicTree, "vscode-extension") }
|
||||
);
|
||||
return archive;
|
||||
}
|
||||
|
||||
function resolverInstructions() {
|
||||
if (process.env.DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS) {
|
||||
return process.env.DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS.trim();
|
||||
}
|
||||
if (process.env.DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY) {
|
||||
return [
|
||||
"Add the controlled hosts entry supplied for this dry run:",
|
||||
"",
|
||||
"```",
|
||||
process.env.DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY.trim(),
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
if (process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_IP) {
|
||||
return [
|
||||
"Add this controlled hosts entry for the dry run:",
|
||||
"",
|
||||
"```",
|
||||
`${process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_IP} disasmer.michelpaulissen.com`,
|
||||
"```",
|
||||
].join("\n");
|
||||
}
|
||||
return [
|
||||
"`disasmer.michelpaulissen.com` should resolve through public DNS. If it",
|
||||
"does not resolve yet, wait for DNS propagation or use the fallback hosts",
|
||||
"entry supplied with the invitation.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function writeGettingStartedAsset(releaseName, publicTreeIdentity, resolution) {
|
||||
const file = path.join(assetsDir, `DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-${releaseName}.md`);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
`# Disasmer Public Dry Run
|
||||
|
||||
This dry run is a real Disasmer deployment for selected external users. If
|
||||
public DNS has not propagated yet, use the fallback resolution instructions
|
||||
below.
|
||||
|
||||
The default operator is the private hosted coordinator at
|
||||
\`https://disasmer.michelpaulissen.com:9443\`. The word \`public\` in this dry run
|
||||
refers to the public Forgejo repository, release downloads, selected-user
|
||||
network access, and public client protocol compatibility. It does not mean the
|
||||
server is the standalone open-source/public coordinator.
|
||||
|
||||
## DNS
|
||||
|
||||
${resolution}
|
||||
|
||||
## Install
|
||||
|
||||
1. Download the binary archive for your platform from the Forgejo Release at
|
||||
\`git.michelpaulissen.com\`.
|
||||
2. Verify it against \`SHA256SUMS\`.
|
||||
3. Extract the archive and put the \`bin/\` directory on your \`PATH\`.
|
||||
4. Download \`disasmer-vscode-*.vsix\` if you want the debugger and Disasmer
|
||||
side views, then install it:
|
||||
|
||||
\`\`\`bash
|
||||
code --install-extension disasmer-vscode-*.vsix
|
||||
\`\`\`
|
||||
|
||||
## Connect
|
||||
|
||||
Use the default operator endpoint:
|
||||
|
||||
\`\`\`bash
|
||||
disasmer login --browser
|
||||
disasmer bundle inspect --project examples/launch-build-demo
|
||||
\`\`\`
|
||||
|
||||
\`disasmer login --browser\` opens your browser through the barebones
|
||||
\`https://disasmer.michelpaulissen.com\` login site and returns to the CLI
|
||||
through a local callback.
|
||||
|
||||
Enroll a node identity with the grant supplied by the invitation:
|
||||
|
||||
\`\`\`bash
|
||||
disasmer node attach --coordinator https://disasmer.michelpaulissen.com:9443 --enrollment-grant <grant> --public-key <public-key>
|
||||
\`\`\`
|
||||
|
||||
That command proves the identity/enrollment path and then exits. To actually run
|
||||
work for the flagship workflow, leave a worker process running in another
|
||||
terminal. Use a fresh enrollment grant, or skip the attach command above and use
|
||||
the worker command directly:
|
||||
|
||||
\`\`\`bash
|
||||
disasmer-node --coordinator https://disasmer.michelpaulissen.com:9443 --tenant <tenant> --project-id <project> --node <node-id> --public-key <public-key> --enrollment-grant <grant> --worker --emit-ready
|
||||
\`\`\`
|
||||
|
||||
Then run the flagship workflow from the filtered public repository while that
|
||||
worker is still running:
|
||||
|
||||
\`\`\`bash
|
||||
disasmer run --project examples/launch-build-demo build
|
||||
\`\`\`
|
||||
|
||||
The dry-run public tree identity is \`${publicTreeIdentity}\`.
|
||||
The release name is \`${releaseName}\`.
|
||||
`,
|
||||
"utf8"
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
function writeInviteAsset(releaseName, publicTreeIdentity, resolution) {
|
||||
const file = path.join(assetsDir, `DISASMER_PUBLIC_DRYRUN_INVITE-${releaseName}.md`);
|
||||
const publicRepo =
|
||||
process.env.DISASMER_PUBLIC_REPO_URL ||
|
||||
"https://git.michelpaulissen.com/<owner>/<public-repo>";
|
||||
const releaseUrl =
|
||||
process.env.DISASMER_FORGEJO_RELEASE_URL ||
|
||||
"the Forgejo Release attached to the public repository";
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
`# Disasmer Public Dry Run Invite
|
||||
|
||||
This invite is for selected external users, such as friends helping test the
|
||||
MVP release experience. The deployment is real and externally reachable, but it
|
||||
is intentionally not broadly advertised yet.
|
||||
|
||||
The default operator behind this invite is the private hosted coordinator at
|
||||
\`https://disasmer.michelpaulissen.com:9443\`. The public part is the Forgejo
|
||||
repository, release downloads, network-reachable dry run, and public client
|
||||
protocol used by the binaries; the hosted server is not the standalone public
|
||||
coordinator.
|
||||
|
||||
If public DNS has not propagated yet, make sure \`disasmer.michelpaulissen.com\`
|
||||
resolves to the deployment host before running the CLI.
|
||||
|
||||
## Links
|
||||
|
||||
- Public repository: ${publicRepo}
|
||||
- Release downloads: ${releaseUrl}
|
||||
- Default operator endpoint: ${defaultOperatorEndpoint}
|
||||
- Public tree identity: ${publicTreeIdentity}
|
||||
- Release name: ${releaseName}
|
||||
|
||||
## DNS
|
||||
|
||||
${resolution}
|
||||
|
||||
## First run
|
||||
|
||||
1. Download the binary archive for your platform and \`SHA256SUMS\` from the
|
||||
Forgejo Release.
|
||||
2. Extract the archive and put \`bin/\` on your \`PATH\`.
|
||||
3. Optionally install \`disasmer-vscode-*.vsix\` with
|
||||
\`code --install-extension disasmer-vscode-*.vsix\`.
|
||||
4. Run \`disasmer login --browser\`; it opens the barebones
|
||||
\`https://disasmer.michelpaulissen.com\` login site and returns to the CLI
|
||||
through a local callback.
|
||||
5. Enroll a node identity with the enrollment grant supplied out of band.
|
||||
6. Start a long-lived worker with a fresh enrollment grant, or use the worker
|
||||
command directly instead of step 5:
|
||||
\`disasmer-node --coordinator https://disasmer.michelpaulissen.com:9443 --tenant <tenant> --project-id <project> --node <node-id> --public-key <public-key> --enrollment-grant <grant> --worker --emit-ready\`.
|
||||
7. Run \`disasmer run --project examples/launch-build-demo build\` from the
|
||||
public repository checkout while the worker process is still running.
|
||||
`,
|
||||
"utf8"
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
function writeSha256Sums(assets) {
|
||||
const sumsPath = path.join(assetsDir, "SHA256SUMS");
|
||||
const lines = assets
|
||||
.map((asset) => `${sha256File(asset)} ${path.basename(asset)}`)
|
||||
.sort()
|
||||
.join("\n");
|
||||
fs.writeFileSync(sumsPath, `${lines}\n`);
|
||||
return sumsPath;
|
||||
}
|
||||
|
||||
function boolEnv(name) {
|
||||
return /^(1|true|yes)$/i.test(process.env[name] || "");
|
||||
}
|
||||
|
||||
function writePublicTreeProvenance(sourceCommit, releaseName) {
|
||||
const provenance = {
|
||||
kind: "disasmer-filtered-public-tree",
|
||||
source_commit: sourceCommit,
|
||||
release_name: releaseName,
|
||||
filtered_out: ["private/**", "experiments/**", ".git", "target"],
|
||||
forgejo_host: forgejoHost,
|
||||
default_operator_endpoint: defaultOperatorEndpoint,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(publicTree, "DISASMER_PUBLIC_TREE.json"),
|
||||
`${JSON.stringify(provenance, null, 2)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
function publishPublicTree(releaseName, sourceCommit, publicTreeIdentity) {
|
||||
const remote =
|
||||
process.env.DISASMER_PUBLIC_REPO_REMOTE ||
|
||||
process.env.DISASMER_PUBLIC_REPO_URL ||
|
||||
null;
|
||||
const enabled = boolEnv("DISASMER_PUBLISH_PUBLIC_TREE");
|
||||
const result = {
|
||||
enabled,
|
||||
remote,
|
||||
branch: publicRepoBranch,
|
||||
commit: null,
|
||||
pushed: false,
|
||||
};
|
||||
|
||||
if (!enabled) {
|
||||
return result;
|
||||
}
|
||||
if (!remote) {
|
||||
throw new Error(
|
||||
"DISASMER_PUBLISH_PUBLIC_TREE requires DISASMER_PUBLIC_REPO_REMOTE or DISASMER_PUBLIC_REPO_URL"
|
||||
);
|
||||
}
|
||||
if (!remote.includes(forgejoHost)) {
|
||||
throw new Error(`public repo remote must point at ${forgejoHost}: ${remote}`);
|
||||
}
|
||||
|
||||
run("git", ["init"], { cwd: publicTree });
|
||||
run("git", ["checkout", "-B", publicRepoBranch], { cwd: publicTree });
|
||||
run("git", ["config", "user.name", "Disasmer release dry run"], {
|
||||
cwd: publicTree,
|
||||
});
|
||||
run("git", ["config", "user.email", "release-dryrun@disasmer.invalid"], {
|
||||
cwd: publicTree,
|
||||
});
|
||||
run("git", ["add", "."], { cwd: publicTree });
|
||||
run(
|
||||
"git",
|
||||
[
|
||||
"commit",
|
||||
"-m",
|
||||
`Public dry run ${releaseName}`,
|
||||
"-m",
|
||||
`Source commit: ${sourceCommit}`,
|
||||
"-m",
|
||||
`Public tree identity: ${publicTreeIdentity}`,
|
||||
],
|
||||
{ cwd: publicTree }
|
||||
);
|
||||
result.commit = commandOutput("git", ["rev-parse", "HEAD"], { cwd: publicTree });
|
||||
run("git", ["remote", "add", "public", remote], { cwd: publicTree });
|
||||
const pushArgs = ["push", "public", `HEAD:${publicRepoBranch}`];
|
||||
if (boolEnv("DISASMER_PUBLIC_REPO_PUSH_FORCE_WITH_LEASE")) {
|
||||
run(
|
||||
"git",
|
||||
[
|
||||
"fetch",
|
||||
"public",
|
||||
`refs/heads/${publicRepoBranch}:refs/remotes/public/${publicRepoBranch}`,
|
||||
],
|
||||
{ cwd: publicTree }
|
||||
);
|
||||
pushArgs.splice(1, 0, "--force-with-lease");
|
||||
}
|
||||
run("git", pushArgs, { cwd: publicTree });
|
||||
result.pushed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const sourceCommit =
|
||||
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
||||
"unknown";
|
||||
const shortCommit = sourceCommit === "unknown" ? "unknown" : sourceCommit.slice(0, 12);
|
||||
const releaseName = process.env.DISASMER_PUBLIC_RELEASE_NAME || `dryrun-${shortCommit}`;
|
||||
const sourceStatus = commandOutput("git", ["status", "--short"]);
|
||||
const sourceTreeClean = sourceStatus === null ? null : sourceStatus === "";
|
||||
|
||||
fs.rmSync(outputRoot, { recursive: true, force: true });
|
||||
ensureDir(publicTree);
|
||||
ensureDir(assetsDir);
|
||||
ensureDir(stagingDir);
|
||||
|
||||
copyFilteredTree(repo, publicTree);
|
||||
assertFilteredTree();
|
||||
writePublicTreeProvenance(sourceCommit, releaseName);
|
||||
const publicTreeIdentity = hashTree(publicTree);
|
||||
const sourceArchive = stageSourceAsset(releaseName);
|
||||
|
||||
run("node", ["scripts/public-release-dryrun-contract-smoke.js"], {
|
||||
cwd: publicTree,
|
||||
});
|
||||
const publicTreePublish = publishPublicTree(
|
||||
releaseName,
|
||||
sourceCommit,
|
||||
publicTreeIdentity
|
||||
);
|
||||
buildPublicBinaries();
|
||||
const binaryArchive = stageBinaryAssets(releaseName);
|
||||
const extensionArchive = stageExtensionAsset();
|
||||
const resolution = resolverInstructions();
|
||||
const gettingStarted = writeGettingStartedAsset(
|
||||
releaseName,
|
||||
publicTreeIdentity,
|
||||
resolution
|
||||
);
|
||||
const invite = writeInviteAsset(releaseName, publicTreeIdentity, resolution);
|
||||
const assets = [
|
||||
sourceArchive,
|
||||
binaryArchive,
|
||||
extensionArchive,
|
||||
gettingStarted,
|
||||
invite,
|
||||
];
|
||||
const sha256Sums = writeSha256Sums(assets);
|
||||
|
||||
const manifest = {
|
||||
kind: "disasmer-public-release-dryrun",
|
||||
release_name: releaseName,
|
||||
source_commit: sourceCommit,
|
||||
source_tree_clean: sourceTreeClean,
|
||||
public_tree_identity: publicTreeIdentity,
|
||||
public_tree: publicTree,
|
||||
filtered_out: ["private/**", "experiments/**", ".git", "target"],
|
||||
forgejo_host: forgejoHost,
|
||||
public_repo_url: process.env.DISASMER_PUBLIC_REPO_URL || publicTreePublish.remote,
|
||||
public_repo_remote: process.env.DISASMER_PUBLIC_REPO_REMOTE || null,
|
||||
public_tree_publish: publicTreePublish,
|
||||
forgejo_release_url: process.env.DISASMER_FORGEJO_RELEASE_URL || null,
|
||||
default_operator_endpoint: defaultOperatorEndpoint,
|
||||
dns_publication_state:
|
||||
process.env.DISASMER_DNS_PUBLICATION_STATE || "published",
|
||||
resolver_override:
|
||||
process.env.DISASMER_RESOLVER_OVERRIDE || "none-required-public-dns",
|
||||
platform: platformName(),
|
||||
tool_versions: {
|
||||
node: process.version,
|
||||
rustc: commandOutput("rustc", ["--version"]) || null,
|
||||
cargo: commandOutput("cargo", ["--version"]) || null,
|
||||
tar: commandOutput("tar", ["--version"]) || null,
|
||||
},
|
||||
commands: [
|
||||
"node scripts/public-release-dryrun-contract-smoke.js",
|
||||
...(publicTreePublish.enabled
|
||||
? [`git push public HEAD:${publicRepoBranch}`]
|
||||
: []),
|
||||
"cargo build --workspace --bins --release",
|
||||
],
|
||||
assets: [...assets, sha256Sums].map((asset) => ({
|
||||
file: asset,
|
||||
name: path.basename(asset),
|
||||
sha256: sha256File(asset),
|
||||
})),
|
||||
notes: [
|
||||
"Upload the assets to the Forgejo Release for the filtered public repository.",
|
||||
"The real service deployment and full e2e dry run remain separate acceptance evidence.",
|
||||
],
|
||||
};
|
||||
|
||||
const manifestPath = path.join(outputRoot, "public-release-manifest.json");
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ manifest: manifestPath, assets: assetsDir }, null, 2));
|
||||
}
|
||||
|
||||
main();
|
||||
71
scripts/public-browser-login-contract-smoke.js
Normal file
71
scripts/public-browser-login-contract-smoke.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const infraRepo = path.resolve(repo, "..", "michelpaulissen.com");
|
||||
|
||||
function read(relativePath, base = repo) {
|
||||
return fs.readFileSync(path.join(base, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing public browser login evidence: ${name}`);
|
||||
}
|
||||
|
||||
const cliSource = read("crates/disasmer-cli/src/main.rs");
|
||||
const cliSmoke = read("scripts/cli-browser-login-flow-smoke.js");
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const phase2 = read("acceptance_criteria_phase2.md");
|
||||
const base = read("acceptance_criteria.md");
|
||||
|
||||
for (const [name, source] of [
|
||||
["base acceptance", base],
|
||||
["phase 2 acceptance", phase2],
|
||||
]) {
|
||||
expect(source, `${name} released binary browser criterion`, /Public released binaries default to a real human browser\/account flow/);
|
||||
}
|
||||
|
||||
expect(cliSource, "browser login start constant", /DEFAULT_BROWSER_LOGIN_START: &str = "https:\/\/disasmer\.michelpaulissen\.com\/auth\/browser\/start"/);
|
||||
expect(cliSource, "default OIDC issuer", /DEFAULT_OIDC_ISSUER_URL: &str = "https:\/\/auth\.michelpaulissen\.com"/);
|
||||
expect(cliSource, "fixed localhost callback", /BROWSER_CALLBACK_ADDR: &str = "127\.0\.0\.1:45173"/);
|
||||
expect(cliSource, "diagnostic plan flag", /plan: bool/);
|
||||
expect(cliSource, "interactive browser branch", /args\.browser && !args\.plan[\s\S]*execute_interactive_browser_login/);
|
||||
expect(cliSource, "browser command override", /DISASMER_BROWSER_OPEN_COMMAND/);
|
||||
expect(cliSource, "callback listener", /TcpListener::bind\(BROWSER_CALLBACK_ADDR\)/);
|
||||
expect(cliSource, "callback completion", /execute_browser_login_completion_for_plan\(args, plan\)/);
|
||||
expect(cliSmoke, "smoke uses fake browser opener", /DISASMER_BROWSER_OPEN_COMMAND/);
|
||||
expect(cliSmoke, "smoke verifies callback code", /browser-smoke-code/);
|
||||
expect(cliSmoke, "smoke verifies coordinator completion", /scoped_cli_session_received/);
|
||||
expect(publicAcceptance, "public acceptance runs browser flow smoke", /node scripts\/cli-browser-login-flow-smoke\.js/);
|
||||
|
||||
if (fs.existsSync(infraRepo)) {
|
||||
const stack = read("modules/stack.nix", infraRepo);
|
||||
const hypervisor = read("hosts/hypervisor/default.nix", infraRepo);
|
||||
const oauth = read("modules/oauth-bootstrap.nix", infraRepo);
|
||||
const site = read("disasmer-site/index.html", infraRepo);
|
||||
|
||||
expect(stack, "Disasmer public host", /publicHost = "disasmer\.michelpaulissen\.com"/);
|
||||
expect(stack, "Disasmer API port", /apiPort = 9443/);
|
||||
expect(hypervisor, "nginx Disasmer vhost", /\$\{stack\.hosts\.disasmer\.publicHost\}/);
|
||||
expect(hypervisor, "nginx Disasmer site root", /root = \.\.\/\.\.\/disasmer-site/);
|
||||
expect(hypervisor, "nginx browser start route", /locations\."= \/auth\/browser\/start"\.return/);
|
||||
expect(hypervisor, "nginx redirects to Authentik", /https:\/\/\$\{stack\.hosts\.authentik\.publicHost\}\/application\/o\/authorize/);
|
||||
expect(hypervisor, "nginx passes state", /state=\$arg_state/);
|
||||
expect(hypervisor, "nginx passes redirect URI", /redirect_uri=\$arg_redirect_uri/);
|
||||
expect(hypervisor, "hosted service uses configured API port", /stack\.hosts\.disasmer\.apiPort/);
|
||||
expect(oauth, "Disasmer Authentik provider", /name: disasmer-provider/);
|
||||
expect(oauth, "Disasmer public OIDC client", /client_type: public/);
|
||||
expect(oauth, "Disasmer client id", /client_id: \${disasmerClientId}/);
|
||||
expect(oauth, "Disasmer localhost redirect", /http:\/\/127\.0\.0\.1:45173\/callback/);
|
||||
expect(oauth, "Disasmer Authentik application", /slug: disasmer/);
|
||||
expect(site, "barebones site describes CLI login", /disasmer login --browser/);
|
||||
expect(site, "barebones site states UX deferral", /Layout and UX work are deferred/);
|
||||
assert.doesNotMatch(site, /<style|stylesheet|\.css|style=|class=/i, "Disasmer dry-run site must remain barebones HTML with no CSS");
|
||||
} else {
|
||||
console.warn("Skipping VPS config checks because ../michelpaulissen.com is not present");
|
||||
}
|
||||
|
||||
console.log("Public browser login contract smoke passed");
|
||||
69
scripts/public-local-demo-matrix-smoke.js
Executable file
69
scripts/public-local-demo-matrix-smoke.js
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing public local demo evidence: ${name}`);
|
||||
}
|
||||
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
const cliInstall = read("scripts/cli-install-smoke.js");
|
||||
const flagship = read("scripts/flagship-demo-smoke.js");
|
||||
const cliLocalRun = read("scripts/cli-local-run-smoke.js");
|
||||
const nodeAttach = read("scripts/node-attach-smoke.js");
|
||||
const vscodeExtension = read("scripts/vscode-extension-smoke.js");
|
||||
const vscodeF5 = read("scripts/vscode-f5-smoke.js");
|
||||
const artifactDownload = read("scripts/artifact-download-smoke.js");
|
||||
const artifactExport = read("scripts/artifact-export-smoke.js");
|
||||
|
||||
for (const script of [publicAcceptance, publicSplit]) {
|
||||
for (const smoke of [
|
||||
"scripts/cli-install-smoke.js",
|
||||
"scripts/flagship-demo-smoke.js",
|
||||
"scripts/cli-local-run-smoke.js",
|
||||
"scripts/node-attach-smoke.js",
|
||||
"scripts/vscode-extension-smoke.js",
|
||||
"scripts/vscode-f5-smoke.js",
|
||||
"scripts/artifact-download-smoke.js",
|
||||
"scripts/artifact-export-smoke.js",
|
||||
]) {
|
||||
assert(script.includes(`node ${smoke}`), `public demo gate must run ${smoke}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(cliInstall, "CLI install from project path", /cargo[\s\S]*install[\s\S]*crates\/disasmer-cli/);
|
||||
expect(cliInstall, "CLI install smoke targets flagship project", /const project = path\.join\(repo, "examples\/launch-build-demo"\)/);
|
||||
expect(cliInstall, "installed CLI inspects flagship project", /installedBin[\s\S]*\["bundle", "inspect", "--project", project\]/);
|
||||
|
||||
expect(flagship, "flagship project source is Rust workflow", /examples\/launch-build-demo[\s\S]*src\/build\.rs/);
|
||||
expect(flagship, "flagship source avoids local machine assumptions", /forbiddenSourceAssumptions/);
|
||||
expect(flagship, "flagship cargo test runs", /cargo[\s\S]*test[\s\S]*launch-build-demo/);
|
||||
|
||||
expect(cliLocalRun, "local run starts node process", /cli_process_started_node_process[\s\S]*true/);
|
||||
expect(cliLocalRun, "local run records logs and metadata", /log_event_response[\s\S]*task_log_recorded[\s\S]*vfs_metadata_response[\s\S]*vfs_metadata_recorded/);
|
||||
expect(cliLocalRun, "local run stages artifact", /\/vfs\/artifacts\/cli-run-output\.txt/);
|
||||
|
||||
expect(nodeAttach, "Linux node attach creates enrollment grant", /create_node_enrollment_grant/);
|
||||
expect(nodeAttach, "Linux node attach uses enrollment exchange", /used_enrollment_exchange[\s\S]*true/);
|
||||
expect(nodeAttach, "attached Linux node runs work", /runAttachedNodeWork[\s\S]*node_status[\s\S]*completed/);
|
||||
|
||||
expect(vscodeExtension, "extension contributes debugger", /contributes\.debuggers[\s\S]*type === "disasmer"/);
|
||||
expect(vscodeF5, "F5 uses local-services backend", /runtimeBackend[\s\S]*local-services/);
|
||||
expect(vscodeF5, "F5 exposes virtual thread", /compile linux[\s\S]*virtual thread/);
|
||||
expect(vscodeF5, "F5 records coordinator task event", /coordinator_task_events[\s\S]*value === 1/);
|
||||
|
||||
expect(artifactDownload, "artifact download creates scoped link", /create_artifact_download_link/);
|
||||
expect(artifactDownload, "artifact download opens stream", /open_artifact_download_stream/);
|
||||
expect(artifactExport, "artifact export targets receiver node", /export_artifact_to_node[\s\S]*node-export-receiver/);
|
||||
expect(artifactExport, "artifact export rejects coordinator bulk relay", /coordinator_bulk_relay_allowed[\s\S]*false/);
|
||||
|
||||
console.log("Public local demo matrix smoke passed");
|
||||
182
scripts/public-private-boundary-smoke.js
Normal file
182
scripts/public-private-boundary-smoke.js
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function walk(relativePath, files = []) {
|
||||
const fullPath = path.join(repo, relativePath);
|
||||
if (!fs.existsSync(fullPath)) return files;
|
||||
const stat = fs.statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
const base = path.basename(relativePath);
|
||||
if (["target", "node_modules", ".git"].includes(base)) return files;
|
||||
for (const entry of fs.readdirSync(fullPath)) {
|
||||
walk(path.join(relativePath, entry), files);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
files.push(relativePath);
|
||||
return files;
|
||||
}
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function copyPublicTree(sourceRoot, destinationRoot, relativePath = ".") {
|
||||
const skippedDirectories = new Set([
|
||||
".git",
|
||||
"target",
|
||||
"node_modules",
|
||||
"private",
|
||||
"experiments",
|
||||
]);
|
||||
const parts = relativePath.split(path.sep).filter(Boolean);
|
||||
if (parts.some((part) => skippedDirectories.has(part))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const source = path.join(sourceRoot, relativePath);
|
||||
const destination = path.join(destinationRoot, relativePath);
|
||||
const stat = fs.statSync(source);
|
||||
if (stat.isDirectory()) {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
for (const entry of fs.readdirSync(source)) {
|
||||
copyPublicTree(sourceRoot, destinationRoot, path.join(relativePath, entry));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
||||
fs.copyFileSync(source, destination);
|
||||
}
|
||||
|
||||
function assertPublicSplitTreeIsCoherent() {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-public-split-"));
|
||||
try {
|
||||
copyPublicTree(repo, tmp);
|
||||
|
||||
for (const forbidden of ["private", "experiments"]) {
|
||||
assert(
|
||||
!fs.existsSync(path.join(tmp, forbidden)),
|
||||
`${forbidden} must be absent from the public split tree`
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceToml = fs.readFileSync(path.join(tmp, "Cargo.toml"), "utf8");
|
||||
const membersBlock = workspaceToml.match(/members\s*=\s*\[([\s\S]*?)\]/);
|
||||
assert(membersBlock, "public workspace must define members");
|
||||
const members = [...membersBlock[1].matchAll(/"([^"]+)"/g)].map(
|
||||
(match) => match[1]
|
||||
);
|
||||
assert(members.length > 0, "public workspace must list members");
|
||||
for (const member of members) {
|
||||
assert(
|
||||
!member.startsWith("private/") && !member.startsWith("experiments/"),
|
||||
`public workspace member must not point at filtered source: ${member}`
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(path.join(tmp, member, "Cargo.toml")),
|
||||
`public workspace member is missing after split: ${member}`
|
||||
);
|
||||
}
|
||||
|
||||
for (const required of [
|
||||
"scripts/acceptance-public.sh",
|
||||
"scripts/verify-public-split.sh",
|
||||
"scripts/public-private-boundary-smoke.js",
|
||||
"scripts/public-local-demo-matrix-smoke.js",
|
||||
"vscode-extension/package.json",
|
||||
"examples/launch-build-demo/Cargo.toml",
|
||||
]) {
|
||||
assert(
|
||||
fs.existsSync(path.join(tmp, required)),
|
||||
`public split tree is missing ${required}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function publicSourceFiles() {
|
||||
return [
|
||||
...walk("crates"),
|
||||
...walk("examples"),
|
||||
...walk("scripts"),
|
||||
...walk("vscode-extension"),
|
||||
"Cargo.toml",
|
||||
].filter((file) => {
|
||||
if (file === "scripts/acceptance-private.sh") return false;
|
||||
if (file === "scripts/acceptance-evidence-contract-smoke.js") return false;
|
||||
if (file === "scripts/acceptance-environment-contract-smoke.js") return false;
|
||||
if (file === "scripts/docs-smoke.js") return false;
|
||||
if (file === "scripts/public-private-boundary-smoke.js") return false;
|
||||
if (file === "scripts/release-blocker-smoke.js") return false;
|
||||
return /\.(rs|toml|js|json|sh)$/.test(file);
|
||||
});
|
||||
}
|
||||
|
||||
const forbiddenPublicPatterns = [
|
||||
/\bdisasmer[_-]hosted[_-]policy\b/,
|
||||
/private\/hosted-policy/,
|
||||
/path\s*=\s*["'][^"']*private\//,
|
||||
/include!\s*\([^)]*private\//,
|
||||
/mod\s+private_hosted/,
|
||||
];
|
||||
|
||||
for (const file of publicSourceFiles()) {
|
||||
const content = read(file);
|
||||
for (const pattern of forbiddenPublicPatterns) {
|
||||
assert(
|
||||
!pattern.test(content),
|
||||
`public source ${file} must not reference private hosted code via ${pattern}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = read("Cargo.toml");
|
||||
assert(
|
||||
!/members\s*=\s*\[[\s\S]*private\//.test(workspace),
|
||||
"public workspace members must not include private hosted crates"
|
||||
);
|
||||
assertPublicSplitTreeIsCoherent();
|
||||
|
||||
const privateHostedRoot = path.join(repo, "private", "hosted-policy");
|
||||
if (fs.existsSync(privateHostedRoot)) {
|
||||
const privateCargo = read("private/hosted-policy/Cargo.toml");
|
||||
assert.match(privateCargo, /name\s*=\s*"disasmer-hosted-policy"/);
|
||||
assert.match(privateCargo, /disasmer-core\s*=\s*\{/);
|
||||
assert.match(privateCargo, /disasmer-coordinator\s*=\s*\{/);
|
||||
|
||||
const privateLib = read("private/hosted-policy/src/lib.rs");
|
||||
for (const required of [
|
||||
"AuthentikOidcConfig",
|
||||
"CommunityTierPolicy",
|
||||
"AdminControls",
|
||||
"preflight_zero_capability_hosted_wasm",
|
||||
"impl CapabilityPolicy for CommunityTierPolicy",
|
||||
"AuthContext",
|
||||
]) {
|
||||
assert(
|
||||
privateLib.includes(required),
|
||||
`private hosted policy is missing ${required}`
|
||||
);
|
||||
}
|
||||
|
||||
const privateFiles = walk("private").filter((file) => !file.includes("/target/"));
|
||||
const privateNodeRuntimeFiles = privateFiles.filter((file) =>
|
||||
/(^|\/)(disasmer-node|node-runtime)(\/|$)/.test(file)
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
privateNodeRuntimeFiles,
|
||||
[],
|
||||
"hosted mode must not carry a forked private node runtime"
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Public/private boundary smoke passed");
|
||||
256
scripts/public-release-dryrun-contract-smoke.js
Normal file
256
scripts/public-release-dryrun-contract-smoke.js
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const serviceEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||
const serviceHost = "disasmer.michelpaulissen.com";
|
||||
const forgejoHost = "git.michelpaulissen.com";
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing public release dry-run evidence: ${name}`);
|
||||
}
|
||||
|
||||
const phase2 = read("acceptance_criteria_phase2.md");
|
||||
const readme = read("README.md");
|
||||
const cliSource = read("crates/disasmer-cli/src/main.rs");
|
||||
const cliLoginSmoke = read("scripts/cli-login-smoke.js");
|
||||
const vscodePackage = JSON.parse(read("vscode-extension/package.json"));
|
||||
const vscodeExtension = read("vscode-extension/extension.js");
|
||||
const vscodeSmoke = read("scripts/vscode-extension-smoke.js");
|
||||
const prepScript = read("scripts/prepare-public-release-dryrun.js");
|
||||
const publishScript = read("scripts/publish-public-release-dryrun.js");
|
||||
const preflightScript = read("scripts/public-release-dryrun-preflight.js");
|
||||
const e2eScript = read("scripts/public-release-dryrun-e2e.js");
|
||||
const finalEvidenceScript = read("scripts/public-release-dryrun-final-evidence.js");
|
||||
const workflow = read(".forgejo/workflows/public-release-dryrun.yml");
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
|
||||
for (const [name, source] of [
|
||||
["phase 2 criteria", phase2],
|
||||
["README", readme],
|
||||
]) {
|
||||
expect(source, name, new RegExp(serviceHost.replaceAll(".", "\\.")));
|
||||
expect(source, `${name} DNS publication state`, /DNS record|public DNS|dns[-_]publication/i);
|
||||
expect(source, `${name} resolver fallback`, /no resolver override|required resolver override|hosts entry|controlled resolution|fallback/i);
|
||||
expect(source, `${name} Forgejo host`, new RegExp(forgejoHost.replaceAll(".", "\\.")));
|
||||
expect(source, `${name} Forgejo Release`, /Forgejo Release/);
|
||||
expect(source, `${name} compiled assets`, /compiled\s+(release\s+)?assets|compiled release assets/);
|
||||
expect(source, `${name} filtered tree`, /private\/\*\*[\s\S]*experiments\/\*\*/);
|
||||
expect(source, `${name} GitHub release out of scope`, /GitHub[\s\S]*outside this dry run|public GitHub-release[\s\S]*out of scope/);
|
||||
}
|
||||
|
||||
expect(cliSource, "CLI default operator constant", new RegExp(`DEFAULT_OPERATOR_ENDPOINT: &str = "${serviceEndpoint.replaceAll(".", "\\.")}"`));
|
||||
expect(cliSource, "login uses default operator", /default_value_t = default_operator_endpoint\(\)/);
|
||||
expect(cliSource, "hosted run records operator endpoint", /operator_endpoint[\s\S]*Some\(default_operator_endpoint\(\)\)/);
|
||||
expect(cliSource, "hosted URL maps to JSON-line transport", /fn json_line_transport_addr[\s\S]*\("https:\/\/", 443\)/);
|
||||
expect(cliSource, "JSON-line session uses mapped transport", /TcpStream::connect\(&transport_addr\)/);
|
||||
expect(cliSource, "default operator maps to port 9443", /json_line_transport_addr\(DEFAULT_OPERATOR_ENDPOINT\)[\s\S]*"disasmer\.michelpaulissen\.com:9443"/);
|
||||
assert.doesNotMatch(cliSource, /coord\.disasmer\.invalid/);
|
||||
|
||||
expect(cliLoginSmoke, "CLI login smoke covers default operator", new RegExp(`defaultOperatorEndpoint = "${serviceEndpoint.replaceAll(".", "\\.")}"`));
|
||||
expect(vscodeExtension, "VS Code default operator constant", new RegExp(`DEFAULT_OPERATOR_ENDPOINT = "${serviceEndpoint.replaceAll(".", "\\.")}"`));
|
||||
assert.strictEqual(
|
||||
vscodePackage.contributes.debuggers[0].configurationAttributes.launch.properties.operatorEndpoint.default,
|
||||
serviceEndpoint
|
||||
);
|
||||
expect(vscodeSmoke, "VS Code smoke covers operator endpoint", /operatorEndpoint\.default/);
|
||||
|
||||
for (const binary of [
|
||||
"disasmer",
|
||||
"disasmer-coordinator",
|
||||
"disasmer-node",
|
||||
"disasmer-debug-dap",
|
||||
]) {
|
||||
expect(prepScript, `release prep includes ${binary}`, new RegExp(`"${binary}"`));
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["release prep filters private and experiments", /filteredOut = \["private", "experiments", "\.git", "target"\]/],
|
||||
["release prep builds public release bins", /cargo"[\s\S]*"build"[\s\S]*"--workspace"[\s\S]*"--bins"[\s\S]*"--release"/],
|
||||
["release prep writes binary archive", /disasmer-public-binaries-\$\{releaseName\}-\$\{platformName\(\)\}\.tar\.gz/],
|
||||
["release prep writes source archive", /disasmer-public-source-\$\{releaseName\}\.tar\.gz/],
|
||||
["release prep writes extension VSIX", /\$\{packageJson\.name\}-\$\{packageJson\.version\}\.vsix[\s\S]*@vscode\/vsce[\s\S]*package/],
|
||||
["release prep writes selected-user guide", /DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-\$\{releaseName\}\.md/],
|
||||
["release prep writes selected-user invite", /DISASMER_PUBLIC_DRYRUN_INVITE-\$\{releaseName\}\.md/],
|
||||
["release prep accepts resolver instructions", /DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS/],
|
||||
["release prep accepts hosts entry", /DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY/],
|
||||
["release prep accepts deployment IP", /DISASMER_PUBLIC_RELEASE_DRYRUN_IP/],
|
||||
["selected-user guide documents DNS or fallback", /public DNS[\s\S]*(fallback|hosts entry)|DNS record[\s\S]*controlled resolution/],
|
||||
["selected-user guide says private hosted coordinator", /default operator is the private hosted coordinator/],
|
||||
["selected-user guide rejects standalone public coordinator", /standalone open-source\/public coordinator/],
|
||||
["selected-user invite documents friends dry run", /friends helping test[\s\S]*not broadly advertised/],
|
||||
["selected-user invite says hosted server is private", /hosted server is not the standalone public\s+coordinator/],
|
||||
["selected-user guide documents default login", /disasmer login --browser/],
|
||||
["selected-user guide documents VSIX install", /code --install-extension disasmer-vscode-\*\.vsix/],
|
||||
["selected-user guide documents node attach", /disasmer node attach --coordinator https:\/\/disasmer\.michelpaulissen\.com:9443/],
|
||||
["release prep writes checksums", /SHA256SUMS/],
|
||||
["release prep writes manifest", /public-release-manifest\.json/],
|
||||
["release prep writes public tree provenance", /DISASMER_PUBLIC_TREE\.json/],
|
||||
["release prep records public tree identity", /public_tree_identity: publicTreeIdentity/],
|
||||
["release prep records DNS state", /dns_publication_state:/],
|
||||
["release prep records resolver override", /resolver_override:/],
|
||||
["release prep records Forgejo URLs", /public_repo_url:[\s\S]*public_repo_remote:[\s\S]*forgejo_release_url:/],
|
||||
["release prep requires publish opt-in", /DISASMER_PUBLISH_PUBLIC_TREE/],
|
||||
["release prep requires Forgejo remote", /remote\.includes\(forgejoHost\)/],
|
||||
["release prep can push filtered tree", /git"[\s\S]*"push"[\s\S]*"public"[\s\S]*`HEAD:\$\{publicRepoBranch\}`/],
|
||||
["release prep records publish result", /public_tree_publish: publicTreePublish/],
|
||||
["release prep publishes tree before building target", /const publicTreePublish = publishPublicTree[\s\S]*buildPublicBinaries\(\)/],
|
||||
]) {
|
||||
expect(prepScript, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["release publisher requires Forgejo token", /DISASMER_FORGEJO_TOKEN/],
|
||||
["release publisher infers Forgejo repo identity", /function resolveRepoIdentity\(manifest\)[\s\S]*parseForgejoRepoIdentity/],
|
||||
["release publisher uses manifest public repo URL", /manifest\.public_repo_url[\s\S]*manifest\.public_repo_remote/],
|
||||
["release publisher uses token auth", /Authorization: `token \$\{token\}`/],
|
||||
["release publisher lists releases", /\/repos\/\$\{encodeURIComponent\(owner\)\}\/\$\{encodeURIComponent\(repoName\)\}\/releases\?limit=100/],
|
||||
["release publisher creates release", /POST[\s\S]*\/repos\/\$\{encodeURIComponent\(owner\)\}\/\$\{encodeURIComponent\(repoName\)\}\/releases/],
|
||||
["release publisher reloads release details", /function loadRelease\(releaseId\)[\s\S]*\/releases\/\$\{releaseId\}/],
|
||||
["release publisher uploads assets", /releases\/\$\{release\.id\}\/assets\?name=\$\{encodeURIComponent\(asset\.name\)\}/],
|
||||
["release publisher uses attachment multipart field", /multipartFile\("attachment", asset\.file\)/],
|
||||
["release publisher skips already attached assets", /existingAssetByName\(release, asset\.name\)/],
|
||||
["release publisher rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/],
|
||||
["release publisher checks manifest kind", /manifest\.kind !== "disasmer-public-release-dryrun"/],
|
||||
["release publisher requires public tree push", /public tree must be pushed before publishing the Forgejo Release/],
|
||||
["release publisher writes evidence report", /public-release-dryrun-forgejo-release\.json/],
|
||||
["release publisher records reused assets", /reused_assets:/],
|
||||
]) {
|
||||
expect(publishScript, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["preflight rejects stale release manifests", /manifest\.source_commit[\s\S]*currentSourceCommit/],
|
||||
["preflight verifies public branch commit", /remoteMain[\s\S]*manifest\.public_tree_publish\.commit/],
|
||||
["preflight verifies local asset checksums", /parseSha256Sums[\s\S]*checksum mismatch/],
|
||||
["preflight records pending external gates", /external_gates:[\s\S]*forgejo_release_publication[\s\S]*public_release_e2e/],
|
||||
["preflight writes evidence report", /public-release-dryrun-preflight\.json/],
|
||||
]) {
|
||||
expect(preflightScript, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["e2e runner requires explicit opt-in", /DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1/],
|
||||
["e2e runner rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/],
|
||||
["e2e runner downloads Forgejo Release assets", /downloadReleaseAssets/],
|
||||
["e2e runner verifies SHA256SUMS", /verifyChecksums/],
|
||||
["e2e runner clones public repo", /"git", \["clone", "--depth", "1"/],
|
||||
["e2e runner rejects private tree leaks", /private[\s\S]*experiments[\s\S]*target/],
|
||||
["e2e runner checks public tree identity", /hashTree\(checkout\)[\s\S]*manifest\.public_tree_identity/],
|
||||
["e2e runner extracts public binaries", /tar"[\s\S]*"-xzf"/],
|
||||
["e2e runner loads public coordinator binary", /executable\(installDir, "disasmer-coordinator"\)/],
|
||||
["e2e runner checks default operator", /defaultLoginPlan\.coordinator[\s\S]*serviceEndpoint/],
|
||||
["e2e runner completes browser login", /--complete-browser-code/],
|
||||
["e2e runner uses public CLI node attach", /"node"[\s\S]*"attach"[\s\S]*serviceEndpoint/],
|
||||
["e2e runner starts public worker runtime", /workerArgs[\s\S]*"--worker"[\s\S]*cp\.spawn\(disasmerNode/],
|
||||
["e2e runner launches task through coordinator", /type: "launch_task"/],
|
||||
["e2e runner verifies assignment polling", /worker_assignment_poll_verified/],
|
||||
["e2e runner validates standalone public coordinator", /validateStandalonePublicCoordinator/],
|
||||
["e2e runner records public coordinator validation", /public_coordinator_validated/],
|
||||
["e2e runner verifies task events", /type: "list_task_events"/],
|
||||
["e2e runner verifies download link", /type: "create_artifact_download_link"/],
|
||||
["e2e runner verifies VS Code debugger", /scripts\/vscode-f5-smoke\.js/],
|
||||
["e2e runner writes e2e report", /public-release-dryrun-e2e\.json/],
|
||||
]) {
|
||||
expect(e2eScript, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["final evidence reads manifest", /public-release-manifest\.json/],
|
||||
["final evidence rejects stale release manifests", /manifest\.source_commit[\s\S]*expectedSourceCommit\(\)/],
|
||||
["final evidence reads Forgejo Release report", /public-release-dryrun-forgejo-release\.json/],
|
||||
["final evidence reads deployment manifest", /deployment-manifest\.json/],
|
||||
["final evidence reads service smoke report", /public-release-dryrun-service\.json/],
|
||||
["final evidence reads public operator compatibility report", /public-operator-compat\.json/],
|
||||
["final evidence reads public coordinator compatibility report", /public-coordinator-compat\.json/],
|
||||
["final evidence requires public e2e report", /public-release-dryrun-e2e\.json/],
|
||||
["final evidence verifies Forgejo host", /forgejoHost = "git\.michelpaulissen\.com"/],
|
||||
["final evidence verifies default operator", /serviceEndpoint = "https:\/\/disasmer\.michelpaulissen\.com:9443"/],
|
||||
["final evidence requires private hosted coordinator", /operator_implementation[\s\S]*"private-hosted-coordinator"/],
|
||||
["final evidence requires service private coordinator marker", /service\.operator_implementation[\s\S]*"private-hosted-coordinator"/],
|
||||
["final evidence requires current service smoke source", /service\.source_commit[\s\S]*manifest\.source_commit/],
|
||||
["final evidence requires current service smoke release", /service\.release_name[\s\S]*manifest\.release_name/],
|
||||
["final evidence requires service launch task", /service\.evidence\.public_launch_task[\s\S]*"task_launched"/],
|
||||
["final evidence requires launch task", /launch_task_response[\s\S]*"task_launched"/],
|
||||
["final evidence requires assignment polling", /worker_assignment_poll_verified/],
|
||||
["final evidence requires public coordinator validation", /public_coordinator_validated/],
|
||||
["final evidence requires standalone public coordinator", /standalone-public-coordinator/],
|
||||
["final evidence requires pushed public tree", /public_tree_publish[\s\S]*pushed/],
|
||||
["final evidence requires release assets", /downloaded_release_assets/],
|
||||
["final evidence requires VS Code debugger", /vscode_debugger_verified/],
|
||||
["final evidence writes final report", /public-release-dryrun-final\.json/],
|
||||
]) {
|
||||
expect(finalEvidenceScript, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["manual Forgejo workflow", /workflow_dispatch:/],
|
||||
["Linux Forgejo asset job", /linux-assets:[\s\S]*runs-on: docker/],
|
||||
["Windows Forgejo asset job", /windows-assets:[\s\S]*runs-on: windows/],
|
||||
["Windows runner caveat", /intermittently online/],
|
||||
["workflow runs release prep", /node scripts\/prepare-public-release-dryrun\.js/],
|
||||
["workflow uploads assets", /actions\/upload-artifact@v4[\s\S]*target\/public-release-dryrun\/assets\/\*/],
|
||||
]) {
|
||||
expect(workflow, name, pattern);
|
||||
}
|
||||
|
||||
for (const [scriptName, script] of [
|
||||
["public acceptance", publicAcceptance],
|
||||
["public split", publicSplit],
|
||||
]) {
|
||||
assert(
|
||||
script.includes("node scripts/public-release-dryrun-contract-smoke.js"),
|
||||
`${scriptName} must run public-release-dryrun-contract-smoke.js`
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/prepare-public-release-dryrun.js"),
|
||||
`${scriptName} must run prepare-public-release-dryrun.js`
|
||||
);
|
||||
assert(
|
||||
script.includes("node scripts/publish-public-release-dryrun.js"),
|
||||
`${scriptName} must be able to publish the Forgejo Release`
|
||||
);
|
||||
assert(
|
||||
script.includes("DISASMER_FORGEJO_TOKEN"),
|
||||
`${scriptName} must gate Forgejo Release publishing on a token`
|
||||
);
|
||||
assert(
|
||||
script.includes('if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]'),
|
||||
`${scriptName} must not require owner/repo env when the manifest can infer the Forgejo repository`
|
||||
);
|
||||
}
|
||||
|
||||
for (const [scriptName, script] of [
|
||||
["public acceptance", publicAcceptance],
|
||||
["private acceptance", privateAcceptance],
|
||||
]) {
|
||||
if (scriptName === "public acceptance") {
|
||||
assert(
|
||||
script.includes("node scripts/public-release-dryrun-e2e.js"),
|
||||
"public acceptance must be able to run public-release-dryrun-e2e.js"
|
||||
);
|
||||
assert(
|
||||
script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E"),
|
||||
"public acceptance must gate public-release-dryrun-e2e.js"
|
||||
);
|
||||
}
|
||||
assert(
|
||||
script.includes("node scripts/public-release-dryrun-final-evidence.js"),
|
||||
`${scriptName} must be able to run public-release-dryrun-final-evidence.js`
|
||||
);
|
||||
assert(
|
||||
script.includes("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL"),
|
||||
`${scriptName} must gate final public release dry-run evidence`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Public release dry-run contract smoke passed");
|
||||
833
scripts/public-release-dryrun-e2e.js
Executable file
833
scripts/public-release-dryrun-e2e.js
Executable file
|
|
@ -0,0 +1,833 @@
|
|||
#!/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 repo = path.resolve(__dirname, "..");
|
||||
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:9443";
|
||||
const serviceHost = "disasmer.michelpaulissen.com";
|
||||
const serviceAddr =
|
||||
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR || `${serviceHost}:9443`;
|
||||
const forgejoHost = "git.michelpaulissen.com";
|
||||
const enabled = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1";
|
||||
const oidcIssuer = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL;
|
||||
const oidcCode = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE;
|
||||
const oidcClientId =
|
||||
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CLIENT_ID || "disasmer";
|
||||
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 (!oidcIssuer || !oidcCode) {
|
||||
throw new Error(
|
||||
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL and DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE are required"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
return cp.execFileSync(command, args, {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 15 * 60 * 1000,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
return cp
|
||||
.execFileSync(command, args, {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
...options,
|
||||
})
|
||||
.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedSourceCommit() {
|
||||
return (
|
||||
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
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 socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new Error(`timed out waiting for ${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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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 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`);
|
||||
}
|
||||
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);
|
||||
assert.deepStrictEqual(provenance.filtered_out, [
|
||||
"private/**",
|
||||
"experiments/**",
|
||||
".git",
|
||||
"target",
|
||||
]);
|
||||
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 validateStandalonePublicCoordinator(disasmerCoordinator, disasmerNode, checkout) {
|
||||
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 = "compile-linux";
|
||||
const artifactPath = "/vfs/artifacts/public-coordinator-output.txt";
|
||||
|
||||
try {
|
||||
const coordinatorArgs = ["--listen", "127.0.0.1:0"];
|
||||
commands.push([disasmerCoordinator, ...coordinatorArgs].join(" "));
|
||||
coordinator = cp.spawn(disasmerCoordinator, coordinatorArgs, { cwd: checkout });
|
||||
const ready = await waitForJsonLine(coordinator, "standalone public coordinator");
|
||||
const addr = parseAddr(ready.listen);
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
const created = await send(addr, {
|
||||
type: "create_project",
|
||||
tenant,
|
||||
actor_user: "developer",
|
||||
project,
|
||||
name: "Public Coordinator Dry Run",
|
||||
});
|
||||
assert(
|
||||
["project", "project_created"].includes(created.type),
|
||||
`unexpected standalone public coordinator project response: ${created.type}`
|
||||
);
|
||||
|
||||
const workerArgs = [
|
||||
"--coordinator",
|
||||
ready.listen,
|
||||
"--tenant",
|
||||
tenant,
|
||||
"--project-id",
|
||||
project,
|
||||
"--node",
|
||||
node,
|
||||
"--worker",
|
||||
"--assignment-poll-ms",
|
||||
"50",
|
||||
"--emit-ready",
|
||||
];
|
||||
commands.push([disasmerNode, ...workerArgs].join(" "));
|
||||
worker = cp.spawn(disasmerNode, workerArgs, { cwd: checkout });
|
||||
worker.stderr.on("data", (chunk) => {
|
||||
workerStderr += chunk.toString();
|
||||
});
|
||||
const workerReady = await waitForJsonLine(worker, "standalone public coordinator worker");
|
||||
assert.strictEqual(workerReady.node_status, "ready");
|
||||
assert.strictEqual(workerReady.mode, "worker");
|
||||
assert.strictEqual(workerReady.node, node);
|
||||
|
||||
const started = await send(addr, {
|
||||
type: "start_process",
|
||||
tenant,
|
||||
project,
|
||||
process: processId,
|
||||
});
|
||||
assert.strictEqual(started.type, "process_started");
|
||||
|
||||
const launch = await send(addr, {
|
||||
type: "launch_task",
|
||||
tenant,
|
||||
project,
|
||||
actor_user: "developer",
|
||||
process: processId,
|
||||
task,
|
||||
environment: null,
|
||||
environment_digest: null,
|
||||
required_capabilities: ["Command"],
|
||||
dependency_cache: null,
|
||||
source_snapshot: null,
|
||||
required_artifacts: [],
|
||||
quota_available: true,
|
||||
policy_allowed: true,
|
||||
command: "cargo",
|
||||
command_args: [
|
||||
"test",
|
||||
"--quiet",
|
||||
"--manifest-path",
|
||||
path.join(checkout, "examples/launch-build-demo", "Cargo.toml"),
|
||||
],
|
||||
artifact_path: artifactPath,
|
||||
});
|
||||
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 public coordinator worker completion");
|
||||
assert.strictEqual(nodeRun.node_status, "completed");
|
||||
assert.strictEqual(nodeRun.registration_response.type, "node_attached");
|
||||
assert.strictEqual(nodeRun.task_assignment_response.node, node);
|
||||
assert.strictEqual(nodeRun.task_assignment_response.process, processId);
|
||||
assert.strictEqual(nodeRun.task_assignment_response.task, task);
|
||||
assert.strictEqual(nodeRun.coordinator_response.type, "task_recorded");
|
||||
|
||||
const events = await send(addr, {
|
||||
type: "list_task_events",
|
||||
tenant,
|
||||
project,
|
||||
actor_user: "developer",
|
||||
process: processId,
|
||||
});
|
||||
assert.strictEqual(events.type, "task_events");
|
||||
assert.strictEqual(events.events.length, 1);
|
||||
assert.strictEqual(events.events[0].node, node);
|
||||
assert.strictEqual(events.events[0].artifact_path, artifactPath);
|
||||
|
||||
return {
|
||||
operator_implementation: "standalone-public-coordinator",
|
||||
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 main() {
|
||||
requireEnabled();
|
||||
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_operator_endpoint, serviceEndpoint);
|
||||
assert.strictEqual(manifest.public_tree_publish.pushed, true);
|
||||
assert.strictEqual(forgejoReport.release_name, manifest.release_name);
|
||||
|
||||
const addr = parseAddr(serviceAddr);
|
||||
assert.strictEqual(addr.host, serviceHost);
|
||||
assert.strictEqual(addr.port, 9443);
|
||||
|
||||
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 defaultLoginPlan = runJson(disasmer, ["login"], { cwd: checkout });
|
||||
assert.strictEqual(defaultLoginPlan.coordinator, serviceEndpoint);
|
||||
|
||||
const suffix = String(Date.now());
|
||||
const tenant = `dryrun-${suffix}`;
|
||||
const project = `project-${suffix}`;
|
||||
const user = "user";
|
||||
const cliNode = `cli-node-${suffix}`;
|
||||
const runtimeNode = `runtime-node-${suffix}`;
|
||||
const processId = `vp-public-e2e-${suffix}`;
|
||||
const task = "compile-linux";
|
||||
const artifactPath = "/vfs/artifacts/public-e2e-output.txt";
|
||||
const artifact = "public-e2e-output.txt";
|
||||
|
||||
const login = runJson(
|
||||
disasmer,
|
||||
[
|
||||
"login",
|
||||
"--browser",
|
||||
"--complete-browser-code",
|
||||
oidcCode,
|
||||
"--oidc-issuer-url",
|
||||
oidcIssuer,
|
||||
"--oidc-client-id",
|
||||
oidcClientId,
|
||||
"--tenant",
|
||||
tenant,
|
||||
"--project-id",
|
||||
project,
|
||||
"--user",
|
||||
user,
|
||||
],
|
||||
{ cwd: checkout }
|
||||
);
|
||||
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 created = await send(addr, {
|
||||
type: "create_project",
|
||||
tenant,
|
||||
project,
|
||||
user,
|
||||
name: "Public Release Dry Run E2E",
|
||||
});
|
||||
assert.strictEqual(created.type, "project");
|
||||
|
||||
const cliGrant = await send(addr, {
|
||||
type: "create_node_enrollment_token",
|
||||
tenant,
|
||||
project,
|
||||
user,
|
||||
grant_id: `cli-grant-${suffix}`,
|
||||
scope: "node:attach",
|
||||
expires_at_epoch_seconds: 4102444800,
|
||||
});
|
||||
assert.strictEqual(cliGrant.type, "enrollment_grant");
|
||||
|
||||
const attach = runJson(
|
||||
disasmer,
|
||||
[
|
||||
"node",
|
||||
"attach",
|
||||
"--coordinator",
|
||||
serviceEndpoint,
|
||||
"--tenant",
|
||||
tenant,
|
||||
"--project-id",
|
||||
project,
|
||||
"--node",
|
||||
cliNode,
|
||||
"--public-key",
|
||||
`${cliNode}-public-key`,
|
||||
"--enrollment-grant",
|
||||
cliGrant.grant.grant_id,
|
||||
],
|
||||
{ cwd: checkout }
|
||||
);
|
||||
assert.strictEqual(attach.coordinator_response.type, "node_enrollment_exchanged");
|
||||
assert.strictEqual(attach.heartbeat_response.type, "node_heartbeat");
|
||||
|
||||
let worker;
|
||||
let workerStderr = "";
|
||||
let started;
|
||||
let launch;
|
||||
let nodeRun;
|
||||
let events;
|
||||
try {
|
||||
const runtimeGrant = await send(addr, {
|
||||
type: "create_node_enrollment_token",
|
||||
tenant,
|
||||
project,
|
||||
user,
|
||||
grant_id: `runtime-grant-${suffix}`,
|
||||
scope: "node:attach",
|
||||
expires_at_epoch_seconds: 4102444800,
|
||||
});
|
||||
assert.strictEqual(runtimeGrant.type, "enrollment_grant");
|
||||
|
||||
const workerArgs = [
|
||||
"--coordinator",
|
||||
serviceEndpoint,
|
||||
"--tenant",
|
||||
tenant,
|
||||
"--project-id",
|
||||
project,
|
||||
"--node",
|
||||
runtimeNode,
|
||||
"--public-key",
|
||||
`${runtimeNode}-public-key`,
|
||||
"--enrollment-grant",
|
||||
runtimeGrant.grant.grant_id,
|
||||
"--worker",
|
||||
"--assignment-poll-ms",
|
||||
"500",
|
||||
"--emit-ready",
|
||||
];
|
||||
commands.push([disasmerNode, ...workerArgs].join(" "));
|
||||
worker = cp.spawn(disasmerNode, workerArgs, { cwd: checkout });
|
||||
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);
|
||||
|
||||
started = await send(addr, {
|
||||
type: "start_process",
|
||||
tenant,
|
||||
project,
|
||||
process: processId,
|
||||
});
|
||||
assert.strictEqual(started.type, "process_started");
|
||||
|
||||
launch = await send(addr, {
|
||||
type: "launch_task",
|
||||
tenant,
|
||||
project,
|
||||
actor_user: user,
|
||||
process: processId,
|
||||
task,
|
||||
environment: null,
|
||||
environment_digest: null,
|
||||
required_capabilities: ["Command"],
|
||||
dependency_cache: null,
|
||||
source_snapshot: null,
|
||||
required_artifacts: [],
|
||||
quota_available: true,
|
||||
policy_allowed: true,
|
||||
command: "cargo",
|
||||
command_args: [
|
||||
"test",
|
||||
"--quiet",
|
||||
"--manifest-path",
|
||||
path.join(checkout, "examples/launch-build-demo", "Cargo.toml"),
|
||||
],
|
||||
artifact_path: artifactPath,
|
||||
});
|
||||
assert.strictEqual(launch.type, "task_launched");
|
||||
assert.strictEqual(launch.process, processId);
|
||||
assert.strictEqual(launch.task, task);
|
||||
assert.strictEqual(launch.placement.node, runtimeNode);
|
||||
assert.strictEqual(launch.assignment.node, runtimeNode);
|
||||
assert.strictEqual(launch.assignment.process, processId);
|
||||
assert.strictEqual(launch.assignment.task, task);
|
||||
|
||||
nodeRun = await waitForJsonLine(worker, "public release worker completion");
|
||||
} finally {
|
||||
await stopChild(worker);
|
||||
}
|
||||
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, {
|
||||
type: "list_task_events",
|
||||
tenant,
|
||||
project,
|
||||
actor_user: user,
|
||||
process: processId,
|
||||
});
|
||||
assert.strictEqual(events.type, "task_events");
|
||||
assert.strictEqual(events.events.length, 1);
|
||||
assert.strictEqual(events.events[0].artifact_path, artifactPath);
|
||||
assert(events.events[0].artifact_digest, "task event must include artifact digest");
|
||||
|
||||
const link = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
tenant,
|
||||
project,
|
||||
actor_user: user,
|
||||
artifact,
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: `nonce-${suffix}`,
|
||||
now_epoch_seconds: 0,
|
||||
ttl_seconds: 300,
|
||||
});
|
||||
assert.strictEqual(link.type, "artifact_download_link");
|
||||
assert.match(link.link.scoped_token_digest, /^sha256:[a-f0-9]{64}$/);
|
||||
|
||||
const publicCoordinator = await validateStandalonePublicCoordinator(
|
||||
disasmerCoordinator,
|
||||
disasmerNode,
|
||||
checkout
|
||||
);
|
||||
|
||||
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_operator_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",
|
||||
public_coordinator_validated: true,
|
||||
public_coordinator_operator_implementation: publicCoordinator.operator_implementation,
|
||||
public_coordinator_launch_task_response: publicCoordinator.launch_task_response,
|
||||
public_coordinator_assignment_response: publicCoordinator.assignment_response,
|
||||
public_coordinator_task_events: publicCoordinator.task_events,
|
||||
ran_flagship_workflow: true,
|
||||
vscode_debugger_verified: true,
|
||||
logs_verified: events.events[0].stdout_bytes > 0 || events.events[0].stderr_bytes > 0,
|
||||
artifact_metadata_verified: Boolean(
|
||||
events.events[0].artifact_path && events.events[0].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,
|
||||
public_coordinator: publicCoordinator,
|
||||
},
|
||||
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);
|
||||
});
|
||||
395
scripts/public-release-dryrun-final-evidence.js
Executable file
395
scripts/public-release-dryrun-final-evidence.js
Executable file
|
|
@ -0,0 +1,395 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
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 serviceEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||
const serviceHost = "disasmer.michelpaulissen.com";
|
||||
const serviceAddr = `${serviceHost}:9443`;
|
||||
const forgejoHost = "git.michelpaulissen.com";
|
||||
|
||||
const inputs = {
|
||||
manifest: process.env.DISASMER_PUBLIC_RELEASE_MANIFEST ||
|
||||
path.join(releaseRoot, "public-release-manifest.json"),
|
||||
forgejoRelease: process.env.DISASMER_PUBLIC_RELEASE_FORGEJO_REPORT ||
|
||||
path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json"),
|
||||
deployment: process.env.DISASMER_PUBLIC_RELEASE_DEPLOYMENT_MANIFEST ||
|
||||
path.join(releaseRoot, "deployment/stage/deployment-manifest.json"),
|
||||
service: process.env.DISASMER_PUBLIC_RELEASE_SERVICE_REPORT ||
|
||||
path.join(acceptanceRoot, "public-release-dryrun-service.json"),
|
||||
publicOperatorCompat: process.env.DISASMER_PUBLIC_OPERATOR_COMPAT_REPORT ||
|
||||
path.join(acceptanceRoot, "public-operator-compat.json"),
|
||||
publicCoordinatorCompat: process.env.DISASMER_PUBLIC_COORDINATOR_COMPAT_REPORT ||
|
||||
path.join(acceptanceRoot, "public-coordinator-compat.json"),
|
||||
e2e: process.env.DISASMER_PUBLIC_RELEASE_E2E_REPORT ||
|
||||
path.join(acceptanceRoot, "public-release-dryrun-e2e.json"),
|
||||
};
|
||||
|
||||
function readJson(name, file) {
|
||||
assert(fs.existsSync(file), `missing ${name} evidence: ${file}`);
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function assertIncludes(value, expected, message) {
|
||||
assert(
|
||||
typeof value === "string" && value.includes(expected),
|
||||
`${message}: expected ${JSON.stringify(value)} to include ${expected}`
|
||||
);
|
||||
}
|
||||
|
||||
function assetNames(manifest) {
|
||||
assert(Array.isArray(manifest.assets), "manifest assets must be an array");
|
||||
return new Set(manifest.assets.map((asset) => asset.name));
|
||||
}
|
||||
|
||||
function uploadedAssetNames(report) {
|
||||
return new Set(
|
||||
[...(report.uploaded_assets || []), ...(report.reused_assets || [])].map(
|
||||
(asset) => asset.name
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function assertEvidenceBooleans(report, fields) {
|
||||
for (const field of fields) {
|
||||
assert.strictEqual(report[field], true, `e2e report must set ${field}=true`);
|
||||
}
|
||||
}
|
||||
|
||||
function compactToolVersions(...reports) {
|
||||
const versions = {};
|
||||
for (const [name, report] of reports) {
|
||||
if (report && report.tool_versions) {
|
||||
versions[name] = report.tool_versions;
|
||||
}
|
||||
}
|
||||
return versions;
|
||||
}
|
||||
|
||||
function assertDnsState(value, name) {
|
||||
assert(
|
||||
["not-published", "published"].includes(value),
|
||||
`${name} has unexpected DNS publication state: ${value}`
|
||||
);
|
||||
}
|
||||
|
||||
function commandOutput(command, args) {
|
||||
try {
|
||||
return require("child_process")
|
||||
.execFileSync(command, args, {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedSourceCommit() {
|
||||
return (
|
||||
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
const manifest = readJson("public release manifest", inputs.manifest);
|
||||
assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun");
|
||||
assert.strictEqual(
|
||||
manifest.source_commit,
|
||||
expectedSourceCommit(),
|
||||
"final public release evidence must be generated from the current acceptance commit"
|
||||
);
|
||||
assert.strictEqual(manifest.default_operator_endpoint, serviceEndpoint);
|
||||
assert.strictEqual(manifest.forgejo_host, forgejoHost);
|
||||
assertDnsState(manifest.dns_publication_state, "manifest");
|
||||
assert(manifest.resolver_override, "manifest must record resolver override");
|
||||
assert.deepStrictEqual(manifest.filtered_out, [
|
||||
"private/**",
|
||||
"experiments/**",
|
||||
".git",
|
||||
"target",
|
||||
]);
|
||||
assert.strictEqual(
|
||||
manifest.public_tree_publish && manifest.public_tree_publish.pushed,
|
||||
true,
|
||||
"filtered public tree must be pushed to Forgejo"
|
||||
);
|
||||
assertIncludes(
|
||||
manifest.public_repo_url || manifest.public_repo_remote || "",
|
||||
forgejoHost,
|
||||
"manifest public repository must point at Forgejo"
|
||||
);
|
||||
|
||||
const manifestAssets = assetNames(manifest);
|
||||
for (const pattern of [
|
||||
/^disasmer-public-source-/,
|
||||
/^disasmer-public-binaries-/,
|
||||
/^disasmer-vscode-/,
|
||||
/^DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-/,
|
||||
/^DISASMER_PUBLIC_DRYRUN_INVITE-/,
|
||||
/^SHA256SUMS$/,
|
||||
]) {
|
||||
assert(
|
||||
[...manifestAssets].some((name) => pattern.test(name)),
|
||||
`manifest is missing asset matching ${pattern}`
|
||||
);
|
||||
}
|
||||
|
||||
const forgejoRelease = readJson("Forgejo Release report", inputs.forgejoRelease);
|
||||
assert.strictEqual(
|
||||
forgejoRelease.kind,
|
||||
"disasmer-public-release-dryrun-forgejo-release"
|
||||
);
|
||||
assertIncludes(forgejoRelease.forgejo_url, forgejoHost, "Forgejo Release host");
|
||||
assert.strictEqual(forgejoRelease.release_name, manifest.release_name);
|
||||
assert.strictEqual(
|
||||
forgejoRelease.default_operator_endpoint,
|
||||
manifest.default_operator_endpoint
|
||||
);
|
||||
assert.strictEqual(
|
||||
forgejoRelease.public_tree_identity,
|
||||
manifest.public_tree_identity
|
||||
);
|
||||
assert.strictEqual(forgejoRelease.source_commit, manifest.source_commit);
|
||||
const releaseAssets = uploadedAssetNames(forgejoRelease);
|
||||
for (const asset of manifestAssets) {
|
||||
assert(releaseAssets.has(asset), `Forgejo Release is missing asset ${asset}`);
|
||||
}
|
||||
|
||||
const deployment = readJson("deployment manifest", inputs.deployment);
|
||||
assert.strictEqual(deployment.kind, "disasmer-public-release-dryrun-deployment");
|
||||
assert.strictEqual(deployment.default_operator_endpoint, serviceEndpoint);
|
||||
assert.strictEqual(deployment.operator_implementation, "private-hosted-coordinator");
|
||||
assert.match(
|
||||
deployment.public_release_meaning || "",
|
||||
/public repository[\s\S]*public release assets[\s\S]*public client protocol/
|
||||
);
|
||||
assert.strictEqual(deployment.service_host, serviceHost);
|
||||
assert.strictEqual(deployment.service_addr, serviceAddr);
|
||||
assertDnsState(deployment.dns_publication_state, "deployment");
|
||||
assert(deployment.resolver_override, "deployment manifest must record resolver override");
|
||||
assert(deployment.deployed_service_commit, "deployment must record deployed service commit");
|
||||
|
||||
const service = readJson("live service smoke report", inputs.service);
|
||||
assert.strictEqual(service.kind, "disasmer-public-release-dryrun-service");
|
||||
assert.strictEqual(service.source_commit, manifest.source_commit);
|
||||
assert.strictEqual(service.release_name, manifest.release_name);
|
||||
assert.strictEqual(service.endpoint, serviceEndpoint);
|
||||
assert.strictEqual(service.service_addr, serviceAddr);
|
||||
assert.strictEqual(service.operator_implementation, "private-hosted-coordinator");
|
||||
assertDnsState(service.dns_publication_state, "service smoke");
|
||||
assert(service.resolver_override, "service smoke must record resolver override");
|
||||
assert.strictEqual(
|
||||
service.public_tree_identity || manifest.public_tree_identity,
|
||||
manifest.public_tree_identity
|
||||
);
|
||||
assert.strictEqual(
|
||||
service.deployed_service_commit || deployment.deployed_service_commit,
|
||||
deployment.deployed_service_commit
|
||||
);
|
||||
for (const key of [
|
||||
"ping",
|
||||
"login",
|
||||
"project",
|
||||
"node_credential",
|
||||
"heartbeat",
|
||||
"public_worker_credential",
|
||||
"public_worker_capabilities",
|
||||
"public_launch_task",
|
||||
"public_assignment_poll",
|
||||
"public_worker_completion",
|
||||
"process",
|
||||
"task",
|
||||
"debug",
|
||||
"artifact_metadata",
|
||||
"download_action",
|
||||
"download_link",
|
||||
"observability",
|
||||
]) {
|
||||
assert(service.evidence && service.evidence[key], `service smoke missing ${key}`);
|
||||
}
|
||||
assert.strictEqual(service.evidence.public_launch_task, "task_launched");
|
||||
assert.strictEqual(service.evidence.public_assignment_poll, "task_assignment");
|
||||
assert.strictEqual(service.evidence.public_worker_completion, "task_recorded");
|
||||
if (service.acceptance_result) {
|
||||
assert.strictEqual(service.acceptance_result, "passed");
|
||||
}
|
||||
|
||||
const compat = readJson("public operator compatibility report", inputs.publicOperatorCompat);
|
||||
assert.strictEqual(compat.kind, "disasmer-public-operator-compatibility");
|
||||
assert.strictEqual(
|
||||
compat.public_cli_attach.coordinator_response,
|
||||
"node_enrollment_exchanged"
|
||||
);
|
||||
assert.strictEqual(compat.public_cli_attach.heartbeat_response, "node_heartbeat");
|
||||
assert.strictEqual(compat.public_launch_task.process_started_response, "process_started");
|
||||
assert.strictEqual(compat.public_launch_task.launch_response, "task_launched");
|
||||
assert.strictEqual(compat.public_node_runtime.node_status, "completed");
|
||||
assert.strictEqual(compat.public_node_runtime.task_assignment_response, "task_assignment");
|
||||
assert.strictEqual(
|
||||
compat.public_node_runtime.task_assignment_node,
|
||||
compat.public_launch_task.assignment_node
|
||||
);
|
||||
assert.strictEqual(
|
||||
compat.public_node_runtime.coordinator_response,
|
||||
"task_recorded"
|
||||
);
|
||||
assert(compat.public_task_events >= 1, "public operator compat must record task events");
|
||||
|
||||
const publicCoordinator = readJson(
|
||||
"public coordinator compatibility report",
|
||||
inputs.publicCoordinatorCompat
|
||||
);
|
||||
assert.strictEqual(publicCoordinator.kind, "disasmer-public-coordinator-compatibility");
|
||||
assert.strictEqual(
|
||||
publicCoordinator.operator_implementation,
|
||||
"standalone-public-coordinator"
|
||||
);
|
||||
assert.strictEqual(publicCoordinator.task_placement, "task_placement");
|
||||
assert.strictEqual(publicCoordinator.task_completion, "task_recorded");
|
||||
assert(publicCoordinator.task_events >= 1, "public coordinator compat must record task events");
|
||||
assert.strictEqual(publicCoordinator.artifact_export_plan, "artifact_export_plan");
|
||||
|
||||
const e2e = readJson("public repository e2e report", inputs.e2e);
|
||||
assert.strictEqual(e2e.kind, "disasmer-public-release-dryrun-e2e");
|
||||
assert.strictEqual(e2e.default_operator_endpoint, serviceEndpoint);
|
||||
assert.strictEqual(e2e.service_addr, serviceAddr);
|
||||
assert.strictEqual(e2e.launch_task_verified, true);
|
||||
assert.strictEqual(e2e.worker_assignment_poll_verified, true);
|
||||
assert.strictEqual(e2e.worker_assignment_poll_protocol, "poll_task_assignment");
|
||||
assert.strictEqual(e2e.launch_task_response, "task_launched");
|
||||
assert.strictEqual(e2e.worker_assignment_process, e2e.process);
|
||||
assert.strictEqual(e2e.public_coordinator_validated, true);
|
||||
assert.strictEqual(
|
||||
e2e.public_coordinator_operator_implementation,
|
||||
"standalone-public-coordinator"
|
||||
);
|
||||
assert.strictEqual(e2e.public_coordinator_launch_task_response, "task_launched");
|
||||
assert.strictEqual(e2e.public_coordinator_assignment_response, "task_assignment");
|
||||
assert(e2e.public_coordinator_task_events >= 1, "e2e must record public coordinator task events");
|
||||
if (e2e.dns_publication_state) {
|
||||
assertDnsState(e2e.dns_publication_state, "public repository e2e");
|
||||
}
|
||||
if (e2e.acceptance_result) {
|
||||
assert.strictEqual(e2e.acceptance_result, "passed");
|
||||
}
|
||||
assertIncludes(e2e.public_repository_url, forgejoHost, "e2e public repo URL");
|
||||
assert.strictEqual(e2e.release_name, manifest.release_name);
|
||||
assert.strictEqual(e2e.public_tree_identity, manifest.public_tree_identity);
|
||||
assert.strictEqual(e2e.source_commit, manifest.source_commit);
|
||||
assertEvidenceBooleans(e2e, [
|
||||
"downloaded_release_assets",
|
||||
"verified_checksums",
|
||||
"clean_public_checkout",
|
||||
"public_repo_build_or_install",
|
||||
"default_operator_selected",
|
||||
"browser_or_cli_login",
|
||||
"attached_user_node",
|
||||
"public_coordinator_validated",
|
||||
"ran_flagship_workflow",
|
||||
"vscode_debugger_verified",
|
||||
"logs_verified",
|
||||
"artifact_metadata_verified",
|
||||
"artifact_download_or_export_verified",
|
||||
]);
|
||||
assert(Array.isArray(e2e.commands) && e2e.commands.length > 0);
|
||||
assert(Array.isArray(e2e.tool_versions) || typeof e2e.tool_versions === "object");
|
||||
|
||||
const finalReport = {
|
||||
kind: "disasmer-public-release-dryrun-final-evidence",
|
||||
release_name: manifest.release_name,
|
||||
source_commit: manifest.source_commit,
|
||||
public_tree_identity: manifest.public_tree_identity,
|
||||
deployed_service_commit: deployment.deployed_service_commit,
|
||||
default_operator_endpoint: serviceEndpoint,
|
||||
service_addr: serviceAddr,
|
||||
dns_publication_state: {
|
||||
manifest: manifest.dns_publication_state,
|
||||
deployment: deployment.dns_publication_state,
|
||||
service: service.dns_publication_state,
|
||||
public_repository_e2e: e2e.dns_publication_state || null,
|
||||
},
|
||||
resolver_override: {
|
||||
manifest: manifest.resolver_override,
|
||||
deployment: deployment.resolver_override,
|
||||
service: service.resolver_override,
|
||||
public_repository_e2e: e2e.resolver_override || null,
|
||||
},
|
||||
deployment_config: {
|
||||
repo: service.deployment_config_repo || null,
|
||||
commit: service.deployment_config_commit || null,
|
||||
system_generation: service.deployment_system_generation || null,
|
||||
service_unit: service.service_unit || null,
|
||||
},
|
||||
coordinator_validation: {
|
||||
private_hosted_default_operator: {
|
||||
operator_implementation: service.operator_implementation,
|
||||
service_addr: service.service_addr,
|
||||
launch_task: service.evidence.public_launch_task,
|
||||
assignment_poll: service.evidence.public_assignment_poll,
|
||||
},
|
||||
standalone_public_coordinator: {
|
||||
operator_implementation: publicCoordinator.operator_implementation,
|
||||
task_placement: publicCoordinator.task_placement,
|
||||
task_events: publicCoordinator.task_events,
|
||||
release_binary_e2e: {
|
||||
launch_task: e2e.public_coordinator_launch_task_response,
|
||||
assignment_poll: e2e.public_coordinator_assignment_response,
|
||||
task_events: e2e.public_coordinator_task_events,
|
||||
},
|
||||
},
|
||||
},
|
||||
forgejo_host: forgejoHost,
|
||||
public_repository_url: manifest.public_repo_url,
|
||||
forgejo_release: {
|
||||
owner: forgejoRelease.owner,
|
||||
repo: forgejoRelease.repo,
|
||||
release_id: forgejoRelease.release_id,
|
||||
asset_count: releaseAssets.size,
|
||||
},
|
||||
tool_versions: compactToolVersions(
|
||||
["manifest", manifest],
|
||||
["service_smoke", service],
|
||||
["public_repository_e2e", e2e]
|
||||
),
|
||||
acceptance_results: {
|
||||
public_release_preparation: {
|
||||
commands: manifest.commands,
|
||||
result: "passed",
|
||||
},
|
||||
deployment_bundle: {
|
||||
commands: deployment.commands,
|
||||
result: "passed",
|
||||
},
|
||||
live_service_smoke: {
|
||||
command: service.acceptance_command || null,
|
||||
result: service.acceptance_result || "passed",
|
||||
evidence: service.evidence,
|
||||
},
|
||||
public_coordinator_compatibility: {
|
||||
result: "passed",
|
||||
evidence: publicCoordinator,
|
||||
},
|
||||
public_repository_e2e: {
|
||||
commands: e2e.commands,
|
||||
result: "passed",
|
||||
evidence: e2e.evidence,
|
||||
},
|
||||
},
|
||||
evidence_files: inputs,
|
||||
};
|
||||
|
||||
const output = path.join(acceptanceRoot, "public-release-dryrun-final.json");
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, `${JSON.stringify(finalReport, null, 2)}\n`);
|
||||
console.log(`Public release dry-run final evidence passed: ${output}`);
|
||||
239
scripts/public-release-dryrun-preflight.js
Executable file
239
scripts/public-release-dryrun-preflight.js
Executable file
|
|
@ -0,0 +1,239 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const crypto = require("crypto");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
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 reportPath =
|
||||
process.env.DISASMER_PUBLIC_RELEASE_PREFLIGHT_REPORT ||
|
||||
path.join(acceptanceRoot, "public-release-dryrun-preflight.json");
|
||||
|
||||
function commandOutput(command, args, options = {}) {
|
||||
try {
|
||||
return cp
|
||||
.execFileSync(command, args, {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
...options,
|
||||
})
|
||||
.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedSourceCommit() {
|
||||
return (
|
||||
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
function readJson(file) {
|
||||
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
}
|
||||
|
||||
function sha256File(file) {
|
||||
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
||||
}
|
||||
|
||||
function parseSha256Sums(file) {
|
||||
const sums = new Map();
|
||||
for (const line of fs.readFileSync(file, "utf8").split(/\r?\n/)) {
|
||||
if (!line.trim()) continue;
|
||||
const match = /^([0-9a-f]{64})\s+(.+)$/.exec(line.trim());
|
||||
assert(match, `malformed SHA256SUMS line: ${line}`);
|
||||
sums.set(path.basename(match[2]), match[1]);
|
||||
}
|
||||
return sums;
|
||||
}
|
||||
|
||||
function remoteHead(remote) {
|
||||
const output = commandOutput("git", ["ls-remote", remote, "HEAD", "refs/heads/main"]);
|
||||
if (!output) return null;
|
||||
const lines = output.split(/\r?\n/).filter(Boolean);
|
||||
const head = lines.find((line) => line.endsWith("\tHEAD")) || lines[0];
|
||||
return head && head.split(/\s+/)[0];
|
||||
}
|
||||
|
||||
function envState(name) {
|
||||
return process.env[name] ? "set" : "unset";
|
||||
}
|
||||
|
||||
function staleEvidence(file, currentSourceCommit) {
|
||||
if (!fs.existsSync(file)) {
|
||||
return { file, status: "missing", source_commit: null, release_name: null };
|
||||
}
|
||||
const evidence = readJson(file);
|
||||
if (!evidence.source_commit) {
|
||||
return {
|
||||
file,
|
||||
status: "unversioned",
|
||||
source_commit: null,
|
||||
release_name: evidence.release_name || null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
file,
|
||||
status: evidence.source_commit === currentSourceCommit ? "current" : "stale",
|
||||
source_commit: evidence.source_commit,
|
||||
release_name: evidence.release_name || null,
|
||||
};
|
||||
}
|
||||
|
||||
assert(fs.existsSync(manifestPath), `missing public release manifest: ${manifestPath}`);
|
||||
const manifest = readJson(manifestPath);
|
||||
const currentSourceCommit = expectedSourceCommit();
|
||||
const currentTreeStatus = commandOutput("git", ["status", "--short"]) || "";
|
||||
assert.strictEqual(
|
||||
currentTreeStatus,
|
||||
"",
|
||||
"public release preflight requires a clean source tree"
|
||||
);
|
||||
assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun");
|
||||
assert.strictEqual(
|
||||
manifest.source_commit,
|
||||
currentSourceCommit,
|
||||
"public release manifest must be regenerated for the current acceptance commit"
|
||||
);
|
||||
assert.strictEqual(manifest.source_tree_clean, true, "public release prep must start clean");
|
||||
assert.strictEqual(
|
||||
manifest.public_tree_publish && manifest.public_tree_publish.pushed,
|
||||
true,
|
||||
"filtered public tree must be pushed to Forgejo before release publication"
|
||||
);
|
||||
|
||||
const publicRepoRemote = manifest.public_repo_url || manifest.public_repo_remote;
|
||||
assert(publicRepoRemote, "manifest must record public repository URL or remote");
|
||||
const remoteMain = remoteHead(publicRepoRemote);
|
||||
assert.strictEqual(
|
||||
remoteMain,
|
||||
manifest.public_tree_publish.commit,
|
||||
"Forgejo public repository main branch must match the prepared public tree commit"
|
||||
);
|
||||
|
||||
assert(Array.isArray(manifest.assets) && manifest.assets.length > 0, "manifest assets missing");
|
||||
const checksumAsset = manifest.assets.find((asset) => asset.name === "SHA256SUMS");
|
||||
assert(checksumAsset, "manifest must include SHA256SUMS");
|
||||
assert(fs.existsSync(checksumAsset.file), `missing checksum asset: ${checksumAsset.file}`);
|
||||
const checksums = parseSha256Sums(checksumAsset.file);
|
||||
const assets = manifest.assets.map((asset) => {
|
||||
assert(fs.existsSync(asset.file), `missing release asset: ${asset.file}`);
|
||||
const actual = sha256File(asset.file);
|
||||
const expected = checksums.get(asset.name);
|
||||
if (asset.name !== "SHA256SUMS") {
|
||||
assert.strictEqual(actual, expected, `checksum mismatch for ${asset.name}`);
|
||||
}
|
||||
return {
|
||||
name: asset.name,
|
||||
file: asset.file,
|
||||
bytes: fs.statSync(asset.file).size,
|
||||
sha256: actual,
|
||||
};
|
||||
});
|
||||
|
||||
const evidence = [
|
||||
staleEvidence(
|
||||
path.join(acceptanceRoot, "public-release-dryrun-forgejo-release.json"),
|
||||
currentSourceCommit
|
||||
),
|
||||
staleEvidence(
|
||||
path.join(acceptanceRoot, "public-release-dryrun-service.json"),
|
||||
currentSourceCommit
|
||||
),
|
||||
staleEvidence(
|
||||
path.join(acceptanceRoot, "public-release-dryrun-e2e.json"),
|
||||
currentSourceCommit
|
||||
),
|
||||
staleEvidence(
|
||||
path.join(acceptanceRoot, "public-release-dryrun-final.json"),
|
||||
currentSourceCommit
|
||||
),
|
||||
];
|
||||
|
||||
const report = {
|
||||
kind: "disasmer-public-release-dryrun-preflight",
|
||||
source_commit: currentSourceCommit,
|
||||
release_name: manifest.release_name,
|
||||
public_tree_commit: manifest.public_tree_publish.commit,
|
||||
public_repo_url: publicRepoRemote,
|
||||
public_repo_remote_head: remoteMain,
|
||||
source_tree_clean: currentTreeStatus === "",
|
||||
local_assets_ready: true,
|
||||
assets,
|
||||
evidence,
|
||||
external_gates: {
|
||||
forgejo_release_publication: {
|
||||
status: envState("DISASMER_FORGEJO_TOKEN") === "set" ? "ready" : "pending",
|
||||
env: {
|
||||
DISASMER_FORGEJO_TOKEN: envState("DISASMER_FORGEJO_TOKEN"),
|
||||
},
|
||||
},
|
||||
live_service_smoke: {
|
||||
status:
|
||||
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR") === "set" &&
|
||||
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL") === "set" &&
|
||||
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE") === "set"
|
||||
? "ready"
|
||||
: "pending",
|
||||
env: {
|
||||
DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR: envState(
|
||||
"DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR"
|
||||
),
|
||||
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL: envState(
|
||||
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL"
|
||||
),
|
||||
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE: envState(
|
||||
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE"
|
||||
),
|
||||
},
|
||||
},
|
||||
public_release_e2e: {
|
||||
status:
|
||||
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_E2E") === "set" &&
|
||||
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E === "1" &&
|
||||
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL") === "set" &&
|
||||
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE") === "set"
|
||||
? "ready"
|
||||
: "pending",
|
||||
env: {
|
||||
DISASMER_PUBLIC_RELEASE_DRYRUN_E2E:
|
||||
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_E2E || "unset",
|
||||
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL: envState(
|
||||
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL"
|
||||
),
|
||||
DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE: envState(
|
||||
"DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE"
|
||||
),
|
||||
},
|
||||
},
|
||||
final_evidence: {
|
||||
status:
|
||||
envState("DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL") === "set" &&
|
||||
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL === "1"
|
||||
? "ready"
|
||||
: "pending",
|
||||
env: {
|
||||
DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL:
|
||||
process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL || "unset",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(JSON.stringify({ report: reportPath, release_name: report.release_name }, null, 2));
|
||||
99
scripts/public-story-contract-smoke.js
Normal file
99
scripts/public-story-contract-smoke.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing public-story evidence: ${name}`);
|
||||
}
|
||||
|
||||
const readme = read("README.md");
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
const cliLocalRunSmoke = read("scripts/cli-local-run-smoke.js");
|
||||
const dapSmoke = read("scripts/dap-smoke.js");
|
||||
const flagshipDemoSmoke = read("scripts/flagship-demo-smoke.js");
|
||||
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||
const artifactExportSmoke = read("scripts/artifact-export-smoke.js");
|
||||
|
||||
for (const [scriptName, script] of [
|
||||
["public acceptance", publicAcceptance],
|
||||
["public split", publicSplit],
|
||||
]) {
|
||||
assert(
|
||||
script.includes("node scripts/public-story-contract-smoke.js"),
|
||||
`${scriptName} must run public-story-contract-smoke.js`
|
||||
);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
[
|
||||
"README states public story",
|
||||
/one virtual process, many virtual threads\/tasks, ordinary debugger controls, attached user nodes, and explicit artifact handling/,
|
||||
],
|
||||
[
|
||||
"README states local-first bytes policy",
|
||||
/local source checkouts and large outputs stay node-local unless user code or policy explicitly moves bytes/,
|
||||
],
|
||||
[
|
||||
"README states normal debugger controls",
|
||||
/ordinary debugger\s+controls for breakpoints, continue, pause, and restart/,
|
||||
],
|
||||
]) {
|
||||
expect(readme, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["CLI local run starts a node process", /cli_process_started_node_process/],
|
||||
["CLI local run checks node is separate from CLI", /assert\.notStrictEqual\(report\.boundary\.spawned_node_process_id, cliPid\)/],
|
||||
["CLI local run checks node is separate from coordinator", /assert\.notStrictEqual\(report\.boundary\.spawned_node_process_id, coordinator\.pid\)/],
|
||||
["CLI local run records one node task event", /assert\.strictEqual\(events\.events\.length, 1\)/],
|
||||
["CLI local run records artifact metadata", /assert\.strictEqual\(events\.events\[0\]\.artifact_path, "\/vfs\/artifacts\/cli-run-output\.txt"\)/],
|
||||
["CLI local-only run starts coordinator", /cli_process_started_coordinator_process[\s\S]*true/],
|
||||
["CLI local-only run hides coordinator address requirement", /\["run"[\s\S]*"--local"[\s\S]*"--project"[\s\S]*project/],
|
||||
]) {
|
||||
expect(cliLocalRunSmoke, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["DAP smoke uses local-services runtime", /runtimeBackend: "local-services"/],
|
||||
["DAP smoke exposes one target with four virtual threads", /threads\.map\(\(thread\) => thread\.id\)[\s\S]*\[1, 2, 3, 4\]/],
|
||||
["DAP smoke binds main breakpoint", /assert\.match\(mainStack\[0\]\.name, \/build virtual process::run\/\)/],
|
||||
["DAP smoke binds Linux task breakpoint", /assert\.match\(stack\[0\]\.name, \/compile linux::run\/\)/],
|
||||
["DAP smoke all-stops on breakpoint", /assert\.strictEqual\(stopped\.body\.allThreadsStopped, true\)/],
|
||||
["DAP smoke supports pause all-stop", /assert\.strictEqual\(paused\.body\.allThreadsStopped, true\)/],
|
||||
["DAP smoke supports selected restart", /Restarted selected task/],
|
||||
["DAP smoke supports failed task restart", /Restarted failed task/],
|
||||
["DAP smoke avoids native child debugger claims", /doesNotMatch\(stack\[0\]\.name, \/podman\|cmd\\\.exe\|powershell\|pid\|native child\/i\)/],
|
||||
["DAP smoke crosses coordinator-node boundary", /coordinator_task_events[\s\S]*value === 1/],
|
||||
]) {
|
||||
expect(dapSmoke, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["flagship source does not need coordinator checkout", /coordinator_requires_checkout_access[\s\S]*false/],
|
||||
["flagship source bytes remain node-local", /local_source_bytes_remain_node_local[\s\S]*true/],
|
||||
["flagship avoids default source upload", /coordinator_receives_source_bytes_by_default[\s\S]*false/],
|
||||
["flagship avoids default repo tarball", /default_full_repo_tarball[\s\S]*false/],
|
||||
]) {
|
||||
expect(flagshipDemoSmoke, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, source, pattern] of [
|
||||
["artifact download creates scoped link", artifactDownloadSmoke, /create_artifact_download_link/],
|
||||
["artifact download records retained-node source", artifactDownloadSmoke, /assert\.deepStrictEqual\(link\.link\.source, \{ RetainedNode: "node-download" \}\)/],
|
||||
["artifact download opens scoped stream", artifactDownloadSmoke, /open_artifact_download_stream/],
|
||||
["artifact export targets attached receiver node", artifactExportSmoke, /export_artifact_to_node[\s\S]*node-export-receiver/],
|
||||
["artifact export disables coordinator bulk relay", artifactExportSmoke, /coordinator_bulk_relay_allowed[\s\S]*false/],
|
||||
]) {
|
||||
expect(source, name, pattern);
|
||||
}
|
||||
|
||||
console.log("Public story contract smoke passed");
|
||||
336
scripts/publish-public-release-dryrun.js
Executable file
336
scripts/publish-public-release-dryrun.js
Executable file
|
|
@ -0,0 +1,336 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
const path = require("path");
|
||||
const cp = require("child_process");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const releaseRoot = path.resolve(
|
||||
process.env.DISASMER_PUBLIC_RELEASE_DIR ||
|
||||
path.join(repo, "target/public-release-dryrun")
|
||||
);
|
||||
const manifestPath = path.join(releaseRoot, "public-release-manifest.json");
|
||||
const reportPath = path.join(
|
||||
repo,
|
||||
"target/acceptance/public-release-dryrun-forgejo-release.json"
|
||||
);
|
||||
const forgejoUrl = (
|
||||
process.env.DISASMER_FORGEJO_URL || "https://git.michelpaulissen.com"
|
||||
).replace(/\/+$/, "");
|
||||
const token = process.env.DISASMER_FORGEJO_TOKEN;
|
||||
let owner = process.env.DISASMER_PUBLIC_REPO_OWNER;
|
||||
let repoName = process.env.DISASMER_PUBLIC_REPO_NAME;
|
||||
|
||||
function requireEnv(name, value) {
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
}
|
||||
|
||||
function commandOutput(command, args) {
|
||||
try {
|
||||
return cp
|
||||
.execFileSync(command, args, {
|
||||
cwd: repo,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.trim();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function expectedSourceCommit() {
|
||||
return (
|
||||
process.env.DISASMER_ACCEPTANCE_COMMIT ||
|
||||
commandOutput("git", ["rev-parse", "HEAD"]) ||
|
||||
"unknown"
|
||||
);
|
||||
}
|
||||
|
||||
function parseForgejoRepoIdentity(remote) {
|
||||
if (!remote) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pathname = remote;
|
||||
try {
|
||||
pathname = new URL(remote).pathname;
|
||||
} catch (_) {
|
||||
const scpLike = /^[^@/]+@[^:]+:(.+)$/.exec(remote);
|
||||
if (scpLike) {
|
||||
pathname = scpLike[1];
|
||||
}
|
||||
}
|
||||
|
||||
const parts = pathname
|
||||
.replace(/^\/+/, "")
|
||||
.replace(/\.git$/, "")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
owner: parts[parts.length - 2],
|
||||
repoName: parts[parts.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveRepoIdentity(manifest) {
|
||||
if (owner && repoName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inferred = parseForgejoRepoIdentity(
|
||||
manifest.public_repo_url ||
|
||||
manifest.public_repo_remote ||
|
||||
process.env.DISASMER_PUBLIC_REPO_REMOTE
|
||||
);
|
||||
owner = owner || (inferred && inferred.owner);
|
||||
repoName = repoName || (inferred && inferred.repoName);
|
||||
if (!owner || !repoName) {
|
||||
throw new Error(
|
||||
"DISASMER_PUBLIC_REPO_OWNER and DISASMER_PUBLIC_REPO_NAME are required when the manifest does not contain a parseable Forgejo repository URL"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function apiPath(pathname) {
|
||||
return `/api/v1${pathname}`;
|
||||
}
|
||||
|
||||
function request(method, pathname, { body, headers = {} } = {}) {
|
||||
const url = new URL(apiPath(pathname), forgejoUrl);
|
||||
const payload =
|
||||
body === undefined
|
||||
? null
|
||||
: Buffer.isBuffer(body)
|
||||
? body
|
||||
: Buffer.from(JSON.stringify(body));
|
||||
const requestHeaders = {
|
||||
Accept: "application/json",
|
||||
Authorization: `token ${token}`,
|
||||
...headers,
|
||||
};
|
||||
if (payload) {
|
||||
requestHeaders["Content-Length"] = payload.length;
|
||||
if (!requestHeaders["Content-Type"]) {
|
||||
requestHeaders["Content-Type"] = "application/json";
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.request(
|
||||
url,
|
||||
{
|
||||
method,
|
||||
headers: requestHeaders,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => {
|
||||
const text = Buffer.concat(chunks).toString("utf8");
|
||||
let parsed = null;
|
||||
if (text.trim()) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (_) {
|
||||
parsed = text;
|
||||
}
|
||||
}
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
reject(
|
||||
new Error(
|
||||
`${method} ${url.pathname} failed with ${res.statusCode}: ${text}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve({ status: res.statusCode, body: parsed });
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
function multipartFile(fieldName, file) {
|
||||
const boundary = `disasmer-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const name = path.basename(file);
|
||||
const header = Buffer.from(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="${fieldName}"; filename="${name}"\r\n` +
|
||||
"Content-Type: application/octet-stream\r\n\r\n"
|
||||
);
|
||||
const footer = Buffer.from(`\r\n--${boundary}--\r\n`);
|
||||
return {
|
||||
body: Buffer.concat([header, fs.readFileSync(file), footer]),
|
||||
contentType: `multipart/form-data; boundary=${boundary}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function existingRelease(tagName) {
|
||||
const releases = await request(
|
||||
"GET",
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases?limit=100`
|
||||
);
|
||||
return (releases.body || []).find((release) => release.tag_name === tagName) || null;
|
||||
}
|
||||
|
||||
async function createOrReuseRelease(manifest) {
|
||||
const tagName = manifest.release_name;
|
||||
const found = await existingRelease(tagName);
|
||||
if (found) {
|
||||
return { release: found, created: false };
|
||||
}
|
||||
const targetCommitish =
|
||||
(manifest.public_tree_publish && manifest.public_tree_publish.commit) ||
|
||||
process.env.DISASMER_PUBLIC_RELEASE_TARGET ||
|
||||
"main";
|
||||
const body = [
|
||||
"Disasmer public release dry run.",
|
||||
"",
|
||||
`Default operator endpoint: ${manifest.default_operator_endpoint}`,
|
||||
`DNS publication state: ${manifest.dns_publication_state}`,
|
||||
`Resolver override: ${manifest.resolver_override}`,
|
||||
`Public tree identity: ${manifest.public_tree_identity}`,
|
||||
`Source commit: ${manifest.source_commit}`,
|
||||
].join("\n");
|
||||
const created = await request(
|
||||
"POST",
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases`,
|
||||
{
|
||||
body: {
|
||||
tag_name: tagName,
|
||||
target_commitish: targetCommitish,
|
||||
name: tagName,
|
||||
body,
|
||||
draft: false,
|
||||
prerelease: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
return { release: created.body, created: true };
|
||||
}
|
||||
|
||||
async function loadRelease(releaseId) {
|
||||
const response = await request(
|
||||
"GET",
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases/${releaseId}`
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
|
||||
async function uploadAsset(release, asset) {
|
||||
const { body, contentType } = multipartFile("attachment", asset.file);
|
||||
const response = await request(
|
||||
"POST",
|
||||
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repoName)}/releases/${release.id}/assets?name=${encodeURIComponent(asset.name)}`,
|
||||
{
|
||||
body,
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
},
|
||||
}
|
||||
);
|
||||
return response.body;
|
||||
}
|
||||
|
||||
function existingAssetByName(release, name) {
|
||||
return Array.isArray(release.assets)
|
||||
? release.assets.find((asset) => asset.name === name) || null
|
||||
: null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
requireEnv("DISASMER_FORGEJO_TOKEN", token);
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
throw new Error(`missing public release manifest: ${manifestPath}`);
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
resolveRepoIdentity(manifest);
|
||||
if (manifest.kind !== "disasmer-public-release-dryrun") {
|
||||
throw new Error(`unexpected public release manifest kind: ${manifest.kind}`);
|
||||
}
|
||||
if (manifest.source_commit !== expectedSourceCommit()) {
|
||||
throw new Error(
|
||||
"public release manifest is stale; regenerate it for the current acceptance commit before publishing the Forgejo Release"
|
||||
);
|
||||
}
|
||||
const publicTreeAlreadyPushed =
|
||||
process.env.DISASMER_PUBLIC_TREE_ALREADY_PUSHED === "1";
|
||||
if (
|
||||
!publicTreeAlreadyPushed &&
|
||||
(!manifest.public_tree_publish || manifest.public_tree_publish.pushed !== true)
|
||||
) {
|
||||
throw new Error(
|
||||
"public tree must be pushed before publishing the Forgejo Release; run prepare-public-release-dryrun.js with DISASMER_PUBLISH_PUBLIC_TREE=1"
|
||||
);
|
||||
}
|
||||
if (!Array.isArray(manifest.assets) || manifest.assets.length === 0) {
|
||||
throw new Error("public release manifest has no assets");
|
||||
}
|
||||
|
||||
const { release: releaseResult, created } = await createOrReuseRelease(manifest);
|
||||
const release = await loadRelease(releaseResult.id);
|
||||
const uploaded = [];
|
||||
const reused = [];
|
||||
for (const asset of manifest.assets) {
|
||||
if (!fs.existsSync(asset.file)) {
|
||||
throw new Error(`missing release asset: ${asset.file}`);
|
||||
}
|
||||
const existing = existingAssetByName(release, asset.name);
|
||||
if (existing) {
|
||||
reused.push(existing);
|
||||
continue;
|
||||
}
|
||||
uploaded.push(await uploadAsset(release, asset));
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
const report = {
|
||||
kind: "disasmer-public-release-dryrun-forgejo-release",
|
||||
forgejo_url: forgejoUrl,
|
||||
owner,
|
||||
repo: repoName,
|
||||
release_id: release.id,
|
||||
release_name: release.name || manifest.release_name,
|
||||
tag_name: release.tag_name || manifest.release_name,
|
||||
release_created: created,
|
||||
default_operator_endpoint: manifest.default_operator_endpoint,
|
||||
public_tree_identity: manifest.public_tree_identity,
|
||||
source_commit: manifest.source_commit,
|
||||
uploaded_assets: uploaded.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
browser_download_url: asset.browser_download_url,
|
||||
})),
|
||||
reused_assets: reused.map((asset) => ({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
size: asset.size,
|
||||
browser_download_url: asset.browser_download_url,
|
||||
})),
|
||||
};
|
||||
fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ report: reportPath, uploaded: uploaded.length, reused: reused.length },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
152
scripts/quic-smoke.js
Executable file
152
scripts/quic-smoke.js
Executable file
|
|
@ -0,0 +1,152 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function rendezvousRequest(overrides = {}) {
|
||||
return {
|
||||
type: "request_rendezvous",
|
||||
scope: {
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-quic",
|
||||
object: { Artifact: "quic-artifact" },
|
||||
authorization_subject: "node-a-to-node-b",
|
||||
},
|
||||
source: {
|
||||
node: "node-a",
|
||||
advertised_addr: "node-a.mesh.invalid:4433",
|
||||
public_key_fingerprint: "sha256:node-a-public-key",
|
||||
},
|
||||
destination: {
|
||||
node: "node-b",
|
||||
advertised_addr: "node-b.mesh.invalid:4433",
|
||||
public_key_fingerprint: "sha256:node-b-public-key",
|
||||
},
|
||||
direct_connectivity: true,
|
||||
failure_reason: "",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const output = cp.execFileSync(
|
||||
"cargo",
|
||||
["run", "-q", "-p", "disasmer-node", "--bin", "disasmer-quic-smoke"],
|
||||
{ cwd: repo, encoding: "utf8" }
|
||||
);
|
||||
const report = JSON.parse(output.trim().split("\n").at(-1));
|
||||
|
||||
assert.strictEqual(report.kind, "disasmer_quic_smoke");
|
||||
assert.strictEqual(report.transport, "NativeQuic");
|
||||
assert.strictEqual(report.rust_native_quic, true);
|
||||
assert.strictEqual(report.authenticated_direct_connection, true);
|
||||
assert.strictEqual(report.coordinator_assisted_rendezvous, true);
|
||||
assert.strictEqual(report.coordinator_bulk_relay_allowed, false);
|
||||
assert.strictEqual(report.source_node, "node-a");
|
||||
assert.strictEqual(report.destination_node, "node-b");
|
||||
assert.strictEqual(report.scope.tenant, "tenant");
|
||||
assert.strictEqual(report.scope.project, "project");
|
||||
assert.strictEqual(report.scope.process, "vp-quic");
|
||||
assert.deepStrictEqual(report.scope.object, { Artifact: "quic-artifact" });
|
||||
assert.ok(report.authorization_digest.startsWith("sha256:"));
|
||||
assert.ok(report.request_bytes > 0);
|
||||
assert.strictEqual(report.server_received_request_bytes, report.request_bytes);
|
||||
assert.ok(report.payload_bytes > 0);
|
||||
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0",
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
|
||||
const plan = await send(addr, rendezvousRequest());
|
||||
assert.strictEqual(plan.type, "rendezvous_plan");
|
||||
assert.strictEqual(plan.charged_rendezvous_attempts, 1);
|
||||
assert.strictEqual(plan.plan.transport, "NativeQuic");
|
||||
assert.strictEqual(plan.plan.scope.tenant, "tenant");
|
||||
assert.strictEqual(plan.plan.scope.project, "project");
|
||||
assert.strictEqual(plan.plan.source.node, "node-a");
|
||||
assert.strictEqual(plan.plan.destination.node, "node-b");
|
||||
assert.strictEqual(plan.plan.coordinator_assisted_rendezvous, true);
|
||||
assert.strictEqual(plan.plan.coordinator_bulk_relay_allowed, false);
|
||||
assert.ok(plan.plan.authorization_digest.startsWith("sha256:"));
|
||||
|
||||
const failed = await send(
|
||||
addr,
|
||||
rendezvousRequest({
|
||||
direct_connectivity: false,
|
||||
failure_reason: "nat traversal failed",
|
||||
})
|
||||
);
|
||||
assert.strictEqual(failed.type, "error");
|
||||
assert.match(failed.message, /nat traversal failed/);
|
||||
assert.match(failed.message, /coordinator bulk relay is disabled/);
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("QUIC smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
190
scripts/release-blocker-smoke.js
Executable file
190
scripts/release-blocker-smoke.js
Executable file
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function maybeRead(relativePath) {
|
||||
const fullPath = path.join(repo, relativePath);
|
||||
if (!fs.existsSync(fullPath)) return null;
|
||||
return fs.readFileSync(fullPath, "utf8");
|
||||
}
|
||||
|
||||
function section(source, heading) {
|
||||
const marker = `## ${heading}`;
|
||||
const start = source.indexOf(marker);
|
||||
assert(start >= 0, `missing section ${marker}`);
|
||||
const next = source.indexOf("\n## ", start + marker.length);
|
||||
return source.slice(start, next >= 0 ? next : source.length);
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing release-blocker evidence: ${name}`);
|
||||
}
|
||||
|
||||
const hiddenDemoBlockerPattern = new RegExp(
|
||||
[
|
||||
"flagship demo requires",
|
||||
["undocumented", "manual state"].join(" "),
|
||||
["hard-coded", "local paths"].join(" "),
|
||||
["demo-only", "credentials"].join(" "),
|
||||
["hidden", "setup"].join(" "),
|
||||
].join("[\\s\\S]*")
|
||||
);
|
||||
const hiddenDemoScanPattern = new RegExp(
|
||||
`demo_setup_pattern='${[
|
||||
["undocumented", "manual state"].join(" "),
|
||||
["hidden", "setup"].join(" "),
|
||||
["demo-only", "credentials?"].join(" "),
|
||||
["hard-coded", "local paths?"].join(" "),
|
||||
]
|
||||
.map((term) => term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
|
||||
.join("\\\\|")}'`
|
||||
);
|
||||
|
||||
const phase2 = read("acceptance_criteria_phase2.md");
|
||||
const base = read("acceptance_criteria.md");
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||
const operatorPanelSmoke = read("scripts/operator-panel-smoke.js");
|
||||
const schedulerSmoke = read("scripts/scheduler-placement-smoke.js");
|
||||
const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js");
|
||||
const hostedCommunitySmoke = maybeRead("private/hosted-policy/scripts/hosted-community-smoke.js");
|
||||
const releaseSourceScan = read("scripts/release-source-scan.sh");
|
||||
const flagshipDemoSmoke = read("scripts/flagship-demo-smoke.js");
|
||||
|
||||
const releaseBlockers = section(phase2, "20. Release blockers");
|
||||
expect(
|
||||
releaseBlockers,
|
||||
"cross-tenant access is listed as a release blocker",
|
||||
/Cross-tenant access succeeds for projects, nodes, processes, logs, artifacts, downloads, debug state, panels, capabilities, source manifests, credentials, or metadata/
|
||||
);
|
||||
expect(
|
||||
releaseBlockers,
|
||||
"manual-state flagship blocker is listed",
|
||||
hiddenDemoBlockerPattern
|
||||
);
|
||||
|
||||
expect(
|
||||
section(base, "21. Authorization and tenant isolation"),
|
||||
"base criteria require tenant isolation failures to block release",
|
||||
/Tenant isolation failures are treated as release blockers/
|
||||
);
|
||||
|
||||
for (const [scriptName, script] of [
|
||||
["public acceptance", publicAcceptance],
|
||||
["public split", publicSplit],
|
||||
]) {
|
||||
for (const smoke of [
|
||||
"scripts/artifact-download-smoke.js",
|
||||
"scripts/operator-panel-smoke.js",
|
||||
"scripts/source-preparation-smoke.js",
|
||||
"scripts/scheduler-placement-smoke.js",
|
||||
"scripts/flagship-demo-smoke.js",
|
||||
]) {
|
||||
assert(
|
||||
script.includes(`node ${smoke}`),
|
||||
`${scriptName} must run ${smoke} as part of tenant-isolation release blocking`
|
||||
);
|
||||
}
|
||||
assert(
|
||||
script.includes("scripts/release-source-scan.sh"),
|
||||
`${scriptName} must run release-source-scan.sh as part of release blocking`
|
||||
);
|
||||
}
|
||||
|
||||
assert(
|
||||
privateAcceptance.includes("node private/hosted-policy/scripts/hosted-community-smoke.js"),
|
||||
"private acceptance must run hosted community cross-tenant checks"
|
||||
);
|
||||
assert(
|
||||
privateAcceptance.includes("node private/hosted-policy/scripts/hosted-deployment-smoke.js"),
|
||||
"private acceptance must run hosted deployment checks"
|
||||
);
|
||||
|
||||
const boundaryEvidence = [
|
||||
[
|
||||
"artifact download",
|
||||
artifactDownloadSmoke,
|
||||
[/const crossTenant = await send/, /const crossTenantOpen = await send/, /tenant mismatch/],
|
||||
],
|
||||
[
|
||||
"operator panel",
|
||||
operatorPanelSmoke,
|
||||
[/const crossTenant = await send/, /render_operator_panel/, /scope\|tenant\|project/],
|
||||
],
|
||||
[
|
||||
"source preparation",
|
||||
sourcePreparationSmoke,
|
||||
[/const crossTenantCompletion = await send/, /complete_source_preparation/, /tenant\\\/project scope/i],
|
||||
],
|
||||
[
|
||||
"scheduler/node capability",
|
||||
schedulerSmoke,
|
||||
[/const crossTenantReport = await send/, /report_node_capabilities/, /tenant\\\/project scope/],
|
||||
],
|
||||
];
|
||||
|
||||
if (hostedCommunitySmoke) {
|
||||
boundaryEvidence.push([
|
||||
"hosted community",
|
||||
hostedCommunitySmoke,
|
||||
[
|
||||
/const foreignAgentList = await send/,
|
||||
/const crossTenantMetadata = await send/,
|
||||
/const crossTenantDownload = await send/,
|
||||
/tenant mismatch/,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
for (const [name, source, patterns] of boundaryEvidence) {
|
||||
for (const pattern of patterns) {
|
||||
expect(source, name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["release source scan rejects manual demo state", hiddenDemoScanPattern],
|
||||
["release source scan rejects hidden local paths", /hidden_local_pattern='file:\/\/\|\/home\/\[.*\]_.-\]\+\/\|\/Users\/\[.*\]_.-\]\+\/\|C:\\\\Users\\\\\|https\?:\/\/\(localhost\|127\\\.0\\\.0\\\.1\)/],
|
||||
]) {
|
||||
expect(releaseSourceScan, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["public split excludes private modules", /--exclude='\.\/private'/],
|
||||
["public split excludes experiments", /--exclude='\.\/experiments'/],
|
||||
["public split tests copied workspace", /cargo test --workspace --manifest-path "\$tmp_dir\/Cargo\.toml"/],
|
||||
["public split builds copied workspace bins", /cargo build --workspace --bins --manifest-path "\$tmp_dir\/Cargo\.toml"/],
|
||||
["public split installs CLI from copied tree", /\(cd "\$tmp_dir" && node scripts\/cli-install-smoke\.js\)/],
|
||||
["public split installs VS Code extension from copied tree", /\(cd "\$tmp_dir" && node scripts\/vscode-extension-smoke\.js\)/],
|
||||
["public split attaches node from copied tree", /\(cd "\$tmp_dir" && node scripts\/node-attach-smoke\.js\)/],
|
||||
["public split runs local services from copied tree", /\(cd "\$tmp_dir" && node scripts\/local-services-smoke\.js\)/],
|
||||
["public split runs CLI local workflow from copied tree", /\(cd "\$tmp_dir" && node scripts\/cli-local-run-smoke\.js\)/],
|
||||
["public split runs artifact download from copied tree", /\(cd "\$tmp_dir" && node scripts\/artifact-download-smoke\.js\)/],
|
||||
["public split runs artifact export from copied tree", /\(cd "\$tmp_dir" && node scripts\/artifact-export-smoke\.js\)/],
|
||||
["public split runs DAP smoke from copied tree", /\(cd "\$tmp_dir" && node scripts\/dap-smoke\.js\)/],
|
||||
["public split runs flagship demo smoke from copied tree", /\(cd "\$tmp_dir" && node scripts\/flagship-demo-smoke\.js\)/],
|
||||
]) {
|
||||
expect(publicSplit, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["flagship demo rejects local machine assumptions", /forbiddenSourceAssumptions/],
|
||||
["flagship demo rejects coordinator checkout access", /coordinator_requires_checkout_access[\s\S]*false/],
|
||||
["flagship demo asserts local source bytes stay node-local", /local_source_bytes_remain_node_local[\s\S]*true/],
|
||||
["flagship demo asserts coordinator receives no source bytes by default", /coordinator_receives_source_bytes_by_default[\s\S]*false/],
|
||||
["flagship demo asserts no default full repo tarball", /default_full_repo_tarball[\s\S]*false/],
|
||||
]) {
|
||||
expect(flagshipDemoSmoke, name, pattern);
|
||||
}
|
||||
|
||||
console.log("Release blocker smoke passed");
|
||||
71
scripts/release-source-scan.sh
Executable file
71
scripts/release-source-scan.sh
Executable file
|
|
@ -0,0 +1,71 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$repo"
|
||||
|
||||
release_paths=(
|
||||
Cargo.toml
|
||||
Cargo.lock
|
||||
README.md
|
||||
crates
|
||||
examples
|
||||
private
|
||||
scripts
|
||||
vscode-extension
|
||||
)
|
||||
|
||||
existing_release_paths=()
|
||||
for path in "${release_paths[@]}"; do
|
||||
if [[ -e "$path" ]]; then
|
||||
existing_release_paths+=("$path")
|
||||
fi
|
||||
done
|
||||
|
||||
prose_scan_paths=(
|
||||
README.md
|
||||
crates
|
||||
examples
|
||||
private
|
||||
scripts
|
||||
vscode-extension
|
||||
)
|
||||
|
||||
existing_prose_scan_paths=()
|
||||
for path in "${prose_scan_paths[@]}"; do
|
||||
if [[ -e "$path" ]]; then
|
||||
existing_prose_scan_paths+=("$path")
|
||||
fi
|
||||
done
|
||||
|
||||
scan_globs=(
|
||||
--glob '!**/target/**'
|
||||
--glob '!**/node_modules/**'
|
||||
--glob '!scripts/release-source-scan.sh'
|
||||
)
|
||||
|
||||
placeholder_pattern='debugger-gate|experiments/debugger-gate|DISASMER-DEMO|device-code-placeholder|artifact://demo|vp-local-demo'
|
||||
if rg -n "${scan_globs[@]}" "$placeholder_pattern" "${existing_release_paths[@]}"; then
|
||||
echo "release source scan failed: stale experiment/demo placeholder reference found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
demo_setup_pattern='undocumented manual state|hidden setup|demo-only credentials?|hard-coded local paths?'
|
||||
if rg -n "${scan_globs[@]}" "$demo_setup_pattern" "${existing_prose_scan_paths[@]}"; then
|
||||
echo "release source scan failed: demo requires hidden setup or demo-only state" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
hidden_local_pattern='file://|/home/[[:alnum:]_.-]+/|/Users/[[:alnum:]_.-]+/|C:\\Users\\|https?://(localhost|127\.0\.0\.1)[^[:space:]]*/artifacts/'
|
||||
if rg -n "${scan_globs[@]}" "$hidden_local_pattern" "${existing_release_paths[@]}"; then
|
||||
echo "release source scan failed: hidden local path or local artifact URL found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
public_wording_pattern='reddit|hacker news|lobsters|launch forum|traffic source|free tier'
|
||||
if rg -n "${scan_globs[@]}" "$public_wording_pattern" "${existing_prose_scan_paths[@]}"; then
|
||||
echo "release source scan failed: public-facing launch-forum/free-tier wording found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Release source scan passed"
|
||||
166
scripts/resource-metering-contract-smoke.js
Normal file
166
scripts/resource-metering-contract-smoke.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function maybeRead(segments) {
|
||||
const fullPath = path.join(repo, ...segments);
|
||||
if (!fs.existsSync(fullPath)) return null;
|
||||
return fs.readFileSync(fullPath, "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing resource metering evidence: ${name}`);
|
||||
}
|
||||
|
||||
function expectGate(script, gateName) {
|
||||
assert(
|
||||
script.includes("node scripts/resource-metering-contract-smoke.js"),
|
||||
`${gateName} must run resource-metering-contract-smoke.js`
|
||||
);
|
||||
}
|
||||
|
||||
const coreLimits = read("crates/disasmer-core/src/limits.rs");
|
||||
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||
const operatorPanelSmoke = read("scripts/operator-panel-smoke.js");
|
||||
const quicSmoke = read("scripts/quic-smoke.js");
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["API call limit kind", /LimitKind::ApiCall/],
|
||||
["spawn limit kind", /LimitKind::Spawn/],
|
||||
["log bytes limit kind", /LimitKind::LogBytes/],
|
||||
["metadata bytes limit kind", /LimitKind::MetadataBytes/],
|
||||
["debug read bytes limit kind", /LimitKind::DebugReadBytes/],
|
||||
["UI event limit kind", /LimitKind::UiEvent/],
|
||||
["rendezvous attempt limit kind", /LimitKind::RendezvousAttempt/],
|
||||
["artifact download bytes limit kind", /LimitKind::ArtifactDownloadBytes/],
|
||||
["hosted fuel limit kind", /LimitKind::HostedFuel/],
|
||||
[
|
||||
"preflight can check without consuming",
|
||||
/pub fn can_charge\([\s\S]*used\.saturating_add\(amount\) > limit/,
|
||||
],
|
||||
[
|
||||
"charge goes through preflight",
|
||||
/pub fn charge\([\s\S]*self\.can_charge\(limits, kind\.clone\(\), amount\)\?/,
|
||||
],
|
||||
]) {
|
||||
expect(coreLimits, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
[
|
||||
"rendezvous charges before transport planning",
|
||||
/CoordinatorRequest::RequestRendezvous[\s\S]*rendezvous_meter\.charge\([\s\S]*LimitKind::RendezvousAttempt[\s\S]*plan_authenticated_direct_bulk_transfer/,
|
||||
],
|
||||
[
|
||||
"artifact link creation preflights downloadable bytes before link creation",
|
||||
/CoordinatorRequest::CreateArtifactDownloadLink[\s\S]*downloadable_size[\s\S]*download_meter\.can_charge\([\s\S]*LimitKind::ArtifactDownloadBytes[\s\S]*create_download_link/,
|
||||
],
|
||||
[
|
||||
"artifact stream opening and chunks charge download bytes",
|
||||
/CoordinatorRequest::OpenArtifactDownloadStream[\s\S]*open_download_stream\([\s\S]*&mut self\.download_meter[\s\S]*stream_download_chunk\([\s\S]*charged_download_bytes/,
|
||||
],
|
||||
]) {
|
||||
expect(coordinatorService, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, source, patterns] of [
|
||||
[
|
||||
"rendezvous smoke",
|
||||
quicSmoke,
|
||||
[/charged_rendezvous_attempts, 1/, /coordinator bulk relay is disabled/],
|
||||
],
|
||||
[
|
||||
"artifact download smoke",
|
||||
artifactDownloadSmoke,
|
||||
[
|
||||
/charged_download_bytes, 16/,
|
||||
/revoked/,
|
||||
],
|
||||
],
|
||||
[
|
||||
"operator panel smoke",
|
||||
operatorPanelSmoke,
|
||||
[
|
||||
/type: "submit_panel_event"/,
|
||||
/max_events: 1/,
|
||||
/used_events, 1/,
|
||||
/rate limit/i,
|
||||
/max_download_bytes: 1/,
|
||||
/exceeds download limit/,
|
||||
],
|
||||
],
|
||||
]) {
|
||||
for (const pattern of patterns) {
|
||||
expect(source, name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
expectGate(publicAcceptance, "public acceptance");
|
||||
expectGate(publicSplit, "public split acceptance");
|
||||
expectGate(privateAcceptance, "private acceptance");
|
||||
|
||||
const privateHostedLib = maybeRead(["private", "hosted-policy", "src", "lib.rs"]);
|
||||
const privateHostedSmoke = maybeRead([
|
||||
"private",
|
||||
"hosted-policy",
|
||||
"scripts",
|
||||
"hosted-community-smoke.js",
|
||||
]);
|
||||
|
||||
if (privateHostedLib && privateHostedSmoke) {
|
||||
for (const [name, pattern] of [
|
||||
[
|
||||
"hosted API authorization meters API calls before policy decisions",
|
||||
/fn authorize\([\s\S]*LimitKind::ApiCall[\s\S]*policy\.decide/,
|
||||
],
|
||||
[
|
||||
"hosted spawn preflights before process start",
|
||||
/pub fn start_user_node_process\([\s\S]*LimitKind::Spawn[\s\S]*let active = self\.coordinator\.start_process/,
|
||||
],
|
||||
[
|
||||
"hosted zero-capability Wasm preflights through trial meter",
|
||||
/preflight_zero_capability_hosted_wasm\([\s\S]*let mut trial_meter = self\.meter\.clone\(\)[\s\S]*LimitKind::HostedFuel[\s\S]*LimitKind::HostedMemoryBytes[\s\S]*LimitKind::HostedWallClockMs[\s\S]*LimitKind::HostedStateBytes[\s\S]*LimitKind::LogBytes[\s\S]*LimitKind::MetadataBytes[\s\S]*LimitKind::UiEvent[\s\S]*LimitKind::ApiCall[\s\S]*self\.meter = trial_meter/,
|
||||
],
|
||||
[
|
||||
"hosted log recording preflights log bytes before storing logs",
|
||||
/record_user_node_task_completion\([\s\S]*LimitKind::LogBytes[\s\S]*self\.logs\.push/,
|
||||
],
|
||||
[
|
||||
"hosted debug reads preflight before inspection is created",
|
||||
/debug_process\([\s\S]*LimitKind::DebugReadBytes[\s\S]*DebugEpoch::pause/,
|
||||
],
|
||||
[
|
||||
"hosted tests cover each zero-capability Wasm budget",
|
||||
/hosted_zero_capability_wasm_preflight_rejects_each_budget_over_limit\([\s\S]*LimitKind::HostedFuel[\s\S]*LimitKind::LogBytes[\s\S]*LimitKind::MetadataBytes[\s\S]*LimitKind::UiEvent[\s\S]*LimitKind::ApiCall/,
|
||||
],
|
||||
]) {
|
||||
expect(privateHostedLib, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["running service denies hosted native compute", /hosted_native_command_preflight/],
|
||||
["running service rejects non-zero hosted capabilities", /required_capabilities: \["Network"\]/],
|
||||
["running service rejects hosted fuel overage", /resource limit exceeded.*HostedFuel/i],
|
||||
["running service rejects spawn quota before retry succeeds", /quotaExhausted[\s\S]*resource limit exceeded.*Spawn/i],
|
||||
["spawn quota survives fresh client retry", /quotaRetryFromFreshClient[\s\S]*resource limit exceeded.*Spawn/i],
|
||||
["spawn quota survives node reconnect", /quotaAfterNodeReconnect[\s\S]*resource limit exceeded.*Spawn/i],
|
||||
["running service rejects over-limit logs", /resource limit exceeded.*LogBytes/i],
|
||||
["running service exposes scoped debug inspection", /type: "debug_process"[\s\S]*debug_inspection/],
|
||||
]) {
|
||||
expect(privateHostedSmoke, name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Resource metering contract smoke passed");
|
||||
303
scripts/scheduler-placement-smoke.js
Executable file
303
scripts/scheduler-placement-smoke.js
Executable file
|
|
@ -0,0 +1,303 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function linuxCapabilities() {
|
||||
return {
|
||||
os: "Linux",
|
||||
arch: "x86_64",
|
||||
capabilities: [
|
||||
"Command",
|
||||
"Containers",
|
||||
"RootlessPodman",
|
||||
"VfsArtifacts"
|
||||
],
|
||||
environment_backends: ["Container"],
|
||||
source_providers: ["filesystem"]
|
||||
};
|
||||
}
|
||||
|
||||
async function attachNode(addr, node) {
|
||||
const attached = await send(addr, {
|
||||
type: "attach_node",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node,
|
||||
public_key: `${node}-public-key`
|
||||
});
|
||||
assert.strictEqual(attached.type, "node_attached");
|
||||
assert.strictEqual(attached.node, node);
|
||||
}
|
||||
|
||||
async function reportNode(addr, node, locality) {
|
||||
const recorded = await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node,
|
||||
capabilities: linuxCapabilities(),
|
||||
cached_environment_digests: locality.cached_environment_digests,
|
||||
dependency_cache_digests: locality.dependency_cache_digests,
|
||||
source_snapshots: locality.source_snapshots,
|
||||
artifact_locations: locality.artifact_locations,
|
||||
direct_connectivity: locality.direct_connectivity !== false,
|
||||
online: true
|
||||
});
|
||||
assert.strictEqual(recorded.type, "node_capabilities_recorded");
|
||||
assert.strictEqual(recorded.node, node);
|
||||
return recorded;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let coordinatorStderr = "";
|
||||
coordinator.stderr.on("data", (chunk) => {
|
||||
coordinatorStderr += chunk.toString();
|
||||
});
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
await attachNode(addr, "cold-node");
|
||||
await attachNode(addr, "warm-node");
|
||||
|
||||
const cold = await reportNode(addr, "cold-node", {
|
||||
cached_environment_digests: [],
|
||||
dependency_cache_digests: [],
|
||||
source_snapshots: [],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: false
|
||||
});
|
||||
assert.strictEqual(cold.node_descriptors, 1);
|
||||
|
||||
const warm = await reportNode(addr, "warm-node", {
|
||||
cached_environment_digests: ["sha256:env-linux-container"],
|
||||
dependency_cache_digests: ["sha256:deps-toolchain"],
|
||||
source_snapshots: ["sha256:source-tree"],
|
||||
artifact_locations: ["toolchain-cache"]
|
||||
});
|
||||
assert.strictEqual(warm.node_descriptors, 2);
|
||||
const reportedNodes = new Set([cold.node, warm.node]);
|
||||
assert.strictEqual(reportedNodes.size, 2);
|
||||
assert(reportedNodes.has("cold-node"));
|
||||
assert(reportedNodes.has("warm-node"));
|
||||
|
||||
const inspected = await send(addr, {
|
||||
type: "list_node_descriptors",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "operator"
|
||||
});
|
||||
assert.strictEqual(inspected.type, "node_descriptors");
|
||||
assert.strictEqual(inspected.actor, "operator");
|
||||
assert.strictEqual(inspected.descriptors.length, 2);
|
||||
const warmDescriptor = inspected.descriptors.find(
|
||||
(descriptor) => descriptor.id === "warm-node"
|
||||
);
|
||||
assert(warmDescriptor, "warm node descriptor must be visible to inspector state");
|
||||
assert(warmDescriptor.capabilities.capabilities.includes("Command"));
|
||||
assert(warmDescriptor.capabilities.capabilities.includes("RootlessPodman"));
|
||||
assert(warmDescriptor.cached_environments.includes("sha256:env-linux-container"));
|
||||
assert(warmDescriptor.dependency_caches.includes("sha256:deps-toolchain"));
|
||||
assert(warmDescriptor.source_snapshots.includes("sha256:source-tree"));
|
||||
assert(warmDescriptor.artifact_locations.includes("toolchain-cache"));
|
||||
|
||||
const crossScopeInspection = await send(addr, {
|
||||
type: "list_node_descriptors",
|
||||
tenant: "other-tenant",
|
||||
project: "project",
|
||||
actor_user: "operator"
|
||||
});
|
||||
assert.strictEqual(crossScopeInspection.type, "node_descriptors");
|
||||
assert.strictEqual(crossScopeInspection.descriptors.length, 0);
|
||||
|
||||
const crossTenantReport = await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "other-tenant",
|
||||
project: "project",
|
||||
node: "warm-node",
|
||||
capabilities: linuxCapabilities(),
|
||||
cached_environment_digests: [],
|
||||
dependency_cache_digests: [],
|
||||
source_snapshots: [],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: true,
|
||||
online: true
|
||||
});
|
||||
assert.strictEqual(crossTenantReport.type, "error");
|
||||
assert.match(crossTenantReport.message, /tenant\/project scope/);
|
||||
|
||||
const placement = await send(addr, {
|
||||
type: "schedule_task",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
environment: {
|
||||
os: "Linux",
|
||||
arch: null,
|
||||
capabilities: ["Containers", "RootlessPodman"]
|
||||
},
|
||||
environment_digest: "sha256:env-linux-container",
|
||||
required_capabilities: ["Command"],
|
||||
dependency_cache: "sha256:deps-toolchain",
|
||||
source_snapshot: "sha256:source-tree",
|
||||
required_artifacts: ["toolchain-cache"],
|
||||
prefer_node: null
|
||||
});
|
||||
assert.strictEqual(placement.type, "task_placement");
|
||||
assert.strictEqual(placement.placement.node, "warm-node");
|
||||
assert.ok(placement.placement.score > 0);
|
||||
assert.ok(placement.placement.reasons.includes("warm environment cache"));
|
||||
assert.ok(placement.placement.reasons.includes("warm dependency cache"));
|
||||
assert.ok(placement.placement.reasons.includes("source snapshot already local"));
|
||||
assert.ok(
|
||||
placement.placement.reasons.includes("1 required artifact(s) already local")
|
||||
);
|
||||
|
||||
const impossible = await send(addr, {
|
||||
type: "schedule_task",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
environment: null,
|
||||
environment_digest: null,
|
||||
required_capabilities: ["WindowsCommandDev"],
|
||||
dependency_cache: null,
|
||||
source_snapshot: null,
|
||||
required_artifacts: [],
|
||||
prefer_node: null
|
||||
});
|
||||
assert.strictEqual(impossible.type, "error");
|
||||
assert.match(impossible.message, /WindowsCommandDev/);
|
||||
|
||||
const quotaDenied = await send(addr, {
|
||||
type: "schedule_task",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
environment: null,
|
||||
environment_digest: null,
|
||||
required_capabilities: ["Command"],
|
||||
dependency_cache: null,
|
||||
source_snapshot: null,
|
||||
required_artifacts: [],
|
||||
quota_available: false,
|
||||
policy_allowed: true,
|
||||
prefer_node: null
|
||||
});
|
||||
assert.strictEqual(quotaDenied.type, "error");
|
||||
assert.match(quotaDenied.message, /quota unavailable for placement/);
|
||||
|
||||
const policyDenied = await send(addr, {
|
||||
type: "schedule_task",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
environment: null,
|
||||
environment_digest: null,
|
||||
required_capabilities: ["Command"],
|
||||
dependency_cache: null,
|
||||
source_snapshot: null,
|
||||
required_artifacts: [],
|
||||
quota_available: true,
|
||||
policy_allowed: false,
|
||||
prefer_node: null
|
||||
});
|
||||
assert.strictEqual(policyDenied.type, "error");
|
||||
assert.match(policyDenied.message, /policy denied placement/);
|
||||
|
||||
await reportNode(addr, "warm-node", {
|
||||
cached_environment_digests: [],
|
||||
dependency_cache_digests: [],
|
||||
source_snapshots: [],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: false
|
||||
});
|
||||
const disconnectedTransfer = await send(addr, {
|
||||
type: "schedule_task",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
environment: null,
|
||||
environment_digest: null,
|
||||
required_capabilities: ["Command"],
|
||||
dependency_cache: null,
|
||||
source_snapshot: "sha256:source-tree",
|
||||
required_artifacts: ["toolchain-cache"],
|
||||
prefer_node: null
|
||||
});
|
||||
assert.strictEqual(disconnectedTransfer.type, "error");
|
||||
assert.match(disconnectedTransfer.message, /source snapshot unavailable/);
|
||||
assert.match(disconnectedTransfer.message, /required artifact\(s\) unavailable/);
|
||||
assert.match(disconnectedTransfer.message, /direct connectivity unavailable/);
|
||||
} catch (error) {
|
||||
if (coordinatorStderr) {
|
||||
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("Scheduler placement smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
30
scripts/sdk-spawn-runtime-smoke.js
Executable file
30
scripts/sdk-spawn-runtime-smoke.js
Executable file
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const sdk = fs.readFileSync(path.join(repo, "crates/disasmer-sdk/src/lib.rs"), "utf8");
|
||||
|
||||
assert.match(sdk, /pub struct RuntimeSpawnEvent/);
|
||||
assert.match(sdk, /pub debugger_visible: bool/);
|
||||
assert.match(sdk, /fn register_runtime_thread/);
|
||||
assert.match(sdk, /register_runtime_thread\(id, self\.name, self\.env\)/);
|
||||
assert.match(sdk, /pub fn debugger_visible\(&self\) -> bool/);
|
||||
|
||||
cp.execFileSync(
|
||||
"cargo",
|
||||
[
|
||||
"test",
|
||||
"-p",
|
||||
"disasmer-sdk",
|
||||
"spawn_task_start_registers_debugger_visible_runtime_thread",
|
||||
"--",
|
||||
"--exact",
|
||||
],
|
||||
{ cwd: repo, stdio: "inherit" }
|
||||
);
|
||||
|
||||
console.log("SDK spawn runtime smoke passed");
|
||||
246
scripts/self-hosted-coordinator-smoke.js
Normal file
246
scripts/self-hosted-coordinator-smoke.js
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const teamArtifactDigest = `sha256:${"a".repeat(64)}`;
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function linuxNodeCapabilities() {
|
||||
return {
|
||||
os: "Linux",
|
||||
arch: "x86_64",
|
||||
capabilities: ["Command", "RootlessPodman", "VfsArtifacts", "QuicDirect"],
|
||||
environment_backends: ["Container"],
|
||||
source_providers: ["filesystem", "git"]
|
||||
};
|
||||
}
|
||||
|
||||
async function attachTrustedNode(addr, node) {
|
||||
const attached = await send(addr, {
|
||||
type: "attach_node",
|
||||
tenant: "team",
|
||||
project: "self-hosted",
|
||||
node,
|
||||
public_key: `${node}-public-key`
|
||||
});
|
||||
assert.strictEqual(attached.type, "node_attached");
|
||||
|
||||
const heartbeat = await send(addr, {
|
||||
type: "node_heartbeat",
|
||||
node
|
||||
});
|
||||
assert.strictEqual(heartbeat.type, "node_heartbeat");
|
||||
|
||||
const reported = await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "team",
|
||||
project: "self-hosted",
|
||||
node,
|
||||
capabilities: linuxNodeCapabilities(),
|
||||
cached_environment_digests: ["sha256:env-team-linux"],
|
||||
dependency_cache_digests: ["sha256:cargo-cache"],
|
||||
source_snapshots: ["sha256:source-team"],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: true,
|
||||
online: true
|
||||
});
|
||||
assert.strictEqual(reported.type, "node_capabilities_recorded");
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
await attachTrustedNode(addr, "team-linux-a");
|
||||
await attachTrustedNode(addr, "team-linux-b");
|
||||
|
||||
const placement = await send(addr, {
|
||||
type: "schedule_task",
|
||||
tenant: "team",
|
||||
project: "self-hosted",
|
||||
environment: {
|
||||
os: "Linux",
|
||||
arch: "x86_64",
|
||||
capabilities: ["Command", "QuicDirect"]
|
||||
},
|
||||
environment_digest: "sha256:env-team-linux",
|
||||
required_capabilities: ["VfsArtifacts"],
|
||||
source_snapshot: "sha256:source-team",
|
||||
required_artifacts: [],
|
||||
prefer_node: "team-linux-a"
|
||||
});
|
||||
assert.strictEqual(placement.type, "task_placement");
|
||||
assert.strictEqual(placement.placement.node, "team-linux-a");
|
||||
assert(placement.placement.reasons.includes("preferred node"));
|
||||
assert(placement.placement.reasons.includes("warm environment cache"));
|
||||
assert(placement.placement.reasons.includes("source snapshot already local"));
|
||||
|
||||
const started = await send(addr, {
|
||||
type: "start_process",
|
||||
tenant: "team",
|
||||
project: "self-hosted",
|
||||
process: "vp-team-build"
|
||||
});
|
||||
assert.strictEqual(started.type, "process_started");
|
||||
|
||||
const reconnected = await send(addr, {
|
||||
type: "reconnect_node",
|
||||
node: "team-linux-a",
|
||||
process: "vp-team-build",
|
||||
epoch: started.epoch
|
||||
});
|
||||
assert.strictEqual(reconnected.type, "node_reconnected");
|
||||
|
||||
const completed = await send(addr, {
|
||||
type: "task_completed",
|
||||
tenant: "team",
|
||||
project: "self-hosted",
|
||||
process: "vp-team-build",
|
||||
node: "team-linux-a",
|
||||
task: "compile-linux",
|
||||
status_code: 0,
|
||||
stdout_bytes: 18,
|
||||
stderr_bytes: 0,
|
||||
artifact_path: "/vfs/artifacts/team-output.txt",
|
||||
artifact_digest: teamArtifactDigest,
|
||||
artifact_size_bytes: 18
|
||||
});
|
||||
assert.strictEqual(completed.type, "task_recorded");
|
||||
|
||||
const events = await send(addr, {
|
||||
type: "list_task_events",
|
||||
tenant: "team",
|
||||
project: "self-hosted",
|
||||
actor_user: "developer",
|
||||
process: "vp-team-build"
|
||||
});
|
||||
assert.strictEqual(events.type, "task_events");
|
||||
assert.strictEqual(events.events.length, 1);
|
||||
assert.strictEqual(events.events[0].node, "team-linux-a");
|
||||
assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/team-output.txt");
|
||||
|
||||
const link = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
tenant: "team",
|
||||
project: "self-hosted",
|
||||
actor_user: "developer",
|
||||
artifact: "team-output.txt",
|
||||
max_bytes: 1024 * 1024,
|
||||
token_nonce: "team-download",
|
||||
now_epoch_seconds: 10,
|
||||
ttl_seconds: 60
|
||||
});
|
||||
assert.strictEqual(link.type, "artifact_download_link");
|
||||
assert.deepStrictEqual(link.link.source, { RetainedNode: "team-linux-a" });
|
||||
|
||||
const exportPlan = await send(addr, {
|
||||
type: "export_artifact_to_node",
|
||||
tenant: "team",
|
||||
project: "self-hosted",
|
||||
actor_user: "developer",
|
||||
artifact: "team-output.txt",
|
||||
receiver_node: "team-linux-b",
|
||||
direct_connectivity: true,
|
||||
failure_reason: ""
|
||||
});
|
||||
assert.strictEqual(exportPlan.type, "artifact_export_plan");
|
||||
assert.strictEqual(exportPlan.source_node, "team-linux-a");
|
||||
assert.strictEqual(exportPlan.receiver_node, "team-linux-b");
|
||||
assert.strictEqual(exportPlan.plan.transport, "NativeQuic");
|
||||
assert.strictEqual(exportPlan.plan.coordinator_assisted_rendezvous, true);
|
||||
assert.strictEqual(exportPlan.plan.coordinator_bulk_relay_allowed, false);
|
||||
|
||||
const reportPath = path.join(repo, "target/acceptance/public-coordinator-compat.json");
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
reportPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
kind: "disasmer-public-coordinator-compatibility",
|
||||
operator_implementation: "standalone-public-coordinator",
|
||||
coordinator_addr: ready.listen,
|
||||
ping: "pong",
|
||||
nodes: ["team-linux-a", "team-linux-b"],
|
||||
task_placement: placement.type,
|
||||
process_started: started.type,
|
||||
task_completion: completed.type,
|
||||
task_events: events.events.length,
|
||||
artifact_download_link: link.type,
|
||||
artifact_export_plan: exportPlan.type,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
);
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("Self-hosted coordinator smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
212
scripts/source-preparation-smoke.js
Executable file
212
scripts/source-preparation-smoke.js
Executable file
|
|
@ -0,0 +1,212 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sourceCapableNode(sourceProviders = ["git"]) {
|
||||
return {
|
||||
os: "Linux",
|
||||
arch: "x86_64",
|
||||
capabilities: ["Command", "SourceFilesystem", "SourceGit"],
|
||||
environment_backends: [],
|
||||
source_providers: sourceProviders
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let coordinatorStderr = "";
|
||||
coordinator.stderr.on("data", (chunk) => {
|
||||
coordinatorStderr += chunk.toString();
|
||||
});
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
const pending = await send(addr, {
|
||||
type: "request_source_preparation",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
provider: "Git"
|
||||
});
|
||||
assert.strictEqual(pending.type, "source_preparation");
|
||||
assert.strictEqual(pending.status.preparation.tenant, "tenant");
|
||||
assert.strictEqual(pending.status.preparation.project, "project");
|
||||
assert.strictEqual(pending.status.preparation.provider, "Git");
|
||||
assert.strictEqual(
|
||||
pending.status.preparation.coordinator_requires_checkout_access,
|
||||
false
|
||||
);
|
||||
assert.deepStrictEqual(pending.status.preparation.required_capabilities, [
|
||||
"SourceGit"
|
||||
]);
|
||||
assert.match(pending.status.disposition.Pending.reason, /waiting|node/i);
|
||||
|
||||
for (const node of ["source-cold", "source-ready"]) {
|
||||
const attached = await send(addr, {
|
||||
type: "attach_node",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node,
|
||||
public_key: `${node}-public-key`
|
||||
});
|
||||
assert.strictEqual(attached.type, "node_attached");
|
||||
}
|
||||
|
||||
const cold = await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "source-cold",
|
||||
capabilities: sourceCapableNode(),
|
||||
cached_environment_digests: [],
|
||||
source_snapshots: [],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: true,
|
||||
online: true
|
||||
});
|
||||
assert.strictEqual(cold.type, "node_capabilities_recorded");
|
||||
|
||||
const readyReport = await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "source-ready",
|
||||
capabilities: sourceCapableNode(),
|
||||
cached_environment_digests: [],
|
||||
source_snapshots: [],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: true,
|
||||
online: true
|
||||
});
|
||||
assert.strictEqual(readyReport.type, "node_capabilities_recorded");
|
||||
|
||||
const assigned = await send(addr, {
|
||||
type: "request_source_preparation",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
provider: "Git"
|
||||
});
|
||||
assert.strictEqual(assigned.type, "source_preparation");
|
||||
assert(["source-cold", "source-ready"].includes(assigned.status.disposition.Assigned.node));
|
||||
assert.strictEqual(
|
||||
assigned.status.preparation.coordinator_requires_checkout_access,
|
||||
false
|
||||
);
|
||||
|
||||
const crossTenantCompletion = await send(addr, {
|
||||
type: "complete_source_preparation",
|
||||
tenant: "other",
|
||||
project: "project",
|
||||
node: "source-ready",
|
||||
provider: "Git",
|
||||
source_snapshot: "sha256:source-prepared"
|
||||
});
|
||||
assert.strictEqual(crossTenantCompletion.type, "error");
|
||||
assert.match(crossTenantCompletion.message, /tenant\/project scope/i);
|
||||
|
||||
const completed = await send(addr, {
|
||||
type: "complete_source_preparation",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "source-ready",
|
||||
provider: "Git",
|
||||
source_snapshot: "sha256:source-prepared"
|
||||
});
|
||||
assert.strictEqual(completed.type, "source_preparation_completed");
|
||||
assert.strictEqual(completed.node, "source-ready");
|
||||
assert.strictEqual(completed.provider, "Git");
|
||||
assert.strictEqual(completed.source_snapshot, "sha256:source-prepared");
|
||||
|
||||
const placement = await send(addr, {
|
||||
type: "schedule_task",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
environment: null,
|
||||
environment_digest: null,
|
||||
required_capabilities: ["SourceGit"],
|
||||
source_snapshot: "sha256:source-prepared",
|
||||
required_artifacts: [],
|
||||
prefer_node: null
|
||||
});
|
||||
assert.strictEqual(placement.type, "task_placement");
|
||||
assert.strictEqual(placement.placement.node, "source-ready");
|
||||
assert(
|
||||
placement.placement.reasons.includes("source snapshot already local"),
|
||||
"completed source preparation must update node source locality"
|
||||
);
|
||||
} catch (error) {
|
||||
if (coordinatorStderr) {
|
||||
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("Source preparation smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
179
scripts/tenant-isolation-contract-smoke.js
Normal file
179
scripts/tenant-isolation-contract-smoke.js
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function maybeRead(segments) {
|
||||
const fullPath = path.join(repo, ...segments);
|
||||
if (!fs.existsSync(fullPath)) return null;
|
||||
return fs.readFileSync(fullPath, "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing tenant-isolation evidence: ${name}`);
|
||||
}
|
||||
|
||||
function expectGate(script, gateName) {
|
||||
assert(
|
||||
script.includes("node scripts/tenant-isolation-contract-smoke.js"),
|
||||
`${gateName} must run tenant-isolation-contract-smoke.js`
|
||||
);
|
||||
}
|
||||
|
||||
const auth = read("crates/disasmer-core/src/auth.rs");
|
||||
const artifact = read("crates/disasmer-core/src/artifact.rs");
|
||||
const operatorPanel = read("crates/disasmer-core/src/operator_panel.rs");
|
||||
const source = read("crates/disasmer-core/src/source.rs");
|
||||
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||
const artifactDownloadSmoke = read("scripts/artifact-download-smoke.js");
|
||||
const operatorPanelSmoke = read("scripts/operator-panel-smoke.js");
|
||||
const schedulerSmoke = read("scripts/scheduler-placement-smoke.js");
|
||||
const sourcePreparationSmoke = read("scripts/source-preparation-smoke.js");
|
||||
const releaseBlockerSmoke = read("scripts/release-blocker-smoke.js");
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
const privateAcceptance = read("scripts/acceptance-private.sh");
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["auth contexts carry tenant and project", /pub struct AuthContext[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
["enrollment grants carry tenant and project", /pub struct EnrollmentGrant[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
["node credentials carry tenant and project", /pub struct NodeCredential[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
["same tenant/project denies tenant mismatch", /pub fn same_tenant_project[\s\S]*tenant mismatch/],
|
||||
["same tenant/project denies project mismatch", /pub fn same_tenant_project[\s\S]*project mismatch/],
|
||||
["auth unit denies cross-tenant access", /fn tenant_project_scope_denies_cross_tenant_access\(\)/],
|
||||
]) {
|
||||
expect(auth, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["artifact metadata carries tenant and project", /pub struct ArtifactMetadata[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
["download links carry tenant project process and actor", /pub struct DownloadLink[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub process: ProcessId[\s\S]*pub actor: Actor/],
|
||||
["downloads authorize against scoped metadata", /let authz = same_tenant_project\(context, &scope\)/],
|
||||
["artifact test denies cross-tenant download", /fn cross_tenant_download_is_denied_even_with_known_artifact_id\(\)/],
|
||||
["artifact test denies cross-project download", /fn cross_project_download_is_denied_even_with_known_artifact_id\(\)/],
|
||||
]) {
|
||||
expect(artifact, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["panel state carries tenant project and process", /pub struct PanelState[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub process: ProcessId/],
|
||||
["panel events carry tenant project and process", /pub struct PanelEvent[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId[\s\S]*pub process: ProcessId/],
|
||||
["panel events reject scope mismatch", /PanelError::ScopeMismatch/],
|
||||
["panel scope validation compares tenant project process", /self\.tenant != event\.tenant[\s\S]*self\.project != event\.project[\s\S]*self\.process != event\.process/],
|
||||
]) {
|
||||
expect(operatorPanel, name, pattern);
|
||||
}
|
||||
|
||||
expect(
|
||||
source,
|
||||
"source preparation carries tenant and project",
|
||||
/pub struct SourcePreparation[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/
|
||||
);
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["project creation rejects foreign tenant reuse", /project id is outside the signed-in tenant scope/],
|
||||
["project selection rejects foreign tenant", /project is outside the signed-in tenant scope/],
|
||||
["project listing uses tenant-scoped context", /CoordinatorRequest::ListProjects[\s\S]*list_projects\(&context\)/],
|
||||
["node capability reports check enrollment tenant scope", /node capability report is outside the enrolled tenant\/project scope/],
|
||||
["operator panel stop state is tenant/project/process keyed", /type PanelStopKey = \(TenantId, ProjectId, ProcessId\)/],
|
||||
["download service tests tenant mismatch", /cross_tenant\.to_string\(\)\.contains\("tenant mismatch"\)/],
|
||||
["download service tests project mismatch", /cross_project\.to_string\(\)\.contains\("project mismatch"\)/],
|
||||
["node capability test rejects cross-scope report", /fn service_rejects_node_capability_report_outside_enrollment_scope\(\)/],
|
||||
["source preparation test rejects cross-scope completion", /fn service_rejects_source_preparation_completion_outside_node_scope\(\)/],
|
||||
]) {
|
||||
expect(coordinatorService, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, sourceText, patterns] of [
|
||||
[
|
||||
"artifact download smoke",
|
||||
artifactDownloadSmoke,
|
||||
[
|
||||
/const crossTenant = await send/,
|
||||
/const crossProject = await send/,
|
||||
/const crossTenantOpen = await send/,
|
||||
/const crossProjectOpen = await send/,
|
||||
/tenant mismatch/,
|
||||
/project mismatch/,
|
||||
/token is invalid/,
|
||||
],
|
||||
],
|
||||
[
|
||||
"operator panel smoke",
|
||||
operatorPanelSmoke,
|
||||
[/const crossTenant = await send/, /render_operator_panel/, /scope\|tenant\|project/],
|
||||
],
|
||||
[
|
||||
"scheduler and capability smoke",
|
||||
schedulerSmoke,
|
||||
[
|
||||
/const crossScopeInspection = await send/,
|
||||
/assert\.strictEqual\(crossScopeInspection\.descriptors\.length, 0\)/,
|
||||
/const crossTenantReport = await send/,
|
||||
/tenant\\\/project scope/,
|
||||
],
|
||||
],
|
||||
[
|
||||
"source preparation smoke",
|
||||
sourcePreparationSmoke,
|
||||
[/const crossTenantCompletion = await send/, /complete_source_preparation/, /tenant\\\/project scope/i],
|
||||
],
|
||||
]) {
|
||||
for (const pattern of patterns) {
|
||||
expect(sourceText, name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
expect(releaseBlockerSmoke, "release blockers list tenant-isolation failure", /cross-tenant access is listed as a release blocker/);
|
||||
expectGate(publicAcceptance, "public acceptance");
|
||||
expectGate(publicSplit, "public split acceptance");
|
||||
expectGate(privateAcceptance, "private acceptance");
|
||||
|
||||
const hostedLib = maybeRead(["private", "hosted-policy", "src", "lib.rs"]);
|
||||
const hostedSmoke = maybeRead([
|
||||
"private",
|
||||
"hosted-policy",
|
||||
"scripts",
|
||||
"hosted-community-smoke.js",
|
||||
]);
|
||||
|
||||
if (hostedLib && hostedSmoke) {
|
||||
for (const [name, pattern] of [
|
||||
["hosted agent keys are tenant/project keyed", /agent_public_keys: BTreeMap<\(TenantId, ProjectId, AgentId\), AgentPublicKeyRecord>/],
|
||||
["hosted agent records carry tenant and project", /pub struct AgentPublicKeyRecord[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
["hosted node statuses carry tenant and project", /pub struct HostedNodeStatus[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
["hosted process statuses carry tenant and project", /pub struct HostedProcessStatus[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
["hosted log entries carry tenant and project", /pub struct HostedLogEntry[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
["hosted debug statuses carry tenant and project", /pub struct HostedDebugSessionStatus[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
["hosted observability snapshots carry tenant and project", /pub struct HostedObservabilitySnapshot[\s\S]*pub tenant: TenantId[\s\S]*pub project: ProjectId/],
|
||||
["hosted observability filters nodes by tenant/project", /node_statuses[\s\S]*filter\(\|status\| status\.tenant == context\.tenant && status\.project == context\.project\)/],
|
||||
["hosted observability filters logs by tenant/project", /log_entries[\s\S]*filter\(\|entry\| entry\.tenant == context\.tenant && entry\.project == context\.project\)/],
|
||||
["hosted observability filters artifacts by tenant/project", /metadata\.tenant == context\.tenant && metadata\.project == context\.project/],
|
||||
["hosted observability filters debug by tenant/project", /debug_sessions[\s\S]*filter\(\|status\| status\.tenant == context\.tenant && status\.project == context\.project\)/],
|
||||
["hosted artifact metadata inspection authorizes scoped metadata", /artifact_metadata_for_context[\s\S]*self\.authorize\(context, &scope, Action::Inspect/],
|
||||
["hosted scheduling rejects foreign nodes", /hosted scheduling targets only authorized user-attached project nodes/],
|
||||
]) {
|
||||
expect(hostedLib, name, pattern);
|
||||
}
|
||||
|
||||
for (const [name, pattern] of [
|
||||
["foreign agent list is empty", /const foreignAgentList = await send[\s\S]*records, \[\]/],
|
||||
["foreign start is denied", /const foreignStart = await send[\s\S]*authorized user-attached project nodes\|tenant mismatch/],
|
||||
["cross-project debug is denied", /const crossProjectDebug = await send[\s\S]*tenant\|project\|debug/],
|
||||
["cross-actor debug is denied", /const crossActorDebug = await send[\s\S]*debug\|permission\|actor\|user/],
|
||||
["cross-tenant metadata is denied", /const crossTenantMetadata = await send[\s\S]*tenant mismatch/],
|
||||
["foreign snapshot reveals no objects", /foreignSnapshot[\s\S]*snapshot\.nodes, \[\][\s\S]*snapshot\.processes, \[\][\s\S]*snapshot\.logs, \[\][\s\S]*snapshot\.artifacts, \[\][\s\S]*snapshot\.debug_sessions, \[\]/],
|
||||
["cross-tenant download is denied", /const crossTenantDownload = await send[\s\S]*tenant mismatch/],
|
||||
]) {
|
||||
expect(hostedSmoke, name, pattern);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Tenant isolation contract smoke passed");
|
||||
107
scripts/user-session-token-boundary-smoke.js
Executable file
107
scripts/user-session-token-boundary-smoke.js
Executable file
|
|
@ -0,0 +1,107 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(file) {
|
||||
return fs.readFileSync(path.join(repo, file), "utf8");
|
||||
}
|
||||
|
||||
function extractBalancedBlock(source, marker) {
|
||||
const start = source.indexOf(marker);
|
||||
assert(start >= 0, `missing marker ${marker}`);
|
||||
const open = source.indexOf("{", start);
|
||||
assert(open >= 0, `missing opening brace for ${marker}`);
|
||||
let depth = 0;
|
||||
for (let index = open; index < source.length; index += 1) {
|
||||
const char = source[index];
|
||||
if (char === "{") depth += 1;
|
||||
if (char === "}") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return source.slice(start, index + 1);
|
||||
}
|
||||
}
|
||||
throw new Error(`missing closing brace for ${marker}`);
|
||||
}
|
||||
|
||||
function extractEnumVariant(source, variant) {
|
||||
const marker = ` ${variant} {`;
|
||||
return extractBalancedBlock(source, marker);
|
||||
}
|
||||
|
||||
const forbiddenUserSessionCredentials =
|
||||
/\b(BrowserSession|CliDeviceSession|BrowserLoginFlow|CliLoginFlow|authorization_url|verification_url|device_code|access_token|refresh_token|provider_token|provider_tokens|session_token|user_token|oauth_token)\b/i;
|
||||
|
||||
function assertNoUserSessionCredential(surface, text) {
|
||||
assert.doesNotMatch(
|
||||
text,
|
||||
forbiddenUserSessionCredentials,
|
||||
`${surface} must not carry user OAuth/browser/session credentials`
|
||||
);
|
||||
}
|
||||
|
||||
const coordinatorService = read("crates/disasmer-coordinator/src/service.rs");
|
||||
for (const variant of [
|
||||
"AttachNode",
|
||||
"NodeHeartbeat",
|
||||
"ReportNodeCapabilities",
|
||||
"RequestRendezvous",
|
||||
"RequestSourcePreparation",
|
||||
"CompleteSourcePreparation",
|
||||
"StartProcess",
|
||||
"ReconnectNode",
|
||||
"CancelTask",
|
||||
"PollTaskControl",
|
||||
"TaskCompleted",
|
||||
]) {
|
||||
assertNoUserSessionCredential(
|
||||
`CoordinatorRequest::${variant}`,
|
||||
extractEnumVariant(coordinatorService, variant)
|
||||
);
|
||||
}
|
||||
|
||||
const nodeRuntime = read("crates/disasmer-node/src/lib.rs");
|
||||
for (const marker of [
|
||||
"pub struct LinuxCommandRunPlan",
|
||||
"pub struct LinuxCommandTaskOutput",
|
||||
"pub struct CapturedCommandLogs",
|
||||
"pub struct VirtualThreadCommand",
|
||||
"pub struct CommandOutput",
|
||||
]) {
|
||||
assertNoUserSessionCredential(marker, extractBalancedBlock(nodeRuntime, marker));
|
||||
}
|
||||
|
||||
const coreExecution = read("crates/disasmer-core/src/execution.rs");
|
||||
assertNoUserSessionCredential(
|
||||
"CommandInvocation",
|
||||
extractBalancedBlock(coreExecution, "pub struct CommandInvocation")
|
||||
);
|
||||
|
||||
const dapAdapter = read("crates/disasmer-dap/src/main.rs");
|
||||
assertNoUserSessionCredential(
|
||||
"DAP variables response",
|
||||
extractBalancedBlock(dapAdapter, "fn variables_response")
|
||||
);
|
||||
|
||||
const panel = read("crates/disasmer-core/src/operator_panel.rs");
|
||||
assertNoUserSessionCredential(
|
||||
"PanelEvent",
|
||||
extractBalancedBlock(panel, "pub struct PanelEvent")
|
||||
);
|
||||
|
||||
const auth = read("crates/disasmer-core/src/auth.rs");
|
||||
assert.match(
|
||||
auth,
|
||||
/task_credentials_do_not_contain_user_session/,
|
||||
"core auth must keep the task credential user-session guard"
|
||||
);
|
||||
assert.match(
|
||||
auth,
|
||||
/CredentialKind::BrowserSession \| CredentialKind::CliDeviceSession/,
|
||||
"task credential guard must reject browser and CLI sessions"
|
||||
);
|
||||
|
||||
console.log("User session token boundary smoke passed");
|
||||
70
scripts/verify-public-split.sh
Executable file
70
scripts/verify-public-split.sh
Executable file
|
|
@ -0,0 +1,70 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
source_commit="$(git -C "$repo_root" rev-parse HEAD)"
|
||||
export DISASMER_ACCEPTANCE_COMMIT="$source_commit"
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
tar \
|
||||
--exclude='./.git' \
|
||||
--exclude='./target' \
|
||||
--exclude='./private' \
|
||||
--exclude='./experiments' \
|
||||
-C "$repo_root" \
|
||||
-cf - . | tar -C "$tmp_dir" -xf -
|
||||
|
||||
if find "$tmp_dir" -path "$tmp_dir/private" -print -quit | grep -q .; then
|
||||
echo "private directory leaked into public split" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if find "$tmp_dir" -path "$tmp_dir/experiments" -print -quit | grep -q .; then
|
||||
echo "experiments directory leaked into public split" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cargo test --workspace --manifest-path "$tmp_dir/Cargo.toml"
|
||||
cargo build --workspace --bins --manifest-path "$tmp_dir/Cargo.toml"
|
||||
(cd "$tmp_dir" && node scripts/acceptance-report-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/acceptance-doc-contract-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/acceptance-environment-contract-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/acceptance-evidence-contract-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/public-private-boundary-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/release-blocker-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/resource-metering-contract-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/hostile-input-contract-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/tenant-isolation-contract-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/public-story-contract-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/public-release-dryrun-contract-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/self-hosted-coordinator-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/public-local-demo-matrix-smoke.js)
|
||||
(cd "$tmp_dir" && scripts/release-source-scan.sh)
|
||||
(cd "$tmp_dir" && node scripts/prepare-public-release-dryrun.js)
|
||||
if [[ -n "${DISASMER_FORGEJO_TOKEN:-}" ]]; then
|
||||
(cd "$tmp_dir" && node scripts/publish-public-release-dryrun.js)
|
||||
fi
|
||||
(cd "$tmp_dir" && node scripts/docs-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/cli-login-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/cli-browser-login-flow-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/cli-install-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/user-session-token-boundary-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/sdk-spawn-runtime-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/node-lifecycle-contract-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/vscode-extension-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/vscode-f5-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/node-attach-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/local-services-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/cancellation-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/cli-local-run-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/artifact-download-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/artifact-export-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/operator-panel-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/source-preparation-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/scheduler-placement-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/windows-best-effort-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/windows-validation-contract-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/quic-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/dap-smoke.js)
|
||||
(cd "$tmp_dir" && node scripts/flagship-demo-smoke.js)
|
||||
179
scripts/vscode-extension-smoke.js
Executable file
179
scripts/vscode-extension-smoke.js
Executable file
|
|
@ -0,0 +1,179 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const assert = require("assert");
|
||||
|
||||
const extension = require("../vscode-extension/extension");
|
||||
const packageJson = require("../vscode-extension/package.json");
|
||||
const extensionSource = fs.readFileSync(
|
||||
path.join(__dirname, "../vscode-extension/extension.js"),
|
||||
"utf8"
|
||||
);
|
||||
const readme = fs.readFileSync(path.join(__dirname, "../README.md"), "utf8");
|
||||
const defaultOperatorEndpoint = "https://disasmer.michelpaulissen.com:9443";
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
assert.strictEqual(packageJson.main, "./extension.js");
|
||||
assert(fs.existsSync(path.join(__dirname, "../vscode-extension", packageJson.main)));
|
||||
assert.deepStrictEqual(packageJson.dependencies || {}, {});
|
||||
assert.match(readme, /code --extensionDevelopmentPath "\$\(pwd\)\/vscode-extension"/);
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-vscode-"));
|
||||
fs.mkdirSync(path.join(root, "envs/linux"), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, "envs/linux/Containerfile"), "FROM alpine\n");
|
||||
fs.mkdirSync(path.join(root, ".disasmer"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
extension.disasmerViewStatePath(root),
|
||||
JSON.stringify({
|
||||
nodes: [{ id: "node-linux", status: "online", capabilities: "Command RootlessPodman" }],
|
||||
processes: [{ id: "vp-build", status: "running", entry: "build" }],
|
||||
logs: [{ task: "compile-linux", message: "stdout=12 stderr=0", bytes: 12 }],
|
||||
artifacts: [{ path: "/vfs/artifacts/app.tar.zst", status: "retained", size: 12 }],
|
||||
inspector: [{ label: "debug", value: "attached" }]
|
||||
})
|
||||
);
|
||||
|
||||
const envs = extension.discoverEnvironmentNames(root);
|
||||
assert.deepStrictEqual(envs, ["linux"]);
|
||||
|
||||
const diagnostics = extension.diagnoseEnvReferences(
|
||||
'let _ = env!("linux"); let _ = env!("windows");',
|
||||
envs
|
||||
);
|
||||
assert.strictEqual(diagnostics.length, 1);
|
||||
assert.strictEqual(diagnostics[0].name, "windows");
|
||||
assert.match(diagnostics[0].message, /envs\/windows\/Containerfile/);
|
||||
|
||||
const inspectCommand = extension.bundleInspectCommand(root, "/repo");
|
||||
assert.strictEqual(inspectCommand.command, "cargo");
|
||||
assert.deepStrictEqual(inspectCommand.args.slice(0, 8), [
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-cli",
|
||||
"--bin",
|
||||
"disasmer",
|
||||
"--",
|
||||
"bundle"
|
||||
]);
|
||||
assert(inspectCommand.args.includes("inspect"));
|
||||
assert(inspectCommand.args.includes("--project"));
|
||||
assert(inspectCommand.args.includes(root));
|
||||
|
||||
const refreshed = extension.refreshBundleBeforeLaunch(root, "/repo", (command, args, options) => {
|
||||
assert.strictEqual(command, "cargo");
|
||||
assert(args.includes("bundle"));
|
||||
assert.strictEqual(options.cwd, "/repo");
|
||||
return {
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ metadata: { identity: "sha256:abc" } }),
|
||||
stderr: ""
|
||||
};
|
||||
});
|
||||
assert.strictEqual(refreshed.metadata.identity, "sha256:abc");
|
||||
|
||||
const launch = extension.resolveDisasmerDebugConfiguration(
|
||||
{ uri: { fsPath: root } },
|
||||
{}
|
||||
);
|
||||
assert.deepStrictEqual(launch, {
|
||||
name: "Disasmer: Launch Virtual Process",
|
||||
type: "disasmer",
|
||||
request: "launch",
|
||||
entry: "build",
|
||||
project: root,
|
||||
runtimeBackend: "local-services",
|
||||
operatorEndpoint: defaultOperatorEndpoint
|
||||
});
|
||||
assert.strictEqual(extension.defaultOperatorEndpoint(), defaultOperatorEndpoint);
|
||||
|
||||
const adapter = extension.debugAdapterExecutableSpec(root, repo);
|
||||
assert.strictEqual(adapter.command, "cargo");
|
||||
assert.deepStrictEqual(adapter.args, [
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-dap",
|
||||
"--bin",
|
||||
"disasmer-debug-dap"
|
||||
]);
|
||||
assert.deepStrictEqual(adapter.options, { cwd: repo });
|
||||
|
||||
const releasedAdapterPath = path.join(root, process.platform === "win32" ? "disasmer-debug-dap.exe" : "disasmer-debug-dap");
|
||||
fs.writeFileSync(releasedAdapterPath, "");
|
||||
const releasedAdapter = extension.debugAdapterExecutableSpec(root, repo);
|
||||
assert.strictEqual(releasedAdapter.command, releasedAdapterPath);
|
||||
assert.deepStrictEqual(releasedAdapter.args, []);
|
||||
assert.deepStrictEqual(releasedAdapter.options, { cwd: root });
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
extension.refreshBundleBeforeLaunch(root, "/repo", () => ({
|
||||
status: 1,
|
||||
stdout: "",
|
||||
stderr: "missing environment linux"
|
||||
})),
|
||||
/missing environment linux/
|
||||
);
|
||||
|
||||
assert(
|
||||
packageJson.contributes.viewsContainers.activitybar.some(
|
||||
(container) => container.id === "disasmer" && container.title === "Disasmer"
|
||||
),
|
||||
"package.json must contribute a Disasmer activity-bar container"
|
||||
);
|
||||
assert(
|
||||
fs.existsSync(path.join(__dirname, "../vscode-extension/resources/disasmer.svg")),
|
||||
"Disasmer activity-bar icon must exist"
|
||||
);
|
||||
const packageViewIds = packageJson.contributes.views.disasmer.map((view) => view.id).sort();
|
||||
const descriptorViewIds = extension.disasmerViewDescriptors().map((view) => view.id).sort();
|
||||
assert.deepStrictEqual(descriptorViewIds, [
|
||||
"disasmer.artifacts",
|
||||
"disasmer.inspector",
|
||||
"disasmer.logs",
|
||||
"disasmer.nodes",
|
||||
"disasmer.processes"
|
||||
]);
|
||||
assert.deepStrictEqual(packageViewIds, descriptorViewIds);
|
||||
for (const viewId of descriptorViewIds) {
|
||||
const items = extension.disasmerViewItems(
|
||||
extension.loadDisasmerViewState(root),
|
||||
viewId
|
||||
);
|
||||
assert(
|
||||
items.length > 0 && !items[0].label.startsWith("No "),
|
||||
`${viewId} should render state-backed items`
|
||||
);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
extension.disasmerViewItems(extension.loadDisasmerViewState(root), "disasmer.nodes")[0],
|
||||
{
|
||||
label: "node-linux",
|
||||
description: "online Command RootlessPodman"
|
||||
}
|
||||
);
|
||||
|
||||
assert(
|
||||
packageJson.contributes.debuggers.some((debuggerContribution) => debuggerContribution.type === "disasmer"),
|
||||
"package.json must contribute the disasmer debugger type"
|
||||
);
|
||||
for (const viewId of descriptorViewIds) {
|
||||
assert(
|
||||
packageJson.activationEvents.includes(`onView:${viewId}`),
|
||||
`${viewId} should activate the extension when opened`
|
||||
);
|
||||
}
|
||||
const launchProperties =
|
||||
packageJson.contributes.debuggers[0].configurationAttributes.launch.properties;
|
||||
assert.strictEqual(launchProperties.runtimeBackend.default, "local-services");
|
||||
assert(launchProperties.runtimeBackend.enum.includes("live-services"));
|
||||
assert.strictEqual(launchProperties.operatorEndpoint.default, defaultOperatorEndpoint);
|
||||
assert.match(extensionSource, /registerDebugAdapterDescriptorFactory\("disasmer"/);
|
||||
assert.match(extensionSource, /disasmer-debug-dap/);
|
||||
assert.match(extensionSource, /\.disasmer\/views\.json/);
|
||||
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
console.log("VS Code extension smoke passed");
|
||||
371
scripts/vscode-f5-smoke.js
Executable file
371
scripts/vscode-f5-smoke.js
Executable file
|
|
@ -0,0 +1,371 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const path = require("path");
|
||||
|
||||
const extension = require("../vscode-extension/extension");
|
||||
|
||||
class DapClient {
|
||||
constructor(spec) {
|
||||
this.child = cp.spawn(spec.command, spec.args, {
|
||||
cwd: spec.options && spec.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();
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const project = path.join(repo, "examples/launch-build-demo");
|
||||
const launchConfig = extension.resolveDisasmerDebugConfiguration(
|
||||
{ uri: { fsPath: project } },
|
||||
{}
|
||||
);
|
||||
|
||||
assert.strictEqual(launchConfig.type, "disasmer");
|
||||
assert.strictEqual(launchConfig.request, "launch");
|
||||
assert.strictEqual(launchConfig.entry, "build");
|
||||
assert.strictEqual(launchConfig.project, project);
|
||||
assert.strictEqual(launchConfig.runtimeBackend, "local-services");
|
||||
|
||||
const inspection = extension.refreshBundleBeforeLaunch(project, repo);
|
||||
assert.match(inspection.metadata.identity, /^sha256:/);
|
||||
|
||||
const client = new DapClient(extension.debugAdapterExecutableSpec(repo));
|
||||
try {
|
||||
const initialize = client.send("initialize", {
|
||||
adapterID: "disasmer",
|
||||
linesStartAt1: true,
|
||||
columnsStartAt1: true
|
||||
});
|
||||
await client.response(initialize, "initialize");
|
||||
|
||||
const launch = client.send("launch", launchConfig);
|
||||
await client.response(launch, "launch");
|
||||
await client.waitFor(
|
||||
(message) => message.type === "event" && message.event === "initialized"
|
||||
);
|
||||
|
||||
const breakpoints = client.send("setBreakpoints", {
|
||||
source: { path: path.join(project, "src/build.rs") },
|
||||
breakpoints: [{ line: 22 }, { line: 31 }, { line: 60 }]
|
||||
});
|
||||
const breakpointResponse = await client.response(breakpoints, "setBreakpoints");
|
||||
assert.strictEqual(breakpointResponse.body.breakpoints[0].verified, 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.allThreadsStopped, true);
|
||||
assert.strictEqual(stopped.body.reason, "breakpoint");
|
||||
|
||||
const threadsRequest = client.send("threads");
|
||||
const threads = (await client.response(threadsRequest, "threads")).body.threads;
|
||||
const linuxThread = threads.find((thread) => thread.name.includes("compile linux"));
|
||||
assert(linuxThread, "F5 launch must expose the Linux task as a virtual thread");
|
||||
|
||||
const stackRequest = client.send("stackTrace", {
|
||||
threadId: linuxThread.id,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames;
|
||||
assert.strictEqual(stack[0].line, 22);
|
||||
assert.strictEqual(stack[0].source.path, path.join(project, "src/build.rs"));
|
||||
assert.strictEqual(stack[0].source.sourceReference || 0, 0);
|
||||
|
||||
const sourceRequest = client.send("source", { source: stack[0].source });
|
||||
const source = (await client.response(sourceRequest, "source")).body;
|
||||
assert.match(source.content, /compile_linux/);
|
||||
|
||||
const nextRequest = client.send("next", { threadId: linuxThread.id });
|
||||
await client.response(nextRequest, "next");
|
||||
const stepped = await client.waitFor(
|
||||
(message) =>
|
||||
message.type === "event" &&
|
||||
message.event === "stopped" &&
|
||||
message.body.reason === "step"
|
||||
);
|
||||
assert.strictEqual(stepped.body.threadId, linuxThread.id);
|
||||
|
||||
const steppedStackRequest = client.send("stackTrace", {
|
||||
threadId: linuxThread.id,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const steppedStack = (await client.response(steppedStackRequest, "stackTrace")).body.stackFrames;
|
||||
assert.strictEqual(steppedStack[0].line, 23);
|
||||
|
||||
const scopesRequest = client.send("scopes", { frameId: steppedStack[0].id });
|
||||
const scopes = (await client.response(scopesRequest, "scopes")).body.scopes;
|
||||
const localsScope = scopes.find((scope) => scope.name === "Source Locals");
|
||||
const argsScope = scopes.find((scope) => scope.name === "Task Args and Handles");
|
||||
const runtimeScope = scopes.find((scope) => scope.name === "Disasmer Runtime");
|
||||
const outputScope = scopes.find((scope) => scope.name === "Recent Output");
|
||||
assert(localsScope, "F5 launch must expose source locals scope");
|
||||
assert(argsScope, "F5 launch must expose task args and handles");
|
||||
assert(runtimeScope, "F5 launch must expose Disasmer runtime state");
|
||||
assert(outputScope, "F5 launch must expose recent output state");
|
||||
|
||||
const localsRequest = client.send("variables", {
|
||||
variablesReference: localsScope.variablesReference
|
||||
});
|
||||
const locals = (await client.response(localsRequest, "variables")).body.variables;
|
||||
assert(
|
||||
locals.some(
|
||||
(variable) =>
|
||||
variable.name === "unavailable-local-diagnostic" &&
|
||||
String(variable.value).includes("cannot be inspected")
|
||||
),
|
||||
"source locals scope must report unavailable real Rust locals explicitly"
|
||||
);
|
||||
|
||||
const argsRequest = client.send("variables", {
|
||||
variablesReference: argsScope.variablesReference
|
||||
});
|
||||
const args = (await client.response(argsRequest, "variables")).body.variables;
|
||||
assert(args.some((variable) => variable.name === "return_value"));
|
||||
assert(args.some((variable) => variable.name === "artifact"));
|
||||
assert(args.some((variable) => variable.name === "source_snapshot"));
|
||||
assert(args.some((variable) => variable.name === "blob"));
|
||||
assert(args.some((variable) => variable.name === "vfs_mounts"));
|
||||
|
||||
const runtimeRequest = client.send("variables", {
|
||||
variablesReference: runtimeScope.variablesReference
|
||||
});
|
||||
const runtime = (await client.response(runtimeRequest, "variables")).body.variables;
|
||||
assert(
|
||||
runtime.some(
|
||||
(variable) => variable.name === "runtime_backend" && variable.value === "LocalServices"
|
||||
),
|
||||
"extension-resolved F5 launch must use the real local-services backend"
|
||||
);
|
||||
assert(
|
||||
runtime.some(
|
||||
(variable) => variable.name === "coordinator_task_events" && variable.value === 1
|
||||
),
|
||||
"F5 launch must cross the coordinator/node boundary and record a task event"
|
||||
);
|
||||
assert(
|
||||
runtime.some(
|
||||
(variable) =>
|
||||
variable.name === "command_status" &&
|
||||
String(variable.value).includes("completed through local services")
|
||||
)
|
||||
);
|
||||
assert(runtime.some((variable) => variable.name === "command_spec"));
|
||||
assert(runtime.some((variable) => variable.name === "stdout_tail"));
|
||||
assert(runtime.some((variable) => variable.name === "stderr_tail"));
|
||||
|
||||
const outputRequest = client.send("variables", {
|
||||
variablesReference: outputScope.variablesReference
|
||||
});
|
||||
const output = (await client.response(outputRequest, "variables")).body.variables;
|
||||
assert(output.some((variable) => variable.name === "stdout_tail"));
|
||||
assert(output.some((variable) => variable.name === "stderr_tail"));
|
||||
|
||||
const continueToWasmLocals = client.send("continue", { threadId: linuxThread.id });
|
||||
await client.response(continueToWasmLocals, "continue");
|
||||
await client.waitFor((message) => message.type === "event" && message.event === "continued");
|
||||
const wasmLocalsStop = await client.waitFor(
|
||||
(message) =>
|
||||
message.type === "event" &&
|
||||
message.event === "stopped" &&
|
||||
message.body.reason === "breakpoint" &&
|
||||
message.body.threadId === 1
|
||||
);
|
||||
assert.strictEqual(wasmLocalsStop.body.allThreadsStopped, true);
|
||||
|
||||
const wasmLocalsStackRequest = client.send("stackTrace", {
|
||||
threadId: 1,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const wasmLocalsStack = (await client.response(wasmLocalsStackRequest, "stackTrace")).body
|
||||
.stackFrames;
|
||||
assert.strictEqual(wasmLocalsStack[0].line, 31);
|
||||
const wasmLocalsScopesRequest = client.send("scopes", {
|
||||
frameId: wasmLocalsStack[0].id
|
||||
});
|
||||
const wasmLocalsScopes = (await client.response(wasmLocalsScopesRequest, "scopes")).body.scopes;
|
||||
const wasmLocalsScope = wasmLocalsScopes.find((scope) => scope.name === "Wasm Frame Locals");
|
||||
assert(wasmLocalsScope, "F5 launch must expose Wasm frame locals scope");
|
||||
const wasmLocalsRequest = client.send("variables", {
|
||||
variablesReference: wasmLocalsScope.variablesReference
|
||||
});
|
||||
const wasmLocals = (await client.response(wasmLocalsRequest, "variables")).body.variables;
|
||||
assert(
|
||||
wasmLocals.some(
|
||||
(variable) =>
|
||||
variable.name === "wasm_local_0" &&
|
||||
String(variable.value).includes("41") &&
|
||||
variable.type === "wasm-frame-local"
|
||||
),
|
||||
"Wasm frame locals must expose runtime values in the VS Code variables path"
|
||||
);
|
||||
|
||||
const continueToLocals = client.send("continue", { threadId: 1 });
|
||||
await client.response(continueToLocals, "continue");
|
||||
await client.waitFor((message) => message.type === "event" && message.event === "continued");
|
||||
const sourceLocalsStop = await client.waitFor(
|
||||
(message) =>
|
||||
message.type === "event" &&
|
||||
message.event === "stopped" &&
|
||||
message.body.reason === "breakpoint" &&
|
||||
message.body.threadId === 1
|
||||
);
|
||||
assert.strictEqual(sourceLocalsStop.body.allThreadsStopped, true);
|
||||
|
||||
const sourceLocalsStackRequest = client.send("stackTrace", {
|
||||
threadId: 1,
|
||||
startFrame: 0,
|
||||
levels: 1
|
||||
});
|
||||
const sourceLocalsStack = (await client.response(sourceLocalsStackRequest, "stackTrace")).body
|
||||
.stackFrames;
|
||||
assert.strictEqual(sourceLocalsStack[0].line, 60);
|
||||
|
||||
const sourceLocalsScopesRequest = client.send("scopes", {
|
||||
frameId: sourceLocalsStack[0].id
|
||||
});
|
||||
const sourceLocalsScopes = (await client.response(sourceLocalsScopesRequest, "scopes")).body
|
||||
.scopes;
|
||||
const sourceLocalsScope = sourceLocalsScopes.find((scope) => scope.name === "Source Locals");
|
||||
assert(sourceLocalsScope, "source-local breakpoint must expose source locals");
|
||||
const sourceLocalsRequest = client.send("variables", {
|
||||
variablesReference: sourceLocalsScope.variablesReference
|
||||
});
|
||||
const sourceLocals = (await client.response(sourceLocalsRequest, "variables")).body.variables;
|
||||
assert(
|
||||
sourceLocals.some(
|
||||
(variable) =>
|
||||
variable.name === "linux" &&
|
||||
String(variable.value).includes("TaskHandle") &&
|
||||
String(variable.value).includes("compile-linux") &&
|
||||
String(variable.value).includes("virtual_thread_id = 2")
|
||||
),
|
||||
"source locals must include the spawned Linux task handle value"
|
||||
);
|
||||
assert(
|
||||
sourceLocals.some((variable) => variable.name === "linux_thread" && variable.value === "2"),
|
||||
"source locals must include the Linux virtual thread id value"
|
||||
);
|
||||
assert(
|
||||
sourceLocals.some(
|
||||
(variable) =>
|
||||
variable.name === "linux_artifact" && String(variable.value).includes("Artifact")
|
||||
),
|
||||
"source locals must include the Linux artifact handle value"
|
||||
);
|
||||
|
||||
await client.close();
|
||||
} catch (error) {
|
||||
client.terminate();
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log("VS Code F5 smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
115
scripts/wasmtime-node-smoke.js
Normal file
115
scripts/wasmtime-node-smoke.js
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const wasmTarget = path.join(
|
||||
repo,
|
||||
"target",
|
||||
"wasm32-unknown-unknown",
|
||||
"debug",
|
||||
"launch_build_demo.wasm"
|
||||
);
|
||||
|
||||
cp.execFileSync(
|
||||
"cargo",
|
||||
["build", "-p", "launch-build-demo", "--target", "wasm32-unknown-unknown"],
|
||||
{ cwd: repo, stdio: "inherit" }
|
||||
);
|
||||
|
||||
assert(fs.existsSync(wasmTarget), `missing compiled Wasm module at ${wasmTarget}`);
|
||||
|
||||
const output = cp.execFileSync(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-wasmtime-smoke",
|
||||
"--",
|
||||
wasmTarget,
|
||||
"task_add_one",
|
||||
"41",
|
||||
"42",
|
||||
],
|
||||
{ cwd: repo, encoding: "utf8" }
|
||||
);
|
||||
|
||||
const report = JSON.parse(output);
|
||||
assert.strictEqual(report.type, "wasmtime_task_smoke");
|
||||
assert.strictEqual(report.export, "task_add_one");
|
||||
assert.strictEqual(report.arg, 41);
|
||||
assert.strictEqual(report.result, 42);
|
||||
|
||||
const debugOutput = cp.execFileSync(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-wasmtime-smoke",
|
||||
"--",
|
||||
"--debug-freeze-resume",
|
||||
wasmTarget,
|
||||
"task_add_one",
|
||||
"41",
|
||||
"42",
|
||||
],
|
||||
{ cwd: repo, encoding: "utf8" }
|
||||
);
|
||||
const debugReport = JSON.parse(debugOutput);
|
||||
assert.strictEqual(debugReport.type, "wasmtime_debug_freeze_resume_smoke");
|
||||
assert.strictEqual(debugReport.export, "task_add_one");
|
||||
assert.strictEqual(debugReport.task, "task_add_one");
|
||||
assert.strictEqual(debugReport.frozen_state, "Frozen");
|
||||
assert.strictEqual(debugReport.resumed_state, "Running");
|
||||
assert(debugReport.stack_frames.some((frame) => String(frame).includes("task_add_one")));
|
||||
assert(
|
||||
debugReport.local_values.some(
|
||||
([name, value]) => name === "wasm_local_0" && String(value).includes("41")
|
||||
),
|
||||
"Wasmtime debug snapshot must expose the real i32 argument as a frame local"
|
||||
);
|
||||
assert.strictEqual(debugReport.node_runtime_captured_wasm_locals, true);
|
||||
assert.strictEqual(debugReport.result, 42);
|
||||
assert.strictEqual(debugReport.node_runtime_reached_wasm_task, true);
|
||||
|
||||
const hostCommandOutput = cp.execFileSync(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-wasmtime-smoke",
|
||||
"--",
|
||||
"--host-command",
|
||||
],
|
||||
{ cwd: repo, encoding: "utf8" }
|
||||
);
|
||||
const hostCommandReport = JSON.parse(hostCommandOutput);
|
||||
assert.strictEqual(hostCommandReport.type, "wasmtime_host_command_smoke");
|
||||
assert.strictEqual(hostCommandReport.export, "compile-linux");
|
||||
assert.strictEqual(hostCommandReport.export_result, 0);
|
||||
assert.strictEqual(hostCommandReport.virtual_thread, "compile-linux");
|
||||
assert.strictEqual(hostCommandReport.stdout, "linux-build-artifact");
|
||||
assert.strictEqual(
|
||||
hostCommandReport.staged_artifact.path,
|
||||
"/vfs/artifacts/linux/app.tar.zst"
|
||||
);
|
||||
assert.strictEqual(hostCommandReport.large_bytes_uploaded, false);
|
||||
assert.strictEqual(hostCommandReport.manifest_objects, 1);
|
||||
assert.strictEqual(hostCommandReport.node_host_import, "disasmer.cmd_run");
|
||||
assert.strictEqual(hostCommandReport.flagship_linux_build_task, true);
|
||||
assert.strictEqual(hostCommandReport.node_executed_host_command, true);
|
||||
assert.strictEqual(hostCommandReport.hosted_control_plane_ran_command, false);
|
||||
|
||||
console.log("Wasmtime node smoke passed");
|
||||
235
scripts/windows-best-effort-smoke.js
Executable file
235
scripts/windows-best-effort-smoke.js
Executable file
|
|
@ -0,0 +1,235 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
const nodeMain = fs.readFileSync(path.join(repo, "crates/disasmer-node/src/main.rs"), "utf8");
|
||||
const nodeLib = fs.readFileSync(path.join(repo, "crates/disasmer-node/src/lib.rs"), "utf8");
|
||||
const executionCore = fs.readFileSync(
|
||||
path.join(repo, "crates/disasmer-core/src/execution.rs"),
|
||||
"utf8"
|
||||
);
|
||||
const dapMain = fs.readFileSync(path.join(repo, "crates/disasmer-dap/src/main.rs"), "utf8");
|
||||
|
||||
function waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function windowsCapabilities() {
|
||||
return {
|
||||
os: "Windows",
|
||||
arch: "x86_64",
|
||||
capabilities: ["Command", "WindowsCommandDev", "VfsArtifacts"],
|
||||
environment_backends: ["WindowsCommandDev"],
|
||||
source_providers: ["filesystem"]
|
||||
};
|
||||
}
|
||||
|
||||
function assertWindowsBackendBoundary() {
|
||||
assert.match(nodeMain, /CoordinatorSession::connect\(&args\.coordinator\)/);
|
||||
assert.match(nodeMain, /"type": "task_completed"/);
|
||||
assert.doesNotMatch(nodeMain, /WindowsCommandDev|windows-command-dev|cfg\(windows\)/);
|
||||
|
||||
const linuxStart = nodeLib.indexOf("impl CommandBackend for LinuxRootlessPodmanBackend");
|
||||
const windowsStart = nodeLib.indexOf("pub struct WindowsCommandDevBackend");
|
||||
assert(linuxStart >= 0, "Linux backend implementation must be present");
|
||||
assert(windowsStart > linuxStart, "Windows backend must be separate from Linux backend");
|
||||
const linuxBackend = nodeLib.slice(linuxStart, windowsStart);
|
||||
assert.doesNotMatch(linuxBackend, /WindowsCommandDev|WindowsSandbox|windows-command-dev/);
|
||||
assert.match(nodeLib, /impl CommandBackend for WindowsCommandDevBackend/);
|
||||
assert.match(nodeLib, /impl CommandBackend for WindowsSandboxStubBackend/);
|
||||
assert.match(executionCore, /\bLinuxRootlessPodman\b/);
|
||||
assert.match(executionCore, /\bWindowsCommandDev\b/);
|
||||
assert.match(executionCore, /\bStubbedWindowsSandbox\b/);
|
||||
|
||||
assert.match(dapMain, /thread\(WINDOWS_THREAD, "compile-windows", "compile windows"/);
|
||||
assert.match(dapMain, /fn launch_threads_include_windows_task_in_same_virtual_process\(\)/);
|
||||
assert.match(dapMain, /fn windows_thread_runtime_variables_share_virtual_process\(\)/);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
assertWindowsBackendBoundary();
|
||||
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let coordinatorStderr = "";
|
||||
coordinator.stderr.on("data", (chunk) => {
|
||||
coordinatorStderr += chunk.toString();
|
||||
});
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
const attached = await send(addr, {
|
||||
type: "attach_node",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "windows-node",
|
||||
public_key: "windows-node-public-key"
|
||||
});
|
||||
assert.strictEqual(attached.type, "node_attached");
|
||||
assert.strictEqual(attached.node, "windows-node");
|
||||
|
||||
const recorded = await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "windows-node",
|
||||
capabilities: windowsCapabilities(),
|
||||
cached_environment_digests: ["sha256:env-windows-command-dev"],
|
||||
source_snapshots: ["sha256:source-tree"],
|
||||
artifact_locations: [],
|
||||
direct_connectivity: true,
|
||||
online: true
|
||||
});
|
||||
assert.strictEqual(recorded.type, "node_capabilities_recorded");
|
||||
assert.strictEqual(recorded.node, "windows-node");
|
||||
|
||||
const placement = await send(addr, {
|
||||
type: "schedule_task",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
environment: {
|
||||
os: "Windows",
|
||||
arch: null,
|
||||
capabilities: ["WindowsCommandDev"]
|
||||
},
|
||||
environment_digest: "sha256:env-windows-command-dev",
|
||||
required_capabilities: ["Command"],
|
||||
source_snapshot: "sha256:source-tree",
|
||||
required_artifacts: [],
|
||||
prefer_node: null
|
||||
});
|
||||
assert.strictEqual(placement.type, "task_placement");
|
||||
assert.strictEqual(placement.placement.node, "windows-node");
|
||||
assert.ok(placement.placement.reasons.includes("warm environment cache"));
|
||||
assert.ok(placement.placement.reasons.includes("source snapshot already local"));
|
||||
|
||||
const started = await send(addr, {
|
||||
type: "start_process",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-windows"
|
||||
});
|
||||
assert.strictEqual(started.type, "process_started");
|
||||
assert.strictEqual(started.process, "vp-windows");
|
||||
|
||||
const reconnected = await send(addr, {
|
||||
type: "reconnect_node",
|
||||
node: "windows-node",
|
||||
process: "vp-windows",
|
||||
epoch: started.epoch
|
||||
});
|
||||
assert.strictEqual(reconnected.type, "node_reconnected");
|
||||
|
||||
const recordedTask = await send(addr, {
|
||||
type: "task_completed",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
process: "vp-windows",
|
||||
node: "windows-node",
|
||||
task: "windows-command-dev",
|
||||
status_code: 0,
|
||||
stdout_bytes: 18,
|
||||
stderr_bytes: 0,
|
||||
artifact_path: "/vfs/artifacts/windows-output.txt",
|
||||
artifact_digest: "sha256:windows-artifact",
|
||||
artifact_size_bytes: 18
|
||||
});
|
||||
assert.strictEqual(recordedTask.type, "task_recorded");
|
||||
assert.strictEqual(recordedTask.task, "windows-command-dev");
|
||||
|
||||
const events = await send(addr, {
|
||||
type: "list_task_events",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
process: "vp-windows"
|
||||
});
|
||||
assert.strictEqual(events.type, "task_events");
|
||||
assert.strictEqual(events.events.length, 1);
|
||||
assert.strictEqual(events.events[0].node, "windows-node");
|
||||
assert.strictEqual(events.events[0].artifact_path, "/vfs/artifacts/windows-output.txt");
|
||||
|
||||
const link = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "windows-output.txt",
|
||||
max_bytes: 1024,
|
||||
token_nonce: "nonce"
|
||||
});
|
||||
assert.strictEqual(link.type, "artifact_download_link");
|
||||
assert.strictEqual(link.link.process, "vp-windows");
|
||||
assert.deepStrictEqual(link.link.source, { RetainedNode: "windows-node" });
|
||||
} catch (error) {
|
||||
if (coordinatorStderr) {
|
||||
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
console.log("Windows best-effort smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
457
scripts/windows-runner-smoke.js
Executable file
457
scripts/windows-runner-smoke.js
Executable file
|
|
@ -0,0 +1,457 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const cp = require("child_process");
|
||||
const fs = require("fs");
|
||||
const net = require("net");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
class DapClient {
|
||||
constructor() {
|
||||
this.child = cp.spawn(
|
||||
"cargo",
|
||||
["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"],
|
||||
{ cwd: repo }
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
waitFor(predicate, timeoutMs = 120000) {
|
||||
const existing = this.messages.find(predicate);
|
||||
if (existing) return Promise.resolve(existing);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.child.kill("SIGKILL");
|
||||
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 waitForJsonLine(child) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline).trim()));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
reject(new Error(`process exited before JSON line with code ${code}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function send(addr, message) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const socket = net.connect(addr.port, addr.host, () => {
|
||||
socket.write(`${JSON.stringify(message)}\n`);
|
||||
});
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const newline = buffer.indexOf("\n");
|
||||
if (newline < 0) return;
|
||||
socket.end();
|
||||
try {
|
||||
resolve(JSON.parse(buffer.slice(0, newline)));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function runWindowsAttach(addr, grant) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-cli",
|
||||
"--bin",
|
||||
"disasmer",
|
||||
"--",
|
||||
"node",
|
||||
"attach",
|
||||
"--coordinator",
|
||||
`${addr.host}:${addr.port}`,
|
||||
"--tenant",
|
||||
"tenant",
|
||||
"--project-id",
|
||||
"project",
|
||||
"--node",
|
||||
"forgejo-windows-node",
|
||||
"--public-key",
|
||||
"forgejo-windows-node-public-key",
|
||||
"--enrollment-grant",
|
||||
grant,
|
||||
"--cap",
|
||||
"windows-command-dev"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Windows node attach failed with code ${code}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout));
|
||||
} catch (error) {
|
||||
reject(
|
||||
new Error(`Windows node attach output was not JSON: ${stdout}\n${error.stack || error.message}`)
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function runWindowsNode(addr, grant) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-node",
|
||||
"--bin",
|
||||
"disasmer-node",
|
||||
"--",
|
||||
"--coordinator",
|
||||
`${addr.host}:${addr.port}`,
|
||||
"--tenant",
|
||||
"tenant",
|
||||
"--project-id",
|
||||
"project",
|
||||
"--node",
|
||||
"forgejo-windows-node",
|
||||
"--public-key",
|
||||
"forgejo-windows-node-public-key",
|
||||
"--enrollment-grant",
|
||||
grant,
|
||||
"--process",
|
||||
"vp-forgejo-windows",
|
||||
"--task",
|
||||
"windows-command-dev",
|
||||
"--command",
|
||||
"cmd",
|
||||
"--arg",
|
||||
"/C",
|
||||
"--arg",
|
||||
"echo disasmer-windows-runner",
|
||||
"--artifact",
|
||||
"/vfs/artifacts/windows-runner-output.txt"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`Windows node smoke failed with code ${code}\n${stderr}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(stdout.trim().split(/\r?\n/).at(-1)));
|
||||
} catch (error) {
|
||||
reject(new Error(`node output was not JSON: ${stdout}\n${error.stack || error.message}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function assertDebuggerShowsWindowsThread() {
|
||||
const client = new DapClient();
|
||||
try {
|
||||
const initialize = client.send("initialize", {
|
||||
adapterID: "disasmer",
|
||||
linesStartAt1: true,
|
||||
columnsStartAt1: true
|
||||
});
|
||||
await client.response(initialize, "initialize");
|
||||
|
||||
const launch = client.send("launch", {
|
||||
entry: "build",
|
||||
project: path.join(repo, "examples/launch-build-demo"),
|
||||
runtimeBackend: "simulated"
|
||||
});
|
||||
await client.response(launch, "launch");
|
||||
await client.waitFor(
|
||||
(message) => message.type === "event" && message.event === "initialized"
|
||||
);
|
||||
|
||||
const configurationDone = client.send("configurationDone");
|
||||
await client.response(configurationDone, "configurationDone");
|
||||
|
||||
const threadsRequest = client.send("threads");
|
||||
const threads = (await client.response(threadsRequest, "threads")).body.threads;
|
||||
assert(
|
||||
threads.some((thread) => thread.name.includes("compile windows")),
|
||||
"debugger must represent the Windows task as a virtual thread"
|
||||
);
|
||||
|
||||
await client.close();
|
||||
} catch (error) {
|
||||
client.child.kill("SIGKILL");
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
if (process.platform !== "win32") {
|
||||
throw new Error(
|
||||
`Windows runner validation requires win32; current platform is ${process.platform}`
|
||||
);
|
||||
}
|
||||
|
||||
const coordinator = cp.spawn(
|
||||
"cargo",
|
||||
[
|
||||
"run",
|
||||
"-q",
|
||||
"-p",
|
||||
"disasmer-coordinator",
|
||||
"--bin",
|
||||
"disasmer-coordinator",
|
||||
"--",
|
||||
"--listen",
|
||||
"127.0.0.1:0"
|
||||
],
|
||||
{ cwd: repo }
|
||||
);
|
||||
let coordinatorStderr = "";
|
||||
coordinator.stderr.on("data", (chunk) => {
|
||||
coordinatorStderr += chunk.toString();
|
||||
});
|
||||
|
||||
try {
|
||||
const ready = await waitForJsonLine(coordinator);
|
||||
const [host, portText] = ready.listen.split(":");
|
||||
const addr = { host, port: Number(portText) };
|
||||
assert.strictEqual((await send(addr, { type: "ping" })).type, "pong");
|
||||
|
||||
const attachGrant = await send(addr, {
|
||||
type: "create_node_enrollment_grant",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "operator",
|
||||
grant: "grant-forgejo-windows-attach",
|
||||
now_epoch_seconds: 0,
|
||||
ttl_seconds: 900
|
||||
});
|
||||
assert.strictEqual(attachGrant.type, "node_enrollment_grant_created");
|
||||
assert.strictEqual(attachGrant.scope, "node:attach");
|
||||
|
||||
const attach = await runWindowsAttach(addr, attachGrant.grant);
|
||||
assert.strictEqual(attach.plan.node, "forgejo-windows-node");
|
||||
assert.strictEqual(attach.boundary.cli_contacted_coordinator, true);
|
||||
assert.strictEqual(attach.boundary.used_enrollment_exchange, true);
|
||||
assert.strictEqual(attach.coordinator_response.type, "node_enrollment_exchanged");
|
||||
assert.strictEqual(attach.coordinator_response.credential.scope, "node:attach");
|
||||
assert(attach.plan.capabilities.capabilities.includes("WindowsCommandDev"));
|
||||
|
||||
const runtimeGrant = await send(addr, {
|
||||
type: "create_node_enrollment_grant",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "operator",
|
||||
grant: "grant-forgejo-windows-runtime",
|
||||
now_epoch_seconds: 0,
|
||||
ttl_seconds: 900
|
||||
});
|
||||
assert.strictEqual(runtimeGrant.type, "node_enrollment_grant_created");
|
||||
|
||||
const report = await runWindowsNode(addr, runtimeGrant.grant);
|
||||
assert.strictEqual(report.node_status, "completed");
|
||||
assert.strictEqual(report.virtual_thread, "windows-command-dev");
|
||||
assert.strictEqual(report.status_code, 0);
|
||||
assert.strictEqual(report.large_bytes_uploaded, false);
|
||||
assert.strictEqual(
|
||||
report.staged_artifact.path,
|
||||
"/vfs/artifacts/windows-runner-output.txt"
|
||||
);
|
||||
assert.strictEqual(report.registration_response.type, "node_enrollment_exchanged");
|
||||
assert.strictEqual(report.registration_response.credential.scope, "node:attach");
|
||||
assert.strictEqual(report.coordinator_response.type, "task_recorded");
|
||||
|
||||
const recorded = await send(addr, {
|
||||
type: "report_node_capabilities",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
node: "forgejo-windows-node",
|
||||
capabilities: {
|
||||
os: "Windows",
|
||||
arch: "x86_64",
|
||||
capabilities: ["Command", "WindowsCommandDev", "VfsArtifacts"],
|
||||
environment_backends: ["WindowsCommandDev"],
|
||||
source_providers: ["filesystem"]
|
||||
},
|
||||
cached_environment_digests: ["sha256:env-windows-command-dev"],
|
||||
source_snapshots: ["sha256:source-tree"],
|
||||
artifact_locations: ["windows-runner-output.txt"],
|
||||
direct_connectivity: true,
|
||||
online: true
|
||||
});
|
||||
assert.strictEqual(recorded.type, "node_capabilities_recorded");
|
||||
|
||||
const placement = await send(addr, {
|
||||
type: "schedule_task",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
environment: {
|
||||
os: "Windows",
|
||||
arch: null,
|
||||
capabilities: ["WindowsCommandDev"]
|
||||
},
|
||||
environment_digest: "sha256:env-windows-command-dev",
|
||||
required_capabilities: ["Command"],
|
||||
source_snapshot: "sha256:source-tree",
|
||||
required_artifacts: ["windows-runner-output.txt"],
|
||||
prefer_node: null
|
||||
});
|
||||
assert.strictEqual(placement.type, "task_placement");
|
||||
assert.strictEqual(placement.placement.node, "forgejo-windows-node");
|
||||
|
||||
const link = await send(addr, {
|
||||
type: "create_artifact_download_link",
|
||||
tenant: "tenant",
|
||||
project: "project",
|
||||
actor_user: "user",
|
||||
artifact: "windows-runner-output.txt",
|
||||
max_bytes: 1024,
|
||||
token_nonce: "nonce"
|
||||
});
|
||||
assert.strictEqual(link.type, "artifact_download_link");
|
||||
assert.deepStrictEqual(link.link.source, {
|
||||
RetainedNode: "forgejo-windows-node"
|
||||
});
|
||||
} catch (error) {
|
||||
if (coordinatorStderr) {
|
||||
error.message = `${error.message}\ncoordinator stderr:\n${coordinatorStderr}`;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
coordinator.kill("SIGTERM");
|
||||
}
|
||||
|
||||
await assertDebuggerShowsWindowsThread();
|
||||
|
||||
const outDir = path.join(repo, "target", "acceptance");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(outDir, "windows-runner.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
kind: "disasmer_windows_runner_validation",
|
||||
platform: process.platform,
|
||||
runner: process.env.FORGEJO_RUNNER_NAME || process.env.RUNNER_NAME || null,
|
||||
validated: true
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`
|
||||
);
|
||||
|
||||
console.log("Windows runner smoke passed");
|
||||
})().catch((error) => {
|
||||
console.error(error.stack || error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
59
scripts/windows-validation-contract-smoke.js
Executable file
59
scripts/windows-validation-contract-smoke.js
Executable file
|
|
@ -0,0 +1,59 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
const assert = require("assert");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const repo = path.resolve(__dirname, "..");
|
||||
|
||||
function read(relativePath) {
|
||||
return fs.readFileSync(path.join(repo, relativePath), "utf8");
|
||||
}
|
||||
|
||||
function expect(source, name, pattern) {
|
||||
assert.match(source, pattern, `missing Windows validation evidence: ${name}`);
|
||||
}
|
||||
|
||||
const workflow = read(".forgejo/workflows/windows-validation.yml");
|
||||
const runnerSmoke = read("scripts/windows-runner-smoke.js");
|
||||
const readme = read("README.md");
|
||||
const publicAcceptance = read("scripts/acceptance-public.sh");
|
||||
const publicSplit = read("scripts/verify-public-split.sh");
|
||||
|
||||
expect(workflow, "manual workflow dispatch", /workflow_dispatch/);
|
||||
expect(workflow, "intermittent Forgejo Windows runner note", /intermittently online/);
|
||||
expect(workflow, "Windows runner label", /runs-on:\s*windows/);
|
||||
expect(workflow, "Windows validation acceptance report env", /DISASMER_WINDOWS_VALIDATION:\s*forgejo-windows-runner/);
|
||||
expect(workflow, "acceptance report step", /node scripts\/acceptance-report\.js windows/);
|
||||
expect(workflow, "Windows runtime unit coverage", /cargo test -p disasmer-node windows_backend_is_labeled_user_attached_dev_execution/);
|
||||
expect(workflow, "Windows runner smoke step", /node scripts\/windows-runner-smoke\.js/);
|
||||
|
||||
expect(runnerSmoke, "real Windows platform guard", /process\.platform !== "win32"/);
|
||||
expect(runnerSmoke, "CLI node attach function", /function runWindowsAttach/);
|
||||
expect(runnerSmoke, "CLI node attach command", /"node"[\s\S]*"attach"[\s\S]*"windows-command-dev"/);
|
||||
expect(runnerSmoke, "enrollment grant creation", /create_node_enrollment_grant[\s\S]*grant-forgejo-windows-attach/);
|
||||
expect(runnerSmoke, "CLI enrollment exchange asserted", /used_enrollment_exchange[\s\S]*true/);
|
||||
expect(runnerSmoke, "runtime enrollment grant", /grant-forgejo-windows-runtime/);
|
||||
expect(runnerSmoke, "runtime uses enrollment grant", /"--enrollment-grant"[\s\S]*grant/);
|
||||
expect(runnerSmoke, "Windows command task runs", /"windows-command-dev"[\s\S]*"cmd"[\s\S]*"echo disasmer-windows-runner"/);
|
||||
expect(runnerSmoke, "artifact metadata is asserted", /windows-runner-output\.txt[\s\S]*artifact_download_link/);
|
||||
expect(runnerSmoke, "Windows placement is asserted", /schedule_task[\s\S]*WindowsCommandDev[\s\S]*forgejo-windows-node/);
|
||||
expect(runnerSmoke, "debugger shows Windows virtual thread", /compile windows/);
|
||||
expect(runnerSmoke, "validation result artifact", /disasmer_windows_runner_validation/);
|
||||
|
||||
expect(readme, "docs mention manual Windows workflow", /manual `Windows validation`\s+workflow/);
|
||||
expect(readme, "docs mention intermittent runner", /intermittent Windows runner/);
|
||||
expect(readme, "docs mention CLI attach in Windows validation", /runs `disasmer node attach`/);
|
||||
expect(readme, "docs keep Windows unvalidated when report is not-run", /windows_validation: "not-run"[\s\S]*best-effort and unvalidated/);
|
||||
|
||||
for (const [scriptName, script] of [
|
||||
["public acceptance", publicAcceptance],
|
||||
["public split", publicSplit],
|
||||
]) {
|
||||
assert(
|
||||
script.includes("node scripts/windows-validation-contract-smoke.js"),
|
||||
`${scriptName} must run windows-validation-contract-smoke.js`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Windows validation contract smoke passed");
|
||||
Loading…
Add table
Add a link
Reference in a new issue