diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json index f69de6e..f6a802c 100644 --- a/DISASMER_PUBLIC_TREE.json +++ b/DISASMER_PUBLIC_TREE.json @@ -1,7 +1,7 @@ { "kind": "disasmer-filtered-public-tree", - "source_commit": "0bdcbac7736ebc2237e80aaa837d7b0d6286252e", - "release_name": "dryrun-0bdcbac7736e", + "source_commit": "b67d2a61d1b2a7600593024b4943e155443eb1d7", + "release_name": "dryrun-b67d2a61d1b2", "filtered_out": [ "private/**", "experiments/**", diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs index 93b6ad9..6a7c750 100644 --- a/crates/disasmer-cli/src/main.rs +++ b/crates/disasmer-cli/src/main.rs @@ -5722,9 +5722,40 @@ fn coordinator_run_report(plan: RunPlan) -> Result { .get("status") .and_then(Value::as_str) .unwrap_or("coordinator_response"); + let task = plan.entry.clone(); + let artifact_path = format!("/vfs/artifacts/{task}-output.txt"); + let launch_task_response = if status == "started" { + let manifest_path = plan.project.join("Cargo.toml"); + session.request_allow_error(json!({ + "type": "launch_task", + "tenant": tenant.clone(), + "project": project.clone(), + "actor_user": user.clone(), + "process": process.clone(), + "task": task.clone(), + "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", + manifest_path.to_string_lossy() + ], + "artifact_path": artifact_path.clone(), + }))? + } else { + Value::Null + }; Ok(json!({ "command": "run", - "status": status, + "status": if launch_task_response.get("type").and_then(Value::as_str) == Some("task_launched") { "task_launched" } else { status }, "project_root": plan.project, "entry": plan.entry, "tenant": tenant, @@ -5734,6 +5765,9 @@ fn coordinator_run_report(plan: RunPlan) -> Result { "coordinator": coordinator, "process": process, "run_start": run_start, + "task": task, + "worker_placement_requested": launch_task_response.is_object(), + "task_launch": launch_task_response, "coordinator_response": response, "coordinator_session_requests": session.requests(), "private_website_required": false, diff --git a/scripts/acceptance-cli-first.sh b/scripts/acceptance-cli-first.sh index f0c1151..d4ee160 100755 --- a/scripts/acceptance-cli-first.sh +++ b/scripts/acceptance-cli-first.sh @@ -34,6 +34,9 @@ fi node scripts/public-release-dryrun-contract-smoke.js node scripts/public-browser-login-contract-smoke.js node scripts/self-hosted-coordinator-smoke.js +if [[ "${DISASMER_CLI_HAPPY_PATH_LIVE:-}" == "1" ]]; then + node scripts/cli-happy-path-live-smoke.js +fi node scripts/public-local-demo-matrix-smoke.js scripts/release-source-scan.sh diff --git a/scripts/cli-first-contract-smoke.js b/scripts/cli-first-contract-smoke.js index 669a83c..e9fc5ea 100644 --- a/scripts/cli-first-contract-smoke.js +++ b/scripts/cli-first-contract-smoke.js @@ -40,6 +40,7 @@ const errorExitSmoke = read("scripts/cli-error-exit-smoke.js"); const browserLoginFlowSmoke = read("scripts/cli-browser-login-flow-smoke.js"); const nodeAttachSmoke = read("scripts/node-attach-smoke.js"); const dapSmoke = read("scripts/dap-smoke.js"); +const cliHappyPathSmoke = read("scripts/cli-happy-path-live-smoke.js"); expect(criteria, "header", /^# Disasmer CLI-First MVP Acceptance Criteria/m); expect( @@ -123,8 +124,31 @@ assert.deepStrictEqual( expect( criteria, "CLI-first non-e2e gate wording", - /scripts\/acceptance-cli-first\.sh[\s\S]*final public-release e2e remains intentionally excluded/ + /scripts\/acceptance-cli-first\.sh[\s\S]*scripts\/cli-happy-path-live-smoke\.js[\s\S]*live default operator/ ); +expect( + cliFirstAcceptance, + "CLI-first gate can run live happy path when explicitly enabled", + /DISASMER_CLI_HAPPY_PATH_LIVE[\s\S]*cli-happy-path-live-smoke\.js/ +); +for (const [name, pattern] of [ + ["happy path uses released binaries", /disasmer-public-binaries-/], + ["happy path completes browser login", /--complete-browser-code/], + ["happy path initializes project", /"project"[\s\S]*"init"/], + ["happy path inspects project", /"inspect"[\s\S]*"--project"/], + ["happy path enrolls node", /"node"[\s\S]*"enroll"/], + ["happy path attaches node", /"node"[\s\S]*"attach"/], + ["happy path starts worker", /--worker/], + ["happy path runs build", /"run"[\s\S]*"build"/], + ["happy path checks logs", /"logs"/], + ["happy path checks artifacts", /"artifact"[\s\S]*"list"/], + ["happy path downloads artifact", /"artifact"[\s\S]*"download"/], + ["happy path restarts process", /"process"[\s\S]*"restart"/], + ["happy path cancels process", /"process"[\s\S]*"cancel"/], + ["happy path writes evidence", /cli-happy-path-live\.json/], +]) { + expect(cliHappyPathSmoke, name, pattern); +} expect( criteria, "artifact export explicit local byte write", diff --git a/scripts/cli-happy-path-live-smoke.js b/scripts/cli-happy-path-live-smoke.js new file mode 100644 index 0000000..113810b --- /dev/null +++ b/scripts/cli-happy-path-live-smoke.js @@ -0,0 +1,397 @@ +#!/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 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 = path.join(releaseRoot, "public-release-manifest.json"); +const reportPath = path.join(acceptanceRoot, "cli-happy-path-live.json"); +const serviceEndpoint = "https://disasmer.michelpaulissen.com:9443"; +const serviceAddr = + process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR || + "disasmer.michelpaulissen.com:9443"; +const oidcIssuer = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL; +const oidcCode = process.env.DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE; +const enabled = process.env.DISASMER_CLI_HAPPY_PATH_LIVE === "1"; +const commands = []; + +function requireEnabled() { + if (!enabled) { + throw new Error( + "DISASMER_CLI_HAPPY_PATH_LIVE=1 is required because this runs the live CLI happy path" + ); + } + 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 nonInteractiveGitEnv(extra = {}) { + return { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_ASKPASS: process.env.GIT_ASKPASS || "/bin/false", + SSH_ASKPASS: process.env.SSH_ASKPASS || "/bin/false", + GIT_SSH_COMMAND: + process.env.GIT_SSH_COMMAND || + "ssh -o BatchMode=yes -o NumberOfPasswordPrompts=0", + ...extra, + }; +} + +function commandEnv(command, env) { + if (command !== "git") return env; + return nonInteractiveGitEnv(env); +} + +function run(command, args, options = {}) { + commands.push([command, ...args].join(" ")); + return cp.execFileSync(command, args, { + cwd: repo, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 15 * 60 * 1000, + ...options, + env: commandEnv(command, options.env), + }); +} + +function runJson(command, args, options = {}) { + const output = run(command, args, options); + const line = output + .trim() + .split(/\r?\n/) + .filter(Boolean) + .at(-1); + return JSON.parse(line); +} + +function executable(root, name) { + return path.join(root, "bin", process.platform === "win32" ? `${name}.exe` : 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) + ); + assert(asset, `missing released binary archive for ${platform}`); + assert(fs.existsSync(asset.file), `missing released binary archive ${asset.file}`); + return asset.file; +} + +function publicRepoUrl(manifest) { + const url = + process.env.DISASMER_PUBLIC_REPO_URL || + process.env.DISASMER_PUBLIC_REPO_REMOTE || + manifest.public_repo_url || + manifest.public_repo_remote; + assert(url, "public repository URL is required"); + assert(url.includes("git.michelpaulissen.com")); + return url; +} + +function waitForJsonLine(child, label) { + return new Promise((resolve, reject) => { + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { + reject(new Error(`${label} did not produce a JSON line\n${stderr}`)); + }, 120000); + child.stdout.on("data", (chunk) => { + stdout += chunk.toString(); + for (const line of stdout.split(/\r?\n/)) { + if (!line.trim()) continue; + try { + const parsed = JSON.parse(line); + clearTimeout(timer); + resolve(parsed); + return; + } catch (_) { + // keep waiting + } + } + }); + child.stderr.on("data", (chunk) => { + stderr += chunk.toString(); + }); + child.once("error", (error) => { + clearTimeout(timer); + reject(error); + }); + child.once("exit", (code) => { + if (code !== 0) { + clearTimeout(timer); + reject(new Error(`${label} exited with ${code}\n${stderr}`)); + } + }); + }); +} + +async function stopChild(child) { + if (!child || child.exitCode !== null) return; + child.kill("SIGTERM"); + await new Promise((resolve) => child.once("exit", resolve)); +} + +async function main() { + requireEnabled(); + const manifest = readJson(manifestPath); + assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); + assert.strictEqual(manifest.default_operator_endpoint, serviceEndpoint); + assert.strictEqual(serviceAddr, "disasmer.michelpaulissen.com:9443"); + + const workRoot = fs.mkdtempSync(path.join(os.tmpdir(), "disasmer-cli-happy-")); + const installDir = path.join(workRoot, "install"); + const checkout = path.join(workRoot, "public-repo"); + ensureDir(installDir); + run("tar", ["-xzf", binaryArchive(manifest), "-C", installDir]); + run("git", ["clone", "--depth", "1", publicRepoUrl(manifest), checkout]); + + const disasmer = executable(installDir, "disasmer"); + const disasmerNode = executable(installDir, "disasmer-node"); + const projectDir = path.join(checkout, "examples/launch-build-demo"); + const suffix = String(Date.now()); + const tenant = `cli-${suffix}`; + const project = `project-${suffix}`; + const user = "user"; + const cliNode = `cli-node-${suffix}`; + const workerNode = `worker-${suffix}`; + const scope = [ + "--coordinator", + serviceEndpoint, + "--tenant", + tenant, + "--project-id", + project, + "--user", + user, + "--json", + ]; + + const login = runJson( + disasmer, + [ + "login", + "--browser", + "--json", + "--complete-browser-code", + oidcCode, + "--oidc-issuer-url", + oidcIssuer, + "--tenant", + tenant, + "--project-id", + project, + "--user", + user, + ], + { cwd: projectDir } + ); + assert.strictEqual(login.plan.coordinator, serviceEndpoint); + assert.strictEqual(login.boundary.scoped_cli_session_received, true); + + const projectInit = runJson( + disasmer, + [ + "project", + "init", + ...scope, + "--new-project", + project, + "--name", + "CLI Happy Path", + "--yes", + ], + { cwd: projectDir } + ); + assert.strictEqual(projectInit.command, "project init"); + assert.strictEqual(projectInit.project_config_written, true); + assert.strictEqual(projectInit.coordinator_response.type, "project"); + + const projectList = runJson(disasmer, ["project", "list", ...scope], { + cwd: projectDir, + }); + assert(projectList.project_count >= 1); + const projectSelect = runJson( + disasmer, + ["project", "select", ...scope, project], + { cwd: projectDir } + ); + assert.strictEqual(projectSelect.command, "project select"); + + const inspection = runJson(disasmer, ["inspect", "--project", ".", "--json"], { + cwd: projectDir, + }); + assert( + inspection.discovered_environments.some((environment) => environment.name === "linux") + ); + assert(inspection.source_providers); + + const cliGrant = runJson( + disasmer, + ["node", "enroll", ...scope, "--grant", `cli-grant-${suffix}`], + { cwd: projectDir } + ); + assert.strictEqual(cliGrant.command, "node enroll"); + const attach = runJson( + disasmer, + [ + "node", + "attach", + "--coordinator", + serviceEndpoint, + "--tenant", + tenant, + "--project-id", + project, + "--node", + cliNode, + "--public-key", + `${cliNode}-public-key`, + "--enrollment-grant", + cliGrant.enrollment_grant.grant, + "--json", + ], + { cwd: projectDir } + ); + assert.strictEqual(attach.command, "node attach"); + assert.strictEqual(attach.boundary.used_enrollment_exchange, true); + + const workerGrant = runJson( + disasmer, + ["node", "enroll", ...scope, "--grant", `worker-grant-${suffix}`], + { cwd: projectDir } + ); + const workerArgs = [ + "--coordinator", + serviceEndpoint, + "--tenant", + tenant, + "--project-id", + project, + "--node", + workerNode, + "--public-key", + `${workerNode}-public-key`, + "--enrollment-grant", + workerGrant.enrollment_grant.grant, + "--worker", + "--assignment-poll-ms", + "500", + "--emit-ready", + ]; + commands.push([disasmerNode, ...workerArgs].join(" ")); + const worker = cp.spawn(disasmerNode, workerArgs, { cwd: projectDir }); + try { + const ready = await waitForJsonLine(worker, "CLI happy-path worker ready"); + assert.strictEqual(ready.node_status, "ready"); + + const runReport = runJson(disasmer, ["run", "build", "--project", ".", "--json"], { + cwd: projectDir, + }); + assert.strictEqual(runReport.command, "run"); + assert.strictEqual(runReport.status, "task_launched"); + assert.strictEqual(runReport.task_launch.type, "task_launched"); + assert.strictEqual(runReport.task_launch.placement.node, workerNode); + + const completion = await waitForJsonLine(worker, "CLI happy-path worker completion"); + assert.strictEqual(completion.node_status, "completed"); + + const processStatus = runJson( + disasmer, + ["process", "status", ...scope, "--process", "vp-current"], + { cwd: projectDir } + ); + assert.strictEqual(processStatus.command, "process status"); + assert(processStatus.current_task_count >= 1); + + const logs = runJson(disasmer, ["logs", ...scope, "--process", "vp-current"], { + cwd: projectDir, + }); + assert(logs.log_entries.length >= 1); + + const artifacts = runJson( + disasmer, + ["artifact", "list", ...scope, "--process", "vp-current"], + { cwd: projectDir } + ); + assert(artifacts.artifacts.length >= 1); + const artifact = artifacts.artifacts[0].artifact; + + const download = runJson(disasmer, ["artifact", "download", ...scope, artifact], { + cwd: projectDir, + }); + assert.strictEqual(download.download_session.link_issued, true); + + const restart = runJson( + disasmer, + ["process", "restart", ...scope, "--process", "vp-current", "--yes"], + { cwd: projectDir } + ); + assert.strictEqual(restart.restart_request.accepted, true); + + const cancel = runJson( + disasmer, + ["process", "cancel", ...scope, "--process", "vp-current", "--yes"], + { cwd: projectDir } + ); + assert.strictEqual(cancel.cancel_request.accepted, true); + + const report = { + kind: "disasmer-cli-happy-path-live", + release_name: manifest.release_name, + source_commit: manifest.source_commit, + service_addr: serviceAddr, + default_operator_endpoint: serviceEndpoint, + public_repository_url: publicRepoUrl(manifest), + tenant, + project, + cli_node: cliNode, + worker_node: workerNode, + signed_in: true, + cli_installed_and_login_ran: true, + project_initialized_or_selected: true, + inspect_discovered_linux: true, + node_attached_with_cli: true, + run_launched_task: true, + work_ran_on_attached_node: true, + status_and_logs_seen: true, + artifact_download_requested: true, + process_restart_requested: true, + process_cancel_requested: true, + commands, + acceptance_result: "passed", + }; + ensureDir(path.dirname(reportPath)); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(`CLI happy-path live smoke passed: ${reportPath}`); + } finally { + await stopChild(worker); + } +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/public-release-dryrun-contract-smoke.js b/scripts/public-release-dryrun-contract-smoke.js index 2e2aa98..897ea05 100644 --- a/scripts/public-release-dryrun-contract-smoke.js +++ b/scripts/public-release-dryrun-contract-smoke.js @@ -203,6 +203,7 @@ for (const [name, pattern] of [ ["final evidence verifies Forgejo host", /forgejoHost = "git\.michelpaulissen\.com"/], ["final evidence verifies default operator", /serviceEndpoint = "https:\/\/disasmer\.michelpaulissen\.com:9443"/], ["final evidence accepts external public tree push", /function publicTreeAlreadyPushed\(manifest\)[\s\S]*DISASMER_PUBLIC_TREE_ALREADY_PUSHED/], + ["final evidence records resolved public repo", /resolvedPublicRepoRemote[\s\S]*public_repository_url: resolvedPublicRepoRemote/], ["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/], diff --git a/scripts/public-release-dryrun-final-evidence.js b/scripts/public-release-dryrun-final-evidence.js index ea095d8..f739097 100755 --- a/scripts/public-release-dryrun-final-evidence.js +++ b/scripts/public-release-dryrun-final-evidence.js @@ -161,8 +161,9 @@ assert.strictEqual( true, "filtered public tree must be pushed to Forgejo" ); +const resolvedPublicRepoRemote = publicRepoRemote(manifest); assertIncludes( - publicRepoRemote(manifest), + resolvedPublicRepoRemote, forgejoHost, "manifest public repository must point at Forgejo" ); @@ -412,7 +413,7 @@ const finalReport = { }, }, forgejo_host: forgejoHost, - public_repository_url: manifest.public_repo_url, + public_repository_url: resolvedPublicRepoRemote, forgejo_release: { owner: forgejoRelease.owner, repo: forgejoRelease.repo,