commit f22d0a5791a863a85f162cf25a10a3575601a5c1 Author: Disasmer release dry run Date: Fri Jul 3 16:07:13 2026 +0200 Public dry run dryrun-309831e1e021 Source commit: 309831e1e021f962c118452336776fd9a94025f9 Public tree identity: sha256:6fa95c1745579bd6256dbeb3d476db0b07c2f24aa9213b0f7783ce1adfc8aca5 diff --git a/.forgejo/workflows/public-release-dryrun.yml b/.forgejo/workflows/public-release-dryrun.yml new file mode 100644 index 0000000..3dcd7df --- /dev/null +++ b/.forgejo/workflows/public-release-dryrun.yml @@ -0,0 +1,84 @@ +name: Public release dry run assets + +on: + workflow_dispatch: + inputs: + release_name: + description: Forgejo Release name or tag for the dry run. + required: false + public_repo_url: + description: Public Forgejo repository URL at git.michelpaulissen.com. + required: false + +jobs: + linux-assets: + runs-on: docker + container: + image: rust:1-bookworm + timeout-minutes: 60 + env: + DISASMER_PUBLIC_RELEASE_NAME: ${{ inputs.release_name }} + DISASMER_PUBLIC_REPO_URL: ${{ inputs.public_repo_url }} + DISASMER_DNS_PUBLICATION_STATE: not-published + DISASMER_RESOLVER_OVERRIDE: required-for-dry-run + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install Node.js + run: | + apt-get update + apt-get install --yes nodejs npm + + - name: Tool versions + run: | + rustc --version + cargo --version + node --version + npm --version + tar --version + + - name: Prepare public release assets + run: node scripts/prepare-public-release-dryrun.js + + - name: Upload dry-run assets + uses: actions/upload-artifact@v4 + with: + name: public-release-dryrun-linux-assets + path: | + target/public-release-dryrun/assets/* + target/public-release-dryrun/public-release-manifest.json + + windows-assets: + # Manual on purpose: the Forgejo Windows runner is intermittently online. + runs-on: windows + timeout-minutes: 60 + env: + DISASMER_PUBLIC_RELEASE_NAME: ${{ inputs.release_name }} + DISASMER_PUBLIC_REPO_URL: ${{ inputs.public_repo_url }} + DISASMER_DNS_PUBLICATION_STATE: not-published + DISASMER_RESOLVER_OVERRIDE: required-for-dry-run + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Tool versions + shell: pwsh + run: | + rustc --version + cargo --version + node --version + npm --version + tar --version + + - name: Prepare public release assets + shell: pwsh + run: node scripts/prepare-public-release-dryrun.js + + - name: Upload dry-run assets + uses: actions/upload-artifact@v4 + with: + name: public-release-dryrun-windows-assets + path: | + target/public-release-dryrun/assets/* + target/public-release-dryrun/public-release-manifest.json diff --git a/.forgejo/workflows/windows-validation.yml b/.forgejo/workflows/windows-validation.yml new file mode 100644 index 0000000..41a9488 --- /dev/null +++ b/.forgejo/workflows/windows-validation.yml @@ -0,0 +1,39 @@ +name: Windows validation + +on: + workflow_dispatch: + +jobs: + windows-runner: + # Manual on purpose: the Forgejo Windows runner is intermittently online. + # Run this workflow for release validation when a runner with this label is available. + runs-on: windows + timeout-minutes: 45 + env: + DISASMER_WINDOWS_VALIDATION: forgejo-windows-runner + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Tool versions + shell: pwsh + run: | + rustc --version + cargo --version + node --version + + - name: Acceptance environment report + shell: pwsh + run: node scripts/acceptance-report.js windows + + - name: Format check + shell: pwsh + run: cargo fmt --all --check + + - name: Windows runtime unit coverage + shell: pwsh + run: cargo test -p disasmer-node windows_backend_is_labeled_user_attached_dev_execution + + - name: Windows runner smoke + shell: pwsh + run: node scripts/windows-runner-smoke.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a8daaa7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/target/ +/.disasmer/ +**/.disasmer/ +/vscode-extension/node_modules/ +/private/*/Cargo.lock +/private/*/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cd1a05d --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2597 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" +dependencies = [ + "gimli", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +dependencies = [ + "allocator-api2", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "cranelift-assembler-x64" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc822414b18d1f5b1b33ce1441534e311e62fef86ebb5b9d382af857d0272c9" +dependencies = [ + "cranelift-assembler-x64-meta", +] + +[[package]] +name = "cranelift-assembler-x64-meta" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c646808b06f4532478d8d6057d74f15c3322f10d995d9486e7dcea405bf521a" +dependencies = [ + "cranelift-srcgen", +] + +[[package]] +name = "cranelift-bforest" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5996f01a686b2349cdb379083ec5ad3e8cb8767fb2d495d3a4f2ee4163a18d" +dependencies = [ + "cranelift-entity", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-bitset" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523fea83273f6a985520f57788809a4de2165794d9ab00fb1254fceb4f5aa00c" +dependencies = [ + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d73d1e372730b5f64ed1a2bd9f01fe4686c8ec14a28034e3084e530c8d951878" +dependencies = [ + "bumpalo", + "cranelift-assembler-x64", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-codegen-meta", + "cranelift-codegen-shared", + "cranelift-control", + "cranelift-entity", + "cranelift-isle", + "gimli", + "hashbrown 0.16.1", + "libm", + "log", + "pulley-interpreter", + "regalloc2", + "rustc-hash", + "serde", + "smallvec", + "target-lexicon", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-codegen-meta" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0319c18165e93dc1ebf78946a8da0b1c341c95b4a39729a69574671639bdb5f" +dependencies = [ + "cranelift-assembler-x64-meta", + "cranelift-codegen-shared", + "cranelift-srcgen", + "heck", + "pulley-interpreter", +] + +[[package]] +name = "cranelift-codegen-shared" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9195cd8aeecb55e401aa96b2eaa55921636e8246c127ed7908f7ef7e0d40f270" + +[[package]] +name = "cranelift-control" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8976c2154b74136322befc74222ab5c7249edd7e2604f8cbef2b94975541ffb9" +dependencies = [ + "arbitrary", +] + +[[package]] +name = "cranelift-entity" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6038b3147c7982f4951150d5f96c7c06c1e7214b99d4b4a98607aadf8ded89d1" +dependencies = [ + "cranelift-bitset", + "serde", + "serde_derive", + "wasmtime-internal-core", +] + +[[package]] +name = "cranelift-frontend" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbd294abe236e23cc3d907b0936226b6a8342db7636daa9c7c72be1e323420e" +dependencies = [ + "cranelift-codegen", + "log", + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cranelift-isle" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a90b6ed3aba84189352a87badeb93b2126d3724225a42dc67fdce53d1b139c" + +[[package]] +name = "cranelift-native" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3ec0cc1a54e22925eacf4fc3dc815f907734d3b377899d19d52bec04863e853" +dependencies = [ + "cranelift-codegen", + "libc", + "target-lexicon", +] + +[[package]] +name = "cranelift-srcgen" +version = "0.130.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "948865622f87f30907bb46fbb081b235ae63c1896a99a83c26a003305c1fa82d" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "disasmer-cli" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "disasmer-core", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "disasmer-coordinator" +version = "0.1.0" +dependencies = [ + "disasmer-core", + "postgres", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "disasmer-core" +version = "0.1.0" +dependencies = [ + "hex", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 1.0.69", +] + +[[package]] +name = "disasmer-dap" +version = "0.1.0" +dependencies = [ + "anyhow", + "disasmer-core", + "disasmer-node", + "serde_json", +] + +[[package]] +name = "disasmer-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "disasmer-node" +version = "0.1.0" +dependencies = [ + "disasmer-core", + "quinn", + "rcgen", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "wasmtime", +] + +[[package]] +name = "disasmer-sdk" +version = "0.1.0" +dependencies = [ + "disasmer-macros", + "futures-executor", + "serde", + "serde_json", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", +] + +[[package]] +name = "gimli" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" +dependencies = [ + "fnv", + "hashbrown 0.16.1", + "indexmap", + "stable_deref_trait", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "launch-build-demo" +version = "0.1.0" +dependencies = [ + "disasmer-sdk", + "futures-executor", + "serde", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mach2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +dependencies = [ + "libc", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "memfd" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" +dependencies = [ + "rustix", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "object" +version = "0.38.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +dependencies = [ + "crc32fast", + "hashbrown 0.16.1", + "indexmap", + "memchr", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "serde", +] + +[[package]] +name = "postgres" +version = "0.19.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ad20e0aa0b24f5a394eab4f78c781d248982b22b25cecc7e3aa46a681605bd" +dependencies = [ + "bytes", + "fallible-iterator", + "futures-util", + "log", + "tokio", + "tokio-postgres", +] + +[[package]] +name = "postgres-protocol" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac", + "md-5", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", + "serde_core", + "serde_json", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulley-interpreter" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec12fe19a9588315a49fe5704502a9c02d6a198303314b0c7c86123b06d29e5" +dependencies = [ + "cranelift-bitset", + "log", + "pulley-macros", + "wasmtime-internal-core", +] + +[[package]] +name = "pulley-macros" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regalloc2" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" +dependencies = [ + "allocator-api2", + "bumpalo", + "hashbrown 0.17.1", + "log", + "rustc-hash", + "smallvec", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "target-lexicon" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85c17d80feb7334b40c484e45ed1a5273dfd8bfda537c3be2e74a06a6686f327" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcef1a61bdb119096e153208ec5cbec23944ce8bca13be5c7f60c634f7403935" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-postgres" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2", + "tokio", + "tokio-util", + "whoami", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" +dependencies = [ + "leb128fmt", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", +] + +[[package]] +name = "wasmparser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" +dependencies = [ + "bitflags", + "hashbrown 0.16.1", + "indexmap", + "semver", + "serde", +] + +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +dependencies = [ + "bitflags", + "indexmap", + "semver", +] + +[[package]] +name = "wasmprinter" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" +dependencies = [ + "anyhow", + "termcolor", + "wasmparser 0.245.1", +] + +[[package]] +name = "wasmtime" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf" +dependencies = [ + "addr2line", + "async-trait", + "bitflags", + "bumpalo", + "cc", + "cfg-if", + "libc", + "log", + "mach2", + "memfd", + "object", + "once_cell", + "postcard", + "pulley-interpreter", + "rustix", + "serde", + "serde_derive", + "smallvec", + "target-lexicon", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-component-macro", + "wasmtime-internal-core", + "wasmtime-internal-cranelift", + "wasmtime-internal-fiber", + "wasmtime-internal-jit-debug", + "wasmtime-internal-jit-icache-coherence", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", + "wat", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-environ" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4172382dcc785c31d0e862c6780a18f5dd437914d22c4691351f965ef751c821" +dependencies = [ + "anyhow", + "cranelift-bforest", + "cranelift-bitset", + "cranelift-entity", + "gimli", + "hashbrown 0.16.1", + "indexmap", + "log", + "object", + "postcard", + "serde", + "serde_derive", + "sha2 0.10.9", + "smallvec", + "target-lexicon", + "wasm-encoder 0.245.1", + "wasmparser 0.245.1", + "wasmprinter", + "wasmtime-internal-core", +] + +[[package]] +name = "wasmtime-internal-component-macro" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae5ec9fff073ff13b81732d56a9515d761c245750bcda09093827f84130ebc25" +dependencies = [ + "anyhow", + "proc-macro2", + "quote", + "syn", + "wasmtime-internal-component-util", + "wasmtime-internal-wit-bindgen", + "wit-parser", +] + +[[package]] +name = "wasmtime-internal-component-util" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "935d9ab293ba27d1ec9aa7bc1b3a43993dbe961af2a8f23f90a11e1331b4c13f" + +[[package]] +name = "wasmtime-internal-core" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3820b174f477d2a7083209d1ad5353fcdb11eaea434b2137b8681029460dd3" +dependencies = [ + "hashbrown 0.16.1", + "libm", + "serde", +] + +[[package]] +name = "wasmtime-internal-cranelift" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1679d205caf9766c6aa309d45bb3e7c634d7725e3164404df33824b9f7c4fb7" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "cranelift-control", + "cranelift-entity", + "cranelift-frontend", + "cranelift-native", + "gimli", + "itertools", + "log", + "object", + "pulley-interpreter", + "smallvec", + "target-lexicon", + "thiserror 2.0.18", + "wasmparser 0.245.1", + "wasmtime-environ", + "wasmtime-internal-core", + "wasmtime-internal-unwinder", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-fiber" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1e505254058be5b0df458d670ee42d9eafe2349d04c1296e9dc01071dc20a85" +dependencies = [ + "cc", + "cfg-if", + "libc", + "rustix", + "wasmtime-environ", + "wasmtime-internal-versioned-export-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-jit-debug" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c2e05b345f1773e59c20e6ad7298fd6857cdea245023d88bb659c96d8f0ea72" +dependencies = [ + "cc", + "wasmtime-internal-versioned-export-macros", +] + +[[package]] +name = "wasmtime-internal-jit-icache-coherence" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86701b234a4643e3f111869aa792b3a05a06e02d486ee9cb6c04dae16b52dab" +dependencies = [ + "cfg-if", + "libc", + "wasmtime-internal-core", + "windows-sys 0.61.2", +] + +[[package]] +name = "wasmtime-internal-unwinder" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63558d801beb83dde9b336eb4ae049019aee26627926edb32cd119d7e4c83cd" +dependencies = [ + "cfg-if", + "cranelift-codegen", + "log", + "object", + "wasmtime-environ", +] + +[[package]] +name = "wasmtime-internal-versioned-export-macros" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737c4d956fc3a848541a064afb683dd2771132a6b125be5baaf95c4379aa47df" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasmtime-internal-wit-bindgen" +version = "43.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2192a77a00b9a67800c2b4e1c70fb6abca79d6b529e53a2ef9dcdcc36090330d" +dependencies = [ + "anyhow", + "bitflags", + "heck", + "indexmap", + "wit-parser", +] + +[[package]] +name = "wast" +version = "252.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65" +dependencies = [ + "bumpalo", + "leb128fmt", + "memchr", + "unicode-width", + "wasm-encoder 0.252.0", +] + +[[package]] +name = "wat" +version = "1.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb" +dependencies = [ + "wast", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-parser" +version = "0.245.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330698718e82983499419494dd1e3d7811a457a9bf9f69734e8c5f07a2547929" +dependencies = [ + "anyhow", + "hashbrown 0.16.1", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser 0.245.1", +] + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..73857eb --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,36 @@ +[workspace] +resolver = "2" +members = [ + "crates/disasmer-cli", + "crates/disasmer-coordinator", + "crates/disasmer-core", + "crates/disasmer-dap", + "crates/disasmer-macros", + "crates/disasmer-node", + "crates/disasmer-sdk", + "examples/launch-build-demo", +] + +[workspace.package] +edition = "2021" +license = "Apache-2.0 OR MIT" +repository = "https://example.invalid/disasmer" + +[workspace.dependencies] +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } +futures-executor = "0.3" +hex = "0.4" +proc-macro2 = "1.0" +postgres = { version = "0.19", features = ["with-serde_json-1"] } +quinn = { version = "0.11.11", default-features = false, features = ["runtime-tokio", "rustls-ring"] } +quote = "1.0" +rcgen = "0.14" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +sha2 = "0.10" +syn = { version = "2.0", features = ["full"] } +tempfile = "3.10" +thiserror = "1.0" +tokio = { version = "1.52", features = ["io-util", "macros", "rt-multi-thread"] } +wasmtime = { version = "=43.0.2", default-features = false, features = ["async", "cranelift", "debug", "runtime", "std", "wat"] } diff --git a/DISASMER_PUBLIC_TREE.json b/DISASMER_PUBLIC_TREE.json new file mode 100644 index 0000000..7f3538f --- /dev/null +++ b/DISASMER_PUBLIC_TREE.json @@ -0,0 +1,13 @@ +{ + "kind": "disasmer-filtered-public-tree", + "source_commit": "309831e1e021f962c118452336776fd9a94025f9", + "release_name": "dryrun-309831e1e021", + "filtered_out": [ + "private/**", + "experiments/**", + ".git", + "target" + ], + "forgejo_host": "git.michelpaulissen.com", + "default_operator_endpoint": "https://disasmer.michelpaulissen.com:9443" +} diff --git a/MVP.md b/MVP.md new file mode 100644 index 0000000..ade1a24 --- /dev/null +++ b/MVP.md @@ -0,0 +1,3559 @@ +# Disasmer MVP Engineering Plan + +**Status:** draft MVP execution plan +**Audience:** engineering / founder-engineer +**Goal:** build a public-launchable MVP that proves Disasmer can replace CI/build systems with a general distributed Wasm runtime whose flagship use case is distributed, local-first, source-debuggable builds. + +--- + +## 0. Executive summary + +Disasmer should launch as a **general distributed Wasm runtime** with a **build-system experience so strong that it is the obvious wedge**. The MVP is not a toy local scheduler and not merely a CI runner with nicer YAML. It must demonstrate the core thesis: + +> A build can be a normal Rust program, running as one virtual process across multiple machines, with build steps represented as virtual threads/tasks, artifacts represented by a low-latency virtual filesystem, and debugging represented as one normal VS Code debug session. + +The launchable MVP should include: + +- Open-source local runtime, SDK, CLI, node runtime, protocol definitions, and VS Code extension. +- Hosted coordinator from day one, with persistent project/identity state in Postgres and private hosted Authentik/OIDC/community-policy modules; the community tier coordinates but does not provide arbitrary hosted compute. +- User-attached Linux and Windows nodes. +- Containerfile-based environment discovery for MVP user projects. +- Rootless Podman backend for Linux command execution. +- Windows command backend sufficient for executing commands unsandboxed, with the Windows sandboxing backend explicitly stubbed (to be implemented later) and isolated behind an interface. +- Wasmtime-based execution for Disasmer guest/orchestration code. +- Native command execution only through node runtimes, never through the hosted coordinator control plane. +- Small serializable task arguments and results. +- Large inputs/outputs passed through handles: `SourceSnapshot`, `Artifact`, `Blob`, and VFS manifests. +- Local-first VFS where `flush()` publishes metadata and `sync()` triggers explicit user-chosen export/transfer/durability behavior; artifacts are otherwise ephemeral node-local data. +- Git support as an optional source-provider module that is included by default, with non-Git providers pluggable and dependencies avoidable when unused. +- All-stop debugging with Debug Epochs as a mandatory MVP capability. +- A Disasmer DAP adapter that targets VS Code first while staying usable by other DAP clients. +- Task restart from filesystem/artifact checkpoint boundaries, not live stack migration or hot-swapping. +- Abuse controls designed into the object model and control plane, not bolted on later. +- Operator panel foundations in the protocol and runtime-state model, with the full immediate-mode panel as the second product milestone. + +The flagship demo should make people immediately understand why this is not “yet another CI system”: + +1. The repo has `envs/linux/Containerfile` and `envs/windows/Containerfile`. +2. The build is one Rust source file using `#[disasmer::main]`, `#[disasmer::task]`, `spawn::task`, `env!("linux")`, `env!("windows")`, `cmd!(...)`, `fs::flush()`, and `fs::sync() / artifact::export_to_node()`. +3. A Linux node and a Windows node can be attached to the hosted coordinator. +4. Pressing F5 in VS Code starts one virtual process. +5. VS Code shows `main`, `compile linux`, `compile windows`, and `package` as threads of one debug target. +6. A breakpoint in the Windows build task stops the whole virtual process using a Debug Epoch. +7. The user edits the build logic, restarts only the failed task from a clean VFS checkpoint, and continues. +8. Artifacts appear through the VFS without coordinator-routed bulk data. + +--- + +## 1. Product stance and launch bar + +### 1.1 Positioning + +Disasmer should be positioned as: + +> A distributed Wasm runtime that makes distributed execution feel like ordinary source-level-debuggable multithreaded programming, with build systems as the first killer use case. + +It is not primarily: + +- a hosted CI SaaS; +- a YAML syntax replacement; +- a remote shell wrapper; +- a Kubernetes abstraction; +- a general-purpose compute subsidy; +- a task queue with logs; +- or a build cache alone. + +It should feel closer to: + +> “What if my build system were an actual program, and the debugger saw the whole distributed build as one process?” + +### 1.2 Public-launch promise + +The launch should be built around a concrete promise: + +```text +Add a Containerfile. +Reference it from Rust with env!("name"). +Press F5. +Debug the distributed build like one normal program. +``` + +The public launch should be able to show a short video or GIF with these beats: + +```text +1. VS Code open on a Rust build program. +2. env!("linux") and env!("windows") autocomplete from envs/**/Containerfile. +3. Press F5. +4. Hosted coordinator shows two attached nodes. +5. Linux and Windows build tasks start. +6. VS Code thread list shows virtual threads. +7. Breakpoint hits in the Windows task. +8. Both tasks stop. +9. User inspects args/locals/artifact handles. +10. User restarts only the failed task. +11. Final artifacts are explicitly exported. +``` + +### 1.3 What must be true before public launch + +The MVP is launchable only if these are true: + +- A user can use the hosted coordinator without you granting them compute. +- A user can attach their own Linux node and run a build task on it. +- A user can attach a Windows node and run a Windows command task on it. +- Source code references environments by logical name, not by runner label or machine name. +- Local-first Linux builds do not upload the repo, tar the checkout, route file reads through the coordinator, or automatically upload build outputs. +- `flush()` is cheap and metadata-first. +- `sync()` is explicit and may move bytes. +- The debugger shows one process and many virtual threads. +- A Debug Epoch stops all Wasm tasks and controlled native command tasks for the virtual process. +- Task restart works from task entrypoint or last published VFS checkpoint. +- Community hosted users cannot run arbitrary containers or native commands on your infrastructure. +- Every public object has tenant ownership, quota accounting, and authorization checks. + +### 1.4 What can be rough at launch + +These can be rough, provided the rough edges are honest and isolated: + +- Windows sandboxing can be a stubbed backend with a clear `windows-command-dev` capability, as long as hosted managed Windows compute is not offered to untrusted users. +- Nix can be included as a default plugin but not required for the flagship Containerfile demo. +- The operator panel can be scaffolded and visible as a preview, with the full immediate-mode UI in the second milestone. +- Debugger variable inspection can prioritize task args, Disasmer handles, top-level locals around Disasmer API calls, stack frames, and command status before supporting every possible Rust compiler local perfectly. +- NAT traversal can be coordinator-aided without relay. If direct node-to-node connectivity fails, scheduling and artifact flows should avoid requiring peer transfer rather than silently relaying bulk data through the coordinator. + +--- + +## 2. Hard MVP decisions from the project constraints + +### 2.1 Product scope + +The MVP includes both: + +- **open-source local-first runtime**, because credibility depends on not hiding the core runtime; and +- **hosted coordinator**, because the GitHub Actions replacement story needs an easy entry point, public rendezvous, auth, node registry, debug sessions, and a web surface. + +The hosted coordinator is a control plane, not a general-purpose compute platform. + +### 2.2 First target user + +The first target user is: + +> A developer or small team that currently uses GitHub Actions, Forgejo Actions, or similar CI, and wants something more programmable, local-first, debuggable, and self-hostable. + +The first user does not need managed compute from Disasmer. They need: + +- local and self-hosted nodes; +- a hosted coordinator for convenience; +- VS Code debugging; +- artifact metadata, ephemeral artifact handles, and explicit output export; +- a better mental model than YAML pipelines; +- and a credible path to replacing CI. + +### 2.3 Host platforms + +MVP node platforms: + +```text +Linux required +Windows required +macOS not required for launch, but kept in the model +BSD-like not required for launch, but kept in the model +``` + +Linux should be the most complete backend. Windows should be complete enough to run a real attached worker and prove cross-platform task placement. macOS remains a design-supported later node type. + +### 2.4 Environment support + +MVP user-facing environment support: + +```text +Containerfile / Dockerfile discovery: required +Nix plugin: included by default, but not launch-critical for all flows +Windows sandbox backend: interface + stub, not trusted managed compute +``` + +The MVP should discover: + +```text +envs//Containerfile +envs//Dockerfile +``` + +It may also discover: + +```text +envs//flake.nix +envs//flake.lock +``` + +But the flagship launch demo should not depend on Nix unless it is already stable in the implementation. + +### 2.5 Execution support + +The MVP must run both: + +- Wasm task entrypoints, using Wasmtime on node runtimes; and +- native commands, through `cmd!(...)`, executed only by node runtimes inside declared environments. + +The coordinator must not run native commands or containers. The hosted control loop may run zero-capability Wasm, but only with strict fuel, memory, log, state, and wall-clock limits. + +### 2.6 Wasm model + +Use the simplest Wasmtime model that is secure and functional: + +```text +Core Wasm module + explicit host imports first. +Component Model support later or behind an internal abstraction. +``` + +Reasoning: + +- Core Wasm embedding is enough for task entrypoints, host imports, memory access, and debug probes. +- The MVP does not need to expose a stable cross-language component ABI yet. +- The runtime should keep an internal `GuestAbi` trait so Component Model support can replace or augment this later. + +### 2.7 Data model + +MVP task boundaries allow only: + +```text +small serializable values +immutable content-addressed objects +Disasmer runtime handles +``` + +Do not allow: + +```text +raw pointers +borrowed references +local file handles +sockets +process handles +MutexGuards +unbounded byte vectors +native closures +live stack migration +``` + +Large data moves through: + +```text +SourceSnapshot +Blob +Artifact +VirtualFile +VfsManifest +``` + +### 2.8 Checkpoint model + +MVP checkpointing means: + +```text +filesystem/artifact checkpoints for task restart +``` + +It does not mean: + +```text +live Wasm stack checkpointing +native process checkpointing +socket checkpointing +transparent migration of running processes +hot-swapping code into existing stack frames +``` + +A restartable task must be recreated from: + +```text +task name +new module hash +original serialized args +same environment handle +same source snapshot or selected dirty overlay +latest allowed VFS checkpoint +``` + +### 2.9 Debugging model + +All-stop debugging is mandatory. + +The debugger must present: + +```text +one virtual process +many virtual threads/tasks +normal breakpoints in Disasmer Rust task code +normal thread list +stacks +selected locals / task args / handle inspection +stdout/stderr per task +artifacts and VFS state in optional views +restart selected task +``` + +The MVP should provide a Disasmer-owned DAP adapter. VS Code is the first supported client, but the adapter should speak ordinary DAP so other IDEs or tools can integrate later without Disasmer providing those integrations. LLDB/lldb-dap can be used internally later for richer DWARF support, but the virtual-process illusion should remain owned by Disasmer. + +### 2.10 Transport + +Use Quinn for MVP QUIC transport, behind a transport abstraction: + +```text +node-to-node data/control links: QUIC over Quinn +node-to-coordinator persistent session: QUIC preferred, HTTPS/WebSocket allowed for auth/bootstrap +bulk transfer: node-to-node only, no coordinator bulk relay +``` + +Quinn is the MVP default because it is Rust-native and async-friendly, which keeps packaging and dogfooding simpler than an external C runtime dependency. Keep the `Transport` trait strict enough that quiche, TQUIC, or another implementation can replace it later if benchmarks, platform packaging, or protocol features justify a change. + +The coordinator can help with rendezvous and endpoint candidate exchange. It should not relay bulk traffic. MVP can avoid cross-node bulk transfer if direct connectivity fails by scheduling tasks where the data already is or requiring explicit export to a receiver node or user-configured storage integration. + +### 2.11 Storage + +Use an abstract coordinator store with a strict split between persistent product state and ephemeral virtual-process state. + +Persistent state should use Postgres in the hosted deployment and should be limited to things that must survive coordinator restart: + +```text +users / tenants / memberships where applicable +projects +node identities and enrollment records +node public keys / credentials / revocation state +bundle registry and environment metadata worth reusing +source-provider configuration metadata +hosted/community policy configuration +billing/plan flags later +longer-lived audit events where needed +``` + +Ephemeral runtime state should live in coordinator memory for the MVP: + +```text +active virtual processes +virtual threads and task placement +Debug Epochs +pending spawn queues +scheduler decisions +live node sessions and heartbeats +recent logs/events +VFS manifest cache for active processes +artifact metadata for active processes +quota counters for active processes, except hosted/community abuse ledgers that the private service wants to persist +``` + +If the coordinator restarts during the MVP, active virtual processes may be lost and should restart cleanly. That is acceptable. Do not overbuild durable process recovery before the runtime/debugging thesis is proven. + +The store abstraction still matters because later the coordinator may need durable process recovery, Raft, sharding, or a different store. The MVP should not spend engineering effort on distributed consensus before the product is proven. + +### 2.12 Auth + +Support two auth modes from the start: + +```text +hosted/community service auth: Authentik through OAuth2/OIDC, implemented in a private hosted module +CLI human auth: browser callback against the hosted service +CLI/agent auth: public-key identity with explicit enrollment/authorization +node attach: short-lived enrollment token -> long-lived node key/certificate +service-to-service: scoped service tokens where needed +open-source/private coordinator auth: simple local/team trust model with pluggable auth hooks, not the hosted OIDC stack by default +``` + +User OAuth tokens must not be passed to nodes. Nodes authenticate with node identity, and tasks receive only explicit, scoped, short-lived task tokens or secrets. + +The CLI should be comfortable for both humans and agents. Browser login is fine for a developer at a workstation; agents should be able to use a generated keypair, an enrollment challenge, and a scoped project/node credential without opening a browser. + +### 2.13 Community hosted abuse stance + +The community hosted tier should prove the platform, not subsidize builds. The word “community” is intentional: it should be useful for serious users, while enterprise/team use cases should naturally need more than one active process, longer retention, stronger identity, or managed capacity. + +Community hosted mode supports: + +```text +sign in +create project (may be implicitly created, max 1) +create virtual process (may be implicitly created, max 1) +open coordinator UI +attach own node (max 4 nodes) +spawn tasks onto own node (max 8 tasks in flight) +debug from VS Code +see logs/artifact metadata for active processes (logs can't be bigger than 16KiB) +``` + +Community hosted mode does not support: + +```text +hosted containers +hosted native commands +default hosted artifact storage +public artifact hosting +network access from hosted Wasm +long-running compute +relay bandwidth +``` + +### 2.14 Operator panel + +The operator panel is second milestone, but the MVP should design toward it: + +- runtime-state structures for panel state and events; +- protocol namespace for typed UI updates; +- control-plane UI slot for a panel; +- built-in widgets only; +- no custom HTML/JS; +- read-only behavior when the virtual process is stopped in a debugger. +- The panel has very tight size/state constraints for community mode + +For public launch, a fixed coordinator dashboard is enough if it clearly hints at the immediate-mode panel API. + +--- + +## 3. MVP architecture + +### 3.1 High-level architecture + +```text +VS Code + ├─ Disasmer VS Code extension + └─ Disasmer DAP adapter + │ + ▼ +Hosted or local coordinator + ├─ Auth / tenants / projects + ├─ node registry and rendezvous + ├─ virtual process registry + ├─ scheduler + ├─ debug epoch coordinator + ├─ VFS/artifact metadata for active processes + ├─ hosted/community policy hooks + ├─ operator panel state/events scaffold + ├─ in-memory active runtime state + └─ Postgres persistent project/identity store + │ + ├────────────── QUIC / control session ───────────────┐ + ▼ ▼ +Linux node runtime Windows node runtime + ├─ Wasmtime task runner ├─ Wasmtime task runner + ├─ command runner ├─ command runner + ├─ Podman environment backend ├─ Windows command backend + ├─ Nix plugin backend ├─ Windows sandbox stub backend + ├─ VFS overlay store ├─ VFS overlay store + ├─ ephemeral artifact/blob cache ├─ ephemeral artifact/blob cache + ├─ debug agent ├─ debug agent + ├─ node cache ├─ node cache + └─ Quinn peer transport └─ Quinn peer transport +``` + +### 3.2 Control plane vs data plane + +The coordinator owns: + +```text +identity +membership +node capabilities +bundle metadata +virtual process metadata +task placement decisions +debug epochs +VFS manifest metadata for active processes +artifact metadata for active processes +quota accounting +operator panel metadata +rendezvous +``` + +All of these need constraints so that the community service can't be abused. + +The nodes own: + +```text +Wasm execution +native command execution +environment materialization +source checkout access +build caches +VFS bytes +artifact bytes +large blob transfer +debug stop/resume implementation +``` + +User-attached nodes are the users' responsibility and can run broad local capabilities. The hosted coordinator must still validate what capabilities are accepted for a hosted/community process, but the open-source/private coordinator should not block normal local-node capabilities by default. + +The coordinator must not become: + +```text +a network filesystem +a blob proxy +a remote compiler filesystem +a hidden build runner +a relay by default +``` + +### 3.3 Recommended repository layout + +```text +disasmer/ + crates/ + disasmer-sdk/ # guest-facing Rust API + disasmer-macros/ # #[disasmer::main], #[disasmer::task], env!() + disasmer-abi/ # guest/host ABI structs and serialization + disasmer-cli/ # user CLI + disasmer-coordinator/ # hosted/local coordinator service + disasmer-node/ # node runtime daemon + disasmer-dap/ # custom Debug Adapter Protocol server + disasmer-vscode-protocol/ # extension <-> CLI/DAP shared types + disasmer-transport/ # Quinn-backed QUIC wrapper and protocol framing + disasmer-store/ # store traits + Postgres persistent-store impl + disasmer-runtime-state/ # in-memory active process/thread/debug state + disasmer-vfs/ # VFS manifests, overlays, blob/chunk references + disasmer-env/ # env discovery, normalization, caching + disasmer-source/ # source-provider traits and manifests + disasmer-git/ # default optional Git source provider module + disasmer-debug-model/ # breakpoints, epochs, stack/locals schema + disasmer-ui-model/ # immediate-mode UI schema scaffold + disasmer-auth/ # node enrollment, pubkey identities, pluggable auth hooks + disasmer-protocol/ # coordinator/node/DAP wire messages + private/ + hosted-oidc/ # Authentik/OIDC integration for the public service + hosted-policy/ # community quotas, abuse controls, zero-cap hosted-loop rules + hosted-admin/ # service admin tools, kill switches, internal dashboards + vscode/ + extension/ # TypeScript extension + examples/ + launch-build/ # flagship Linux + Windows build demo + local-one-node/ # minimal spawn/join example + operator-panel-preview/ # second-milestone preview + docs/ + MVP.md + architecture.md + protocol.md + security.md + debugging.md +``` + +The private folders are normal repository folders during development, but must be easy to exclude from the public source release. The public crates should expose policy/auth interfaces; the private modules implement hosted-service-specific OIDC, community quota enforcement, hosted zero-capability Wasm limits, abuse tooling, and admin affordances. + +### 3.4 Process model + +MVP process types: + +```text +disasmer coordinator + Hosted or local control-plane service. + +disasmer node + Long-running node runtime attached to a coordinator. + +disasmer dap + Debug Adapter Protocol process launched by VS Code. + +disasmer cli + User/agent CLI for login, init, build, run, node attach, artifact export. +``` + +### 3.5 Primary user commands + +```bash +# Authenticate a human against the hosted coordinator. +disasmer login https://disasmer.example.com + +# Create or enroll a non-browser CLI identity for an agent. +disasmer auth keygen --name forgejo-worker + +disasmer auth enroll-key --coordinator https://disasmer.example.com --project my-project --public-key ~/.config/disasmer/keys/forgejo-worker.pub + +# Initialize project metadata; optional because defaults should work. +disasmer init + +# Show discovered environments, source providers, and snapshot policy. +disasmer inspect + +# Build the virtual binary/bundle. +disasmer build + +# Attach a node. OS, architecture, container backend, Git availability, and most +# capabilities are auto-detected; --cap remains available for explicit overrides. +disasmer node attach --coordinator https://disasmer.example.com --token dis_node_enroll_... + +# Override or add an auto-detected capability only when needed. +disasmer node attach --coordinator https://disasmer.example.com --token dis_node_enroll_... --cap env.windows-command-dev=true + +# Run without VS Code. Hosted coordinator is the default when logged in; +# project defaults to the current directory. Entry point is optional. +disasmer run + +disasmer run build-linux + +disasmer run --project ../other-repo package-release + +disasmer run --local smoke-test + +# Export final artifacts by asking an attached/local receiver node to pull them. +# There is no default coordinator artifact store. +disasmer artifact export --process --to ./dist +``` + +`disasmer run` should accept an optional entry point. This is a good idea because one Disasmer project may contain several useful virtual-process entrypoints: `build`, `test`, `package`, `release`, `watch`, `operator`, or project-specific workflows. The default remains the `#[disasmer::main]` entrypoint when no entry is supplied. + +VS Code should hide most of this behind: + +```text +Disasmer: Login +Disasmer: Attach Local Node +Disasmer: Build Bundle +Disasmer: Run +Disasmer: Debug +Disasmer: Restart Failed Task +Disasmer: Export Artifacts +``` + +--- + +## 4. The flagship launch example + +### 4.1 Project layout + +```text +launch-build-demo/ + Cargo.toml + Cargo.lock + src/ + main.rs + envs/ + linux/ + Containerfile + windows/ + Containerfile + inputs/ + build-config.json + disasmer.toml # optional; only used for launch polish/overrides +``` + +### 4.2 Example `disasmer.toml` + +The file should be optional, but the demo can include it to make platform constraints obvious. + +```toml +[project] +name = "launch-build-demo" + +[envs.linux] +path = "envs/linux/Containerfile" +kind = "container" +requires = { os = "linux", arch = "x86_64" } + +[envs.windows] +path = "envs/windows/Containerfile" +kind = "container" +requires = { os = "windows", arch = "x86_64" } +backend_hint = "windows-command-dev" + +[vfs] +include = [ + "src/**", + "Cargo.toml", + "Cargo.lock", + "inputs/**" +] +exclude = [ + "target/**", + ".git/**" +] + +[git] +enabled = true +dirty_overlay = true +submodules = true +``` + +### 4.3 Example Rust build program + +```rust +use disasmer::prelude::*; + +#[derive(Serialize, Deserialize, Clone, Debug, DisasmerArg)] +enum Target { + LinuxX64, + WindowsX64, +} + +impl Target { + fn env(&self) -> Env { + match self { + Target::LinuxX64 => env!("linux"), + Target::WindowsX64 => env!("windows"), + } + } + + fn name(&self) -> &'static str { + match self { + Target::LinuxX64 => "linux-x64", + Target::WindowsX64 => "windows-x64", + } + } + + fn command(&self) -> CommandSpec { + match self { + Target::LinuxX64 => cmd!("cargo", "build", "--release"), + Target::WindowsX64 => cmd!("cargo", "build", "--release"), + } + } +} + +#[disasmer::main] +async fn main() -> Result<()> { + // The hosted coordinator control loop is intentionally capless. Source access + // happens on a user-attached node through the default Git source-provider + // module, not inside the coordinator process. + let src = spawn::task(prepare_source) + .placement(placement::any_node().with_cap("source.git")) + .queue_until_node_available() + .start() + .await? + .join() + .await?; + + let completed = DAtomicU32::new(0).await?; + + let linux = spawn::task(compile) + .arg(BuildArgs::new(Target::LinuxX64, src.clone(), completed.clone())) + .env(env!("linux")) + .start() + .await?; + + let windows = spawn::task(compile) + .arg(BuildArgs::new(Target::WindowsX64, src.clone(), completed.clone())) + .env(env!("windows")) + .start() + .await?; + + let linux_artifact = linux.join().await?; + let windows_artifact = windows.join().await?; + + let dist = package(vec![linux_artifact, windows_artifact]).await?; + + assert_eq!(completed.load(Ordering::SeqCst).await?, 2); + + // Export is explicit code. The coordinator does not provide a default + // artifact store; this asks an attached receiver/local node to pull bytes. + artifact::export_to_node(dist, receiver::local("dist")).await?; + Ok(()) +} + +#[disasmer::task] +async fn prepare_source() -> Result { + source::git::current_repo().snapshot().await +} + +#[derive(Serialize, Deserialize, Clone, Debug, DisasmerArg)] +struct BuildArgs { + target: Target, + src: SourceSnapshot, + completed: DAtomicU32, +} + +impl BuildArgs { + fn new(target: Target, src: SourceSnapshot, completed: DAtomicU32) -> Self { + Self { target, src, completed } + } +} + +#[disasmer::task] +async fn compile(args: BuildArgs) -> Result { + fs::mount("/src", args.src).await?; + + args.target.command() + .cwd("/src") + .env("DISASMER_TARGET", args.target.name()) + .run() + .await?; + + let output_path = match args.target { + Target::LinuxX64 => "/src/target/release/app", + Target::WindowsX64 => "/src/target/release/app.exe", + }; + + let artifact_path = format!("/vfs/artifacts/{}/{}", args.target.name(), basename(output_path)); + fs::copy(output_path, &artifact_path).await?; + + fs::flush().await?; + args.completed.fetch_add(1, Ordering::SeqCst).await?; + + Artifact::from_path(artifact_path).await +} + +#[disasmer::task] +async fn package(outputs: Vec) -> Result { + fs::create_dir_all("/vfs/artifacts/dist").await?; + + for output in outputs { + fs::copy(output.path(), "/vfs/artifacts/dist/").await?; + } + + fs::flush().await?; + Artifact::from_path("/vfs/artifacts/dist").await +} +``` + +### 4.4 What the demo proves + +The demo proves: + +- The build graph is source code, not YAML. +- Environment resources live in the repo. +- Source code references `env!("linux")` and `env!("windows")`, not runner labels. +- The coordinator schedules by logical environment and node capability. +- Linux and Windows tasks can run in the same virtual process. +- `cmd!(...)` runs on nodes, not on the hosted coordinator. +- Outputs become artifact handles through the VFS. +- `flush()` publishes artifacts for downstream tasks. +- Final artifact export is explicit code and does not imply coordinator artifact storage. +- VS Code sees one virtual process and multiple virtual threads. +- A Debug Epoch stops the whole virtual process. +- Restarting a failed task after editing source is part of the normal loop. + +--- + +## 5. Rust SDK and task API + +### 5.1 MVP API surface + +The MVP Rust SDK should include: + +```rust +#[disasmer::main] +#[disasmer::task] +env!("name") +spawn::task(task_fn) +TaskHandle +Artifact +Blob +SourceSnapshot +VirtualFile +DAtomicU32 +DAtomicU64 +DChannel +fs::mount() +fs::copy() +fs::flush() +fs::sync() / artifact::export_to_node() +cmd!(...) +source::git::current_repo().snapshot() +ui::* scaffold only +``` + +Do not include every distributed data structure in the first API. The launch demo needs small data, atomics/counters, task handles, source snapshots, artifacts, and VFS operations more than it needs a complete distributed memory library. + +### 5.2 Task registration + +`#[disasmer::task]` should generate: + +- a stable task symbol; +- a task ABI descriptor; +- argument/result serialization metadata; +- debug metadata for task entry, source spans, and selected local/argument values; +- capability declarations inferred from APIs used where possible; +- task restart compatibility hash; +- a registration entry in the bundle manifest. + +Example generated manifest entry: + +```json +{ + "task_id": "compile@sha256:...", + "symbol": "compile", + "source": "src/main.rs", + "arg_type": "BuildArgs@sha256:...", + "result_type": "Artifact@sha256:...", + "requires": ["vfs", "cmd", "artifact.publish"], + "debug": { + "spans": "debug/compile.spans.json", + "locals_schema": "debug/compile.locals.json" + } +} +``` + +### 5.3 Argument rules + +Task arguments and results must implement `DisasmerArg`. + +Allowed: + +```text +u32, u64, bool, String +small structs/enums +SourceSnapshot +Artifact +Blob +Env +DAtomicU32 / DAtomicU64 +DChannel +VirtualFile +small Vec with configured size limit +``` + +Rejected: + +```text +&T / &mut T +raw pointers +Rc +RefCell +std::fs::File +TcpStream +MutexGuard +native thread/process handles +unbounded Vec +closures with implicit captures +``` + +The compiler error should explain the distributed boundary. For example: + +```text +BuildArgs cannot be passed to a Disasmer virtual thread because it contains `&Path`. +Use String, SourceSnapshot, VirtualFile, Blob, or Artifact instead. +``` + +### 5.4 Size limits + +Set a default task argument size limit such as: + +```text +soft warning: 256 KiB +hard default: 4 MiB +project override: allowed but noisy +``` + +The hosted/community coordinator control loop should use much tighter limits, for example a 4 KiB warning and 16 KiB hard limit. User-attached nodes can keep the normal node defaults unless project policy overrides them. +A user accidentally passing an 82 MB `Vec` should see: + +```text +Task argument is 82 MB and would be copied to the spawned virtual thread. +Use Blob::from_file, SourceSnapshot, Artifact, or VirtualFile instead. +``` + +### 5.5 Host imports + +The MVP guest ABI should be explicit and small. + +Core imports: + +```text +disasmer.spawn.start +disasmer.spawn.join +disasmer.task.yield_safepoint +disasmer.task.current +disasmer.fs.mount +disasmer.fs.copy +disasmer.fs.flush +disasmer.fs.sync +disasmer.artifact.from_path +disasmer.artifact.open +disasmer.blob.open +disasmer.cmd.start +disasmer.cmd.poll +disasmer.cmd.kill +disasmer.atomic.new_u32 +disasmer.atomic.fetch_add_u32 +disasmer.atomic.load_u32 +disasmer.debug.probe +disasmer.log.write +disasmer.ui.emit_snapshot # scaffold only +``` + +### 5.6 Native command API + +`cmd!(...)` should build a `CommandSpec` rather than immediately running a process. + +```rust +cmd!("cargo", "build", "--release") + .cwd("/src") + .env("RUSTFLAGS", "...") + .timeout(Duration::from_secs(600)) + .run() + .await?; +``` + +The host checks: + +- current virtual thread has `cmd.run` capability; +- selected environment supports native commands; +- the task is running on a node, not the hosted coordinator; +- process limits are configured; +- filesystem mounts are inside the VFS/environment policy; +- network policy is explicit; +- logs are capped and streamed with backpressure. + +The hosted coordinator control plane never has this capability. An open-source/private coordinator may run with an embedded local node for trusted deployments; that node can have normal local command capabilities by default. Keep this as a compact policy/runtime module boundary rather than a separate node implementation, so dogfooding still uses the same node runtime code. + +### 5.7 Compile-time environment references + +`env!("linux")` should validate against generated environment metadata. + +Implementation: + +1. `disasmer build` scans `envs/**` and optional `disasmer.toml`. +2. It writes generated metadata to `target/disasmer/generated/envs.rs` and a JSON manifest. +3. The macro consults generated metadata during build. +4. VS Code reads the same metadata for autocomplete and diagnostics. + +Missing env error: + +```text +env!("windows") does not exist. +Discovered environments: linux. +Create envs/windows/Containerfile or add [envs.windows] to disasmer.toml. +``` + +--- + +## 6. Bundle and build pipeline + +### 6.1 Bundle concept + +A Disasmer bundle is a content-addressed virtual binary. + +It includes: + +```text +Wasm module(s) +Disasmer manifest +registered tasks +debug metadata +environment definitions +selected environment context files +source snapshot metadata +selected VFS seed files +capability declarations +content hashes +``` + +It should not embed full container images. + +### 6.2 Bundle layout + +```text +app.dis/ + manifest/disasmer.json + modules/main.wasm + debug/main.dwarf + debug/disasmer-debug.json + envs/linux/Containerfile + envs/windows/Containerfile + envs/manifest.json + source/source-snapshot.json + source/dirty-overlay-manifest.json + inputs/build-config.json + vfs/seed-manifest.json + capabilities.json +``` + +The on-disk `.dis` can initially be a directory during development and later become a zip/tar-like archive with content-addressed chunks. + +### 6.3 Build command pipeline + +```text +disasmer build + 1. discover project root + 2. load disasmer.toml if present + 3. discover envs/**/Containerfile and envs/**/Dockerfile + 4. discover Nix flakes if plugin enabled + 5. resolve source snapshot through enabled source-provider module, Git by default + 6. compute dirty overlay manifest + 7. generate env metadata for env!() + 8. compile Rust guest to Wasm + 9. run macro/task registration collection + 10. collect debug metadata + 11. build VFS seed manifest + 12. compute bundle digest + 13. write app.dis +``` + +### 6.4 Digest strategy + +Bundle digest should include: + +```text +Wasm bytes +task ABI descriptors +environment recipe files +environment context file hashes +source snapshot provider identity and revision, such as a Git commit id +dirty overlay file hashes +selected input file hashes +capability declarations +debug metadata hash +SDK ABI version +``` + +It should not include: + +```text +node-local cache paths +absolute host checkout path +local build timestamps +unselected .git internals +unselected target directory contents +``` + +### 6.5 Compatibility hashes + +Task restart requires compatibility checks. Generate hashes for: + +```text +task public signature +argument schema +result schema +capability declaration +environment reference set +VFS mount contract +Disasmer ABI version +``` + +If only implementation changed and public schema is compatible, allow selected task restart. If schema changed, restart the whole virtual process. + +--- + +## 7. Node runtime + +### 7.1 Node responsibilities + +The node runtime is where real work happens. + +It must support: + +```text +registration with coordinator +capability reporting +heartbeat and health reporting +bundle fetch or local bundle lookup +Wasmtime task instantiation +native command execution +environment materialization/caching +VFS overlay storage +ephemeral artifact/blob cache +source snapshot providers +debug epoch handling +logs/events streaming +quota enforcement +Quinn peer links +``` + +### 7.2 Node state directories + +Linux default: + +```text +~/.local/share/disasmer/node/ + config.toml + identity/ + bundles/ + env-cache/ + vfs/ + blobs/ + artifacts/ + logs/ + tmp/ +``` + +Windows default: + +```text +%LOCALAPPDATA%\Disasmer\node\ + config.toml + identity\ + bundles\ + env-cache\ + vfs\ + blobs\ + artifacts\ + logs\ + tmp\ +``` + +### 7.3 Node capability model + +Node capabilities should be explicit and signed by node identity plus coordinator policy. + +Example: + +```json +{ + "node_id": "node_123", + "tenant_id": "tenant_abc", + "os": "linux", + "arch": "x86_64", + "capabilities": { + "wasm": true, + "cmd": true, + "container.podman.rootless": true, + "nix": true, + "git.source_provider": true, + "debug.suspend_process_tree": true + }, + "limits": { + "max_tasks": 4, + "max_log_bytes_per_task": 10485760, + "max_arg_bytes": 4194304 + } +} +``` + +Windows dev backend example: + +```json +{ + "node_id": "node_win_123", + "tenant_id": "tenant_abc", + "os": "windows", + "arch": "x86_64", + "capabilities": { + "wasm": true, + "cmd": true, + "windows.command_dev": true, + "windows.sandbox_stub": true, + "debug.job_object_suspend": true + }, + "security_note": "not a managed-untrusted sandbox backend" +} +``` + +### 7.4 Wasmtime task runner + +Each running virtual thread/task maps to: + +```text +Wasmtime Engine +compiled module cache +Store +Linker with Disasmer imports +task entrypoint dispatcher +fuel/epoch/resource limits +debug probe table +VFS overlay handle +handle table +``` + +`ThreadHostState` should contain: + +```rust +struct ThreadHostState { + tenant_id: TenantId, + project_id: ProjectId, + virtual_process_id: VirtualProcessId, + virtual_thread_id: VirtualThreadId, + task_id: TaskId, + bundle_digest: Digest, + debug_epoch: Option, + vfs_overlay: VfsOverlayHandle, + handles: HandleTable, + limits: ResourceLimits, + coordinator: CoordinatorClient, + node_store: NodeStore, + command_runner: Arc, +} +``` + +### 7.5 Command runner + +The command runner must abstract platform differences. + +```rust +trait CommandRunner { + async fn start(&self, spec: CommandSpec, ctx: CommandContext) -> Result; + async fn poll(&self, handle: CommandHandle) -> Result; + async fn suspend_for_debug(&self, handle: CommandHandle, epoch: DebugEpochId) -> Result<()>; + async fn resume_from_debug(&self, handle: CommandHandle, epoch: DebugEpochId) -> Result<()>; + async fn terminate(&self, handle: CommandHandle) -> Result<()>; +} +``` + +Linux implementations: + +```text +PodmanRootlessCommandRunner +LocalProcessGroupCommandRunner for development +``` + +Windows implementations: + +```text +WindowsCommandDevRunner +WindowsSandboxRunnerStub +``` + +Do not offer `WindowsCommandDevRunner` as secure managed compute for untrusted public workloads. + +### 7.6 Linux Podman backend + +Linux MVP should use rootless Podman. + +Responsibilities: + +```text +build image from Containerfile/Dockerfile +cache image by environment digest +mount /src, /work, /cache, /out or VFS paths +run command with resource limits where practical +capture stdout/stderr +pause/unpause or suspend process tree for debug epoch +publish outputs into VFS overlay +``` + +Default mounts: + +```text +/src source snapshot or bind mount +/work task-local writable overlay +/cache node-local dependency cache keyed by env/project +/out artifact staging path +``` + +### 7.7 Windows backend + +For the public-launch MVP, Windows should support an attached node that can run build commands in a controlled workspace. + +Minimum Windows backend: + +```text +create per-task temp workspace +materialize source snapshot into workspace or bind local checkout when safe +set environment variables from normalized env +run command as non-admin service user where possible +capture stdout/stderr +track process tree with Job Object-like lifecycle management +stage artifacts into VFS overlay +support best-effort debug suspend/resume +``` + +Windows sandboxing should be behind an interface but not claimed as production isolation until implemented and tested. + +Potential future backend options: + +```text +Windows Sandbox CLI backend +AppContainer / Less-Privileged AppContainer backend +Hyper-V isolated runner backend +Windows container backend, if project needs it +``` + +### 7.8 Logs + +Logs are both a UX feature and an abuse vector. + +Node should: + +- stream recent log chunks to coordinator; +- keep larger logs node-local unless `sync()` or retention policy requires upload; +- redact configured secrets before sending; +- enforce per-task and per-process log byte caps; +- include task id, virtual thread id, sequence number, timestamp, and stream name. + +Log event: + +```json +{ + "tenant_id": "tenant_abc", + "process_id": "vp_123", + "thread_id": "vt_456", + "task_id": "task_789", + "stream": "stderr", + "seq": 42, + "bytes": "...", + "truncated": false +} +``` + +--- + +## 8. Coordinator + +### 8.1 Coordinator responsibilities + +The coordinator should provide: + +```text +auth/session handling; hosted OIDC through private module +project registry +node enrollment and identity +node heartbeats +node capability registry +virtual process lifecycle +task placement +bundle registry +debug session registry +Debug Epoch coordination +VFS manifest metadata for active processes +artifact metadata for active processes +hosted/community policy hooks and optional private quota ledger +operator panel state/events scaffold +rendezvous for node-to-node QUIC +web dashboard +CLI API +VS Code/DAP API +``` + +It should not run arbitrary user commands. + +### 8.2 Coordinator state split + +The coordinator needs two state layers in the MVP. + +Persistent Postgres state: + +```text +users / tenants / memberships if hosted module is enabled +projects +node enrollment records +node public keys / credentials / revocations +project-scoped source-provider configuration +bundle registry entries worth reusing +persistent environment metadata/cache hints +hosted/community plan or policy records in the private module +longer-lived audit records where needed +``` + +In-memory runtime state: + +```text +active virtual processes +virtual threads +pending task queues +live node sessions and heartbeats +scheduler placements +Debug Epochs +recent logs/events +VFS manifest metadata for active processes +artifact metadata and locations for active processes +operator panel snapshots/events for active processes +``` + +For the MVP, if the coordinator restarts, active virtual processes restart or fail cleanly. Do not persist every process/thread/log/VFS row merely to survive coordinator restarts. Persistence is for product identity and project continuity, not durable runtime recovery. + +### 8.3 Store interfaces and indicative persistent tables + +Define a persistent store trait for things that must survive restart: + +```rust +trait CoordinatorPersistentStore { + async fn create_tenant(...); + async fn create_project(...); + async fn register_node_identity(...); + async fn revoke_node_credential(...); + async fn create_bundle(...); + async fn save_environment_metadata(...); + async fn save_source_provider_config(...); + async fn authorize_persistent_object(...); +} +``` + +Define a separate runtime-state trait that is in-memory for the MVP: + +```rust +trait CoordinatorRuntimeState { + async fn create_virtual_process(...); + async fn create_virtual_thread(...); + async fn update_thread_state(...); + async fn create_debug_epoch(...); + async fn publish_vfs_manifest(...); + async fn publish_artifact_metadata(...); + async fn append_recent_log_event(...); + async fn reserve_runtime_quota(...); +} +``` + +Indicative persistent Postgres tables: + +```sql +users( + id, + authentik_subject nullable, + email nullable, + created_at, + suspended_at +) + +tenants( + id, + slug, + plan, + created_at +) + +tenant_members( + tenant_id, + user_id, + role +) + +projects( + id, + tenant_id, + name, + visibility, + created_at +) + +nodes( + id, + tenant_id, + project_id nullable, + display_name, + os, + arch, + trust_label, + created_at, + revoked_at +) + +node_capabilities_last_seen( + node_id, + capability, + value, + reported_at +) + +node_credentials( + node_id, + public_key, + cert_fingerprint nullable, + expires_at nullable, + revoked_at +) + +bundles( + id, + tenant_id, + project_id, + digest, + manifest_json, + created_at +) + +environments( + id, + tenant_id, + project_id, + bundle_id nullable, + name, + kind, + digest, + requirements_json, + recipe_manifest_json, + created_at +) + +source_provider_configs( + id, + tenant_id, + project_id, + provider, + config_json, + created_at +) + +audit_events( + id, + tenant_id, + project_id nullable, + actor_id nullable, + event_kind, + object_id nullable, + event_json, + created_at +) +``` + +Runtime-only structures may look like the old tables conceptually, but they should be ordinary in-memory maps/queues first: `virtual_processes`, `virtual_threads`, `debug_epochs`, active `vfs_manifests`, artifact locations, recent logs, and operator panel snapshots. + +### 8.4 Tenant safety invariant + +Every row that belongs to user content should include `tenant_id`. Every API should authorize by tenant and object relationship. + +Required tests: + +```text +user A cannot list user B nodes +user A cannot attach to user B process +user A cannot read user B logs +user A cannot read user B artifact metadata for active processes +user A cannot request debug memory from user B process +node A cannot claim tenant B process +node A cannot publish artifact for tenant B +``` + +### 8.5 Node enrollment + +Flow: + +```text +1. User opens coordinator UI or CLI. +2. User creates a node enrollment token for a project or tenant. +3. Token has short TTL and may include allowed capability constraints; node OS, architecture, and most capabilities are auto-detected at attach time. +4. Node CLI starts with token. +5. Node generates keypair. +6. Node calls enrollment endpoint. +7. Coordinator validates token and policy. +8. Coordinator creates node identity/certificate. +9. Node stores credential locally. +10. Node opens persistent session to coordinator. +``` + +Enrollment token example: + +```json +{ + "tenant_id": "tenant_abc", + "project_id": "proj_123", + "expires_at": "...", + "allowed_capabilities": [ + "wasm", + "cmd", + "container.podman.rootless" + ], + "auto_detect": ["os", "arch", "env", "source_providers"], + "overrides_allowed": true +} +``` + +### 8.6 Scheduler + +The MVP scheduler should be simple, deterministic, and locality-aware. +It should also be an easily replacible module in case the user wants different scheduling, or perhaps they have limitless resources (e.g. cloud backed) + +Inputs: + +```text +task environment requirements +node capabilities +node health +source snapshot locations +environment cache locations +artifact locations +node current load +tenant/project policy +quota availability +debug affinity / restart affinity +``` + +Scoring: + +```text +score(node) = + + environment compatible + + source snapshot already local + + environment digest cached + + dependency cache likely warm + + needed artifacts local + + same node as previous failed attempt when restart is desired + - current load + - expected bytes to transfer + - recent failures + - policy warnings +``` + +Placement should prefer the local node if it can run the task and already has the repo. + +### 8.7 State machine + +Virtual process states: + +```text +CREATED +STARTING +RUNNING +FREEZE_REQUESTED +FROZEN +RESUMING +COMPLETED +FAILED +CANCELLED +``` + +Virtual thread states: + +```text +PENDING +PLACED +STARTING +RUNNING +WAITING +COMMAND_RUNNING +FREEZE_REQUESTED +FROZEN +COMPLETED +FAILED +CANCELLED +RESTARTING +``` + +Debug epoch states: + +```text +REQUESTED +FREEZING +FROZEN +RESUMING +RESUMED +FAILED +``` + +--- + +## 9. Transport and networking + +### 9.1 Transport choices + +Use Quinn for QUIC in the MVP, behind a Rust abstraction. + +```rust +trait Transport { + async fn connect_node(&self, endpoint: Endpoint, identity: NodeIdentity) -> Result; + async fn accept_node(&self) -> Result; + async fn open_stream(&self, kind: StreamKind) -> Result; + async fn datagram(&self, msg: Datagram) -> Result<()>; +} +``` + +The abstraction is important because Rust bindings and platform packaging may be rough. Keep QUIC-specific details in `disasmer-transport`. + +### 9.2 Coordinator sessions + +The coordinator session carries: + +```text +node heartbeat +capability updates +task start/stop messages +debug epoch messages +VFS manifest publication +artifact metadata for active processes publication +log event stream +rendezvous endpoint exchange +quota updates +``` + +For development, it is acceptable to bootstrap auth over HTTPS and then use QUIC for persistent sessions. + +### 9.3 Node-to-node sessions + +Node-to-node sessions carry: + +```text +blob chunk requests +artifact reads +source snapshot delta transfer +VFS chunk transfer +debug lazy memory/page reads where appropriate +``` + +They should not carry unauthenticated traffic. Every stream must include: + +```text +tenant id +project id +process id when applicable +authorized object id +fencing token / epoch where applicable +request id +quota accounting key +``` + +### 9.4 Rendezvous without relay + +Coordinator-aided connection: + +```text +1. Node A needs artifact from Node B. +2. A asks coordinator for artifact locations. +3. Coordinator authorizes A and returns candidate endpoints for B. +4. Coordinator notifies B to expect A. +5. A and B attempt direct QUIC connection. +6. If connection succeeds, data moves node-to-node. +7. If connection fails, scheduler avoids this transfer or asks user to sync/export through configured durable storage. +``` + +No silent bulk relay through the coordinator. + +### 9.5 Failure handling + +If direct node-to-node connection fails: + +- do not fall back to coordinator bulk transfer by default; +- mark the object location as unreachable from the requesting node; +- choose a different placement if possible; +- if impossible, fail with a clear action: + +```text +Artifact is on node windows-1, but linux-1 cannot establish direct QUIC. +Run an artifact receiver node, configure an explicit external storage/export task, or run the dependent task on windows-1. +``` + +--- + +## 10. Environment system + +### 10.1 Discovery + +Scan default paths: + +```text +envs//Containerfile +envs//Dockerfile +envs//flake.nix +envs//flake.lock +``` + +Normalize to: + +```json +{ + "name": "linux", + "kind": "container", + "digest": "sha256:...", + "recipe": "envs/linux/Containerfile", + "context": "envs/linux", + "requires": { + "os": "linux", + "arch": "x86_64" + }, + "capabilities": { + "cmd": true, + "network": false + } +} +``` + +### 10.2 Containerfile MVP + +Containerfile is the MVP path. + +Linux: + +```text +Containerfile -> Podman build -> local image id -> run task command +``` + +Windows: + +```text +Containerfile discovered and hashed +Windows backend may treat it as a declared environment contract/stub +actual command execution uses WindowsCommandDevRunner for attached user nodes +``` + +This is intentionally not marketed as secure Windows container isolation. + +### 10.3 Nix plugin + +Nix should be present as a plugin because the architecture already expects pluggable environment resources, and Nix is useful for development reproducibility. + +MVP Nix support can be: + +```text +discovery of flake.nix and flake.lock +hashing into environment digest +optional local materialization on nodes with Nix installed +cache location reporting +not required for the flagship launch demo +``` + +### 10.4 Environment materialization cache + +Cache key: + +```text +env digest + backend kind + platform + Disasmer env ABI version +``` + +Cache record: + +```json +{ + "env_digest": "sha256:...", + "node_id": "node_123", + "backend": "podman-rootless", + "materialized_ref": "localhost/disasmer/env@sha256:...", + "status": "ready", + "last_used_at": "..." +} +``` + +### 10.5 Environment security policy + +Default task environment policy: + +```text +network disabled unless explicitly granted +host filesystem inaccessible except declared VFS mounts +secrets absent unless explicitly granted +no privileged containers +no Docker/Podman socket mount +read-only source mount when possible +write access only to /work and artifact staging +logs capped +process lifetime capped by policy +``` + +For user-attached nodes, Disasmer should still enforce its policy, but documentation must be honest that the node owner controls the machine and can bypass local restrictions. + +--- + +## 11. VFS, artifacts, and latency + +### 11.1 Core rule + +```text +Move metadata eagerly. +Move bytes lazily. +Run work where the bytes already are. +``` + +This should be treated as an engineering invariant, not a performance optimization later. + +### 11.2 Mount model + +Default virtual filesystem: + +```text +/src or /vfs/project + read-only source/input snapshot + +/work or /vfs/work + task-local writable overlay + +/vfs/artifacts + published artifact namespace + +/vfs/shared + optional explicit shared namespace, probably not launch-critical +``` + +### 11.3 Source snapshots and source-provider modules + +Source support should be pluggable. Git is the default included source-provider module, not a hard dependency of the VFS core. + +A source-provider module must be able to produce a `SourceSnapshot` manifest such as: + +```text +provider name and version +source identity +base revision or content root +sub-resource state if applicable +changed-file / dirty overlay manifest +included untracked or generated inputs +ignored-file policy +``` + +For Git specifically, the default module should model a source snapshot as: + +```text +repo identity +commit hash +submodule state +dirty overlay manifest +included untracked files +ignored-file policy +``` + +Local node behavior: + +```text +if task runs on node with checkout: + bind mount checkout read-only where safe + materialize dirty overlay if needed +else: + fetch missing content through the enabled source provider or by content hash +``` + +Do not tar and upload the entire repo by default. Do not require users who use another source system to carry Git dependencies in their runtime. + +### 11.4 Dirty overlay + +Dirty overlay manifest: + +```json +{ + "base_commit": "abc123", + "files": [ + { + "path": "src/main.rs", + "mode": "100644", + "digest": "sha256:...", + "size": 12345 + } + ], + "deleted": ["old/file.rs"] +} +``` + +Dirty bytes move only if the chosen node does not already have them. + +### 11.5 VFS overlay commits + +Each task has a writable overlay. `flush()` creates a manifest delta: + +```json +{ + "process_id": "vp_123", + "thread_id": "vt_456", + "parent_epoch": 4, + "epoch": 5, + "created": [ + { + "path": "/vfs/artifacts/linux-x64/app", + "digest": "sha256:...", + "size": 9912321, + "locations": ["node_linux_1"] + } + ], + "modified": [], + "deleted": [] +} +``` + +The coordinator stores the manifest and locations. It does not receive the bytes unless a configured durability/export policy requires it. + +### 11.6 `flush()` semantics + +`flush()` means: + +```text +close current task write epoch +hash changed files/chunks +publish VFS manifest delta +publish artifact metadata for /vfs/artifacts changes +make changes visible to downstream tasks according to VFS consistency rules +return after metadata is accepted by coordinator +``` + +`flush()` should not: + +```text +upload large artifacts by default +replicate to other nodes by default +export to local dist by default +route bytes through coordinator +``` + +### 11.7 `sync()` and explicit artifact movement semantics + +`sync()` means: + +```text +apply an explicit export/transfer/durability policy chosen by the program or CLI +verify referenced bytes still exist on at least one node or explicit store +move bytes directly between nodes or to a user-configured external destination +update artifact metadata for active processes with the result +``` + +`sync()` should not imply a default coordinator artifact store. By default, artifacts and other large data are nearly ephemeral: they live on nodes for as long as a task, downstream consumer, export, restart, or configured cache/GC policy needs them. They may linger long enough for practical task restart, but unsynced node-local artifacts are not a durability guarantee. + +Example policies: + +```rust +// Ask an attached/local receiver node to pull bytes into ./dist. +artifact::export_to_node(dist, receiver::local("dist")).await?; + +// Send bytes to a user-provided storage integration implemented by project code. +artifact::copy_to(outputs, storage::s3("release-bucket")).await?; + +// Keep artifacts available on the producing node long enough for likely restarts. +artifact::retain(outputs, RetainPolicy::BestEffort { min_ttl: Duration::from_hours(24) }).await?; +``` + +MVP should implement at least: + +```text +metadata-only flush +node-local ephemeral artifact/blob cache +explicit export to an attached/local receiver node +best-effort retention/GC knobs +clear errors when bytes have been garbage-collected or are unreachable +``` + +Users who want artifacts on their own machine should either run a receiver node there or write/use an explicit storage/export integration. The coordinator should keep artifact metadata for active processes, not act as the default artifact data store. + +### 11.8 Artifact lifecycle + +Artifact states: + +```text +PUBLISHED_METADATA +AVAILABLE_ON_NODE +RETAINED_BEST_EFFORT +EXPORTED +DURABLE_EXTERNAL +EXPIRED +DELETED +``` + +To allow producing artifacts without much effort from the user, the control pane could provide a download button through the immediate-mode UI, which will stream the (best-effort retained) artefact from the node that retains it. "Best effort" and usage tier limits apply as normal, but it should be an error to create a download link to an artifact that can't be downloaded within the limits in the first place. Download links should be secure and not just allow anyone that guesses the correct link to download any artifact. + +Artifact metadata: + +```json +{ + "artifact_id": "art_123", + "path": "/vfs/artifacts/linux-x64/app", + "digest": "sha256:...", + "size": 9912321, + "producer_thread_id": "vt_linux", + "locations": [ + { "kind": "node", "node_id": "node_linux_1" } + ], + "durability_state": "PUBLISHED_METADATA", + "retention": "best_effort_node_gc" +} +``` + +### 11.9 Local-first acceptance test + +For a local Linux build from an existing Git checkout: + +- no full-repo tarball is created; +- no source bytes are uploaded to coordinator; +- compiler file reads are local; +- build cache reads are local; +- artifact bytes remain local after `flush()`; +- coordinator receives only metadata, logs within caps, and debug/session events; +- explicit export/transfer code is the first operation allowed to move final artifact bytes for durability or user-visible export. + +--- + +## 12. Task execution lifecycle + +### 12.1 Spawn flow + +```text +1. Guest calls spawn::task(compile).arg(...).env(env!("linux")).start().await. +2. Wasm guest invokes disasmer.spawn.start host import. +3. Node host validates current task capability. +4. Host serializes TaskSpec with task id, env id, args, handles, VFS base epoch. +5. Coordinator creates virtual_thread row in PENDING state. +6. Scheduler chooses node. +7. Coordinator sends StartTask to chosen node. +8. Node fetches or locates bundle. +9. Node materializes environment if needed. +10. Node creates VFS overlay. +11. Node instantiates Wasmtime module or command wrapper. +12. Node calls named task entrypoint. +13. Thread becomes visible in debugger and coordinator UI. +``` + +### 12.2 Join flow + +```text +1. Parent awaits TaskHandle.join(). +2. Parent task parks or yields. +3. Child completes with serialized result or error. +4. Result may contain handles such as Artifact. +5. Coordinator records completion. +6. Parent is woken. +7. Parent receives decoded result. +``` + +### 12.3 Command flow inside task + +```text +1. Guest task calls cmd!(...).run().await. +2. Host import validates command capability. +3. Node command runner starts process in task environment. +4. Task enters COMMAND_RUNNING state. +5. Logs stream to node and coordinator under caps. +6. Debug Epoch requests suspend controlled process tree. +7. Command exit status is returned to guest. +8. Guest decides whether to publish artifacts. +``` + +### 12.4 Failure behavior + +MVP rules: + +```text +Task trap -> task FAILED, process may enter debug break depending policy. +Command nonzero exit -> task receives error, can handle or fail. +Node lost with non-replicated running state -> virtual process FAILED. +Node lost after flushed but unsynced artifact -> artifact may be unavailable. +Node lost after synced artifact -> artifact survives according to sync policy. +``` + +Do not pretend non-replicated state is recoverable. + +### 12.5 Restart flow + +```text +1. User edits source. +2. VS Code triggers disasmer build. +3. New bundle is produced. +4. User selects Restart Failed Task or Restart Selected Virtual Thread. +5. DAP/coordinator checks task compatibility hash. +6. Runtime stops old task if needed. +7. Unflushed overlay changes are discarded unless explicitly preserved. +8. New task starts with original args, same env, chosen VFS checkpoint. +9. Breakpoints are rebound. +10. Other tasks remain alive only if compatibility checks pass. +``` + +If compatibility fails: + +```text +Task signature changed; restarting whole virtual process is required. +``` + +This capability is important to advertise in any demo(s). + +--- + +## 13. Debugging + +### 13.1 Design goal + +The user should experience Disasmer as one ordinary debug target: + +```text +Process: launch-build-demo +Threads: + 1 main + 2 compile linux-x64 + 3 compile windows-x64 + 4 package +``` + +The debugger should not force the user to understand node IDs, QUIC links, VFS manifests, or environment cache internals unless they open the Disasmer inspector. + +### 13.2 Use a custom DAP adapter + +Use `disasmer-dap` as the VS Code debug adapter. + +Why: + +- DAP is the right integration point for VS Code. +- The target is not one native process; it is a virtual process spanning nodes. +- Disasmer must control the thread list, stop reasons, restart commands, artifact views, and all-stop semantics. +- LLDB can be a future internal helper, but it should not own the UX. + +### 13.3 Debug components + +```text +VS Code extension + launches disasmer-dap and contributes commands/views + +disasmer-dap + speaks DAP to VS Code + speaks Disasmer debug protocol to coordinator + maps DAP process/thread/stack/variables to virtual model + +coordinator debug service + owns debug sessions and Debug Epochs + broadcasts freeze/resume + aggregates thread state + +node debug agent + sets breakpoints/probes + freezes/resumes Wasmtime tasks + suspends/resumes controlled command processes + captures stacks/locals/handle state +``` + +### 13.4 Debug Epoch protocol + +When a breakpoint is hit: + +```text +1. Node detects breakpoint/trap/probe in virtual thread T. +2. Node reports BreakHit(process, thread, location) to coordinator. +3. Coordinator creates DebugEpoch E. +4. Coordinator marks process FREEZE_REQUESTED. +5. Coordinator sends Freeze(E) to all nodes hosting threads for the process. +6. Wasm tasks stop at current probe/safepoint or next safepoint. +7. Native command tasks are suspended using backend-specific process-tree control. +8. VFS overlay manifests are pinned. +9. Memory/handle snapshots are pinned copy-on-write where applicable. +10. Nodes report Frozen(E, thread states). +11. Coordinator marks epoch FROZEN. +12. DAP emits stopped event for all threads. +``` + +Resume: + +```text +1. User presses Continue. +2. DAP sends Continue(E) to coordinator. +3. Coordinator sends Resume(E) to nodes. +4. Nodes unpin debug snapshots. +5. Wasm tasks continue. +6. Native command tasks resume. +7. Coordinator marks epoch RESUMED. +``` + +### 13.5 Safepoints and breakpoints + +MVP should use layered breakpoint support: + +#### Layer 1: explicit debug probes + +`#[disasmer::task]` and `#[disasmer::main]` generate probes at: + +```text +task entry +before/after spawn +before/after join +before/after cmd run +before/after flush/sync +await boundaries +explicit disasmer::debug::probe!() calls +``` + +This provides reliable all-stop behavior early. + +#### Layer 2: source-line breakpoints + +The build pipeline maps source line breakpoints to probe points and, where possible, Wasm code offsets using debug metadata. + +Launch acceptance should include: + +```text +breakpoint in main() +breakpoint in compile() +breakpoint before/after cmd!().run() +breakpoint before artifact publish/flush +breakpoint in package() +``` + +#### Layer 3: richer DWARF/LLDB integration + +After the DAP skeleton works, improve locals/stacks by reading Rust/Wasm debug metadata. This is important, but it should not block the all-stop architecture. + +### 13.6 Variables and locals + +Launchable debugger variable support should include: + +```text +task arguments +return values +Target enums and simple structs +Artifact handles +SourceSnapshot handles +Blob handles +DAtomic values +DChannel status +current VFS mounts +current command spec/status +recent stdout/stderr tail +``` + +For arbitrary Rust locals, use best-effort DWARF support where available. If a local cannot be inspected, the debugger should say so clearly and still show task args/handles. + +### 13.7 Native command tasks in debugger + +A native command is debugger-visible as part of the virtual thread, but MVP does not need to debug inside `cargo` or the compiler itself. + +The command frame should show: + +```text +command argv +working directory +environment name +node id in inspector only +start time +status +exit code when done +recent stdout/stderr +resource limits +``` + +When the virtual process is frozen, controlled native command processes should be suspended. If the backend cannot safely suspend a command, the node must report failure and the debugger must show a clear error rather than claiming all-stop succeeded. + +### 13.8 DAP features required for launch + +Required: + +```text +initialize +launch / attach +setBreakpoints +configurationDone +threads +stackTrace +scopes +variables +continue +pause +next / step-over at probe granularity +disconnect +stopped events +continued events +output events +custom restart-task request +``` + +Not required for launch: + +```text +watchpoints +reverse debugging +non-stop debugging +hot code replace +debugging arbitrary child process internals +``` + +### 13.9 Disasmer inspector + +Default debugger view remains normal. Add an optional inspector tree: + +```text +Virtual Process + Threads + compile linux-x64 -> node linux-1 -> env linux -> running/frozen + compile windows-x64 -> node windows-1 -> env windows -> running/frozen + VFS + epoch 4 + artifacts + Debug Epoch + id E + frozen nodes + Environments + linux digest cached on linux-1 + windows digest cached/stub on windows-1 + Transport + node links status +``` + +This helps explain the system without polluting the normal debug UX. + +--- + +## 14. VS Code extension + +### 14.1 Extension responsibilities + +The extension should: + +```text +watch envs/**/Containerfile +watch envs/**/Dockerfile +watch envs/**/flake.nix +watch envs/**/flake.lock +watch inputs/** +watch src/** +run disasmer build before debugging +launch/attach DAP session +show virtual threads in normal debug UI +show per-task logs +show artifact tree +show node connection status +provide env!("...") autocomplete +provide disasmer.toml diagnostics +provide restart selected/failed task commands +``` + +### 14.2 Launch configuration + +Example `.vscode/launch.json`: + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Disasmer Build", + "type": "disasmer", + "request": "launch", + "coordinator": "https://disasmer.example.com", + "project": "${workspaceFolder}", + "preLaunchTask": "disasmer: build", + "localFirst": true + } + ] +} +``` + +### 14.3 Editor diagnostics + +Diagnostics: + +```text +env!("name") missing +env discovered but incompatible with available nodes +Containerfile changed; bundle rebuild needed +large task argument warning +cmd! used in task without cmd capability +fs::sync missing for final artifact export warning +Windows backend is dev/stub isolation warning +``` + +### 14.4 VS Code views + +MVP views: + +```text +Disasmer: Nodes +Disasmer: Virtual Processes +Disasmer: Artifacts +Disasmer: Logs +Disasmer: Inspector +``` + +Operator panel preview can appear as: + +```text +Disasmer: Operator Panel Preview +``` + +But the full immediate-mode panel is second milestone. + +--- + +## 15. Hosted coordinator and product UX + +### 15.1 Hosted coordinator MVP + +Hosted coordinator pages: + +```text +Sign in +Projects +Project detail +Node attach instructions +Node list +Virtual process list +Virtual process detail +Logs +Artifacts +Debug session status +Operator panel placeholder/preview +Settings / quotas +``` + +### 15.2 Hosted Authentik/OIDC integration + +Use Authentik as the identity provider through OIDC in the private hosted module. The open-source/private coordinator should expose auth hooks and simpler local/team auth, but should not require the hosted OIDC stack. + +Implementation requirements: + +```text +authorization code + PKCE for web login +strict redirect URI allowlist +minimal scopes +short session lifetime +refresh handling if needed +logout/back-channel support later +CLI token issuance after web login +node enrollment tokens separate from user sessions +``` + +### 15.3 Community tier + +Community tier defaults: + +```text +1 active virtual process +tiny hosted Wasm control loop limit +no hosted native commands +no hosted containers +no hosted network from tasks +small logs +small metadata +small artifact retention +4 user-attached nodes allowed +operator panel access +``` + +The hosted control loop is useful for trying Disasmer and coordinating attached nodes, not for mining, scraping, compiling huge repos, or hosting files. + +### 15.4 Abuse controls as architecture + +Every resource-consuming action must pass through a hosted/community quota policy module before work starts. + +Quota dimensions: + +```text +active virtual processes +hosted Wasm fuel +hosted Wasm memory +hosted process wall-clock +API calls +node heartbeats +spawn attempts +log bytes +metadata bytes +artifact bytes +artifact downloads +debug memory reads +operator panel updates +rendezvous attempts +``` + +Pattern: + +```rust +quota.reserve(tenant, Resource::SpawnAttempt, 1).await?; +// perform operation +quota.commit(reservation).await?; +``` + +For streams, use incremental accounting and backpressure. + +### 15.5 Public input threat model + +Treat as hostile: + +```text +users +OAuth identities +nodes +programs +build files +Containerfiles +Git repos +logs +artifacts +operator panel text/events +debug requests +node capabilities +cache claims +``` + +### 15.6 Public launch security baseline + +Required before public launch: + +- Tenant ID on every object. +- Authorization check on every API endpoint. +- Negative cross-tenant tests. +- No hosted containers on community tier. +- No hosted native commands on community tier. +- No arbitrary network access from hosted Wasm. +- Rate limits on auth, APIs, rendezvous, logs, spawn, and UI events. +- Log and artifact size caps. +- Private artifacts by default. +- No public file hosting by default. +- Node identity signing/revocation. +- Task capabilities explicit. +- Debug attach requires project permission. +- Debug memory reads quota-limited and audited. +- Operator panel uses built-in widgets only. +- No custom HTML or JS in panels. +- No password fields in user-defined panels. +- No OAuth flows inside operator panels. + +--- + +## 16. Operator panel scaffold and second milestone + +### 16.1 MVP scaffold + +Even if the full panel is second milestone, the MVP should include: + +```text +ui model crate +panel snapshot table +panel event table +coordinator API namespace +web placeholder area +DAP/debug interaction policy +security restrictions +``` + +The first visible version can be a fixed coordinator dashboard showing: + +```text +process state +tasks +progress +logs +artifacts +Debug / Restart / Cancel buttons as control-plane actions +``` + +### 16.2 Immediate-mode second milestone + +The second milestone adds: + +```rust +let panel = ui::panel("Repo builder").await?; + +loop { + let action = panel.draw(|ui| { + ui.heading("Repo builder"); + ui.text("Build status"); + ui.task_table("Tasks", tasks.snapshot()); + ui.log_tail("Recent output", logs.tail()); + if ui.button("Restart failed").clicked() { + ui.emit(Action::RestartFailed); + } + }).await?; + + if let Some(action) = action { + handle(action).await?; + } +} +``` + +### 16.3 Panel security model + +MVP/second milestone rules: + +```text +built-in widgets only +no custom HTML +no custom JavaScript +all text escaped +external links restricted +no OAuth/password widgets +panel labeled as user-program-provided +rate-limited updates +state size capped +events typed and schema-checked +read-only while debugger has process frozen +control-plane actions may remain available while frozen +``` + +--- + +## 17. Security and capability model + +### 17.1 Capability-first execution + +Every task has a capability set. + +Example: + +```json +{ + "task": "compile", + "capabilities": { + "vfs.read": ["/src"], + "vfs.write": ["/work", "/vfs/artifacts/linux-x64"], + "cmd.run": true, + "network.outbound": false, + "secrets": [] + } +} +``` + +### 17.2 Coordinator authz checks + +Every coordinator API checks: + +```text +authenticated principal +principal tenant membership +object tenant id +project membership/role +operation permission +quota availability +object state compatibility +``` + +### 17.3 Node trust model + +Default rule: + +```text +A user's work runs only on that user's nodes, managed paid nodes, or explicitly shared team nodes. +``` + +For MVP: + +- do not mix tenants on user-provided nodes; +- do not trust user-provided nodes to produce truthful artifacts for other tenants; +- label artifacts with node provenance; +- expose trust label in inspector; +- eventually support reproducible repeated builds for higher trust. + +### 17.4 Bundle identity + +Bundles should be content-addressed and integrity-checked. + +Later signing can be added, but MVP should at least ensure: + +```text +coordinator records bundle digest +node verifies bundle digest before running +manifest records environment/resource digests +task specs reference bundle digest +artifact provenance references bundle/task/node +``` + +### 17.5 Secrets + +MVP secrets support should be minimal. + +Recommended launch stance: + +```text +no secrets in hosted/community tasks +secrets not shown in logs/UI/debug by default +explicit grant required per task/environment +short-lived scoped secret materialization +secret access audited +secret redaction in logs +``` + +For the first launch demo, avoid secrets entirely. + +### 17.6 Container/runtime hardening + +Linux Podman backend should default to: + +```text +rootless +no privileged containers +no host Docker/Podman socket mount +read-only source mount +writable task workdir only +limited network by default +seccomp/AppArmor/SELinux where available +resource limits where practical +``` + +Windows dev backend should be clearly scoped as user-attached-node execution, not public managed secure isolation. + +--- + +## 18. Implementation milestones + +This sequence avoids building a beautiful UI on top of a runtime that cannot yet prove the thesis. + +### Milestone 0: Repository and protocol spine + +Deliverables: + +```text +monorepo layout +shared IDs and protocol types +CoordinatorStore trait +Postgres persistent-store migrations skeleton +CLI skeleton +node skeleton +SDK skeleton +VS Code extension skeleton +DAP skeleton +example project skeleton +``` + +Acceptance criteria: + +```text +cargo workspace builds +CLI can print discovered project info +coordinator starts locally with Postgres for persistent project/node state and in-memory runtime state +node starts and registers fake heartbeat locally +VS Code extension can run a dummy DAP session +``` + +Key considerations: + +- Do not overdesign protocol versioning, but add a protocol version field immediately. +- Use stable IDs everywhere. +- Add tenant_id to data models from the first migration. + +### Milestone 1: Local single-node Wasm task execution + +Deliverables: + +```text +#[disasmer::main] +#[disasmer::task] +DisasmerArg derive +named task registry +Wasmtime runner +spawn/join on same node +small serialized args/results +basic logs +basic DAtomicU32 or equivalent demo handle +``` + +Acceptance criteria: + +```text +example local-one-node runs main task +main spawns child Wasm task +child returns serializable result +parent joins result +logs show per virtual thread +``` + +Considerations: + +- Keep the first task ABI tiny. +- Ban closures for MVP. +- Fail early on invalid args. + +### Milestone 2: Environment discovery and bundle generation + +Deliverables: + +```text +envs/** discovery +env!("name") compile/editor validation +disasmer build creates app.dis directory +bundle manifest +task manifest +environment manifest +source snapshot manifest placeholder +VFS seed manifest +``` + +Acceptance criteria: + +```text +adding envs/linux/Containerfile makes env!("linux") valid +missing env gives useful error +bundle digest changes when Containerfile changes +bundle digest changes when task ABI changes +``` + +Considerations: + +- Keep `disasmer.toml` optional. +- Make generated metadata visible and inspectable. + +### Milestone 3: VFS and artifacts + +Deliverables: + +```text +per-task VFS overlay +/vfs/project or /src mount +/vfs/work +/vfs/artifacts +fs::copy +fs::flush metadata publication +Artifact::from_path +fs::sync export-to-local-directory +content-addressed blob/chunk store +``` + +Acceptance criteria: + +```text +task writes artifact +flush publishes artifact metadata for active processes +parent joins Artifact handle +package task reads artifact handle +sync exports final artifact to dist/ +large artifact bytes do not go through coordinator in local mode +``` + +Considerations: + +- Build the metadata/byte split now. +- Ensure artifact path namespace is stable. +- Add tests proving `flush()` is metadata-only. + +### Milestone 4: Linux Podman command backend + +Deliverables: + +```text +Podman rootless env materialization +Containerfile build/cache by digest +cmd!(...).run() +stdout/stderr streaming +process limits/log caps +artifact staging from command output +basic process suspend/resume for debug epoch +``` + +Acceptance criteria: + +```text +Linux task runs cargo build in envs/linux/Containerfile +logs stream to CLI/coordinator +artifact is published through VFS +local source checkout is bind-mounted when task runs locally +``` + +Considerations: + +- Keep the first Containerfile expectations simple. +- Do not support arbitrary privileged containers. +- Do not mount the container engine socket. + +### Milestone 5: Coordinator/node split with hosted-ready persistent store + +Deliverables: + +```text +coordinator service +Postgres-backed persistent store +node enrollment token +node identity +node heartbeat +bundle registration +task placement +StartTask command over persistent session +logs/events over stream +local coordinator mode still works +``` + +Acceptance criteria: + +```text +coordinator runs separately from node +node attaches with token +disasmer run schedules task onto attached node +node executes task and reports completion +Postgres contains persistent project/node/bundle metadata; active process/thread/artifact metadata lives in memory +``` + +Considerations: + +- Use same code path for local and hosted as much as possible. +- Do not create a special “demo scheduler” that will be thrown away. + +### Milestone 6: Quinn transport and rendezvous + +Deliverables: + +```text +Quinn transport wrapper +node-to-coordinator persistent session if practical +node-to-node direct connection +rendezvous endpoint exchange +authenticated stream framing +blob/artifact chunk transfer protocol +no coordinator bulk relay +``` + +Acceptance criteria: + +```text +two nodes establish direct QUIC link +node A can request authorized artifact chunk from node B +failed direct connection produces clear error +coordinator never receives bulk artifact bytes by default +``` + +Considerations: + +- Keep HTTPS bootstrap if it makes auth simpler. +- Put transport behind traits for testability. +- Add trace logs for failed connectivity. + +### Milestone 7: Windows attached node backend + +Deliverables: + +```text +Windows node build/test +WindowsCommandDevRunner +per-task workspace +process/log capture +artifact staging +best-effort process tree lifecycle +Windows sandbox backend trait + stub +Forgejo Windows worker setup docs +``` + +Acceptance criteria: + +```text +Windows node attaches to hosted/local coordinator +scheduler places env!("windows") task on Windows node +Windows task runs cargo build or a simple command +artifact is published and joined by parent +VS Code/coordinator shows Windows virtual thread +``` + +Considerations: + +- Be explicit that this is not production-grade untrusted Windows sandboxing. +- Do not offer managed public Windows compute until isolation is real. + +### Milestone 8: All-stop debugging and DAP + +Deliverables: + +```text +custom DAP adapter +launch/attach +thread list +breakpoint setting +source/probe mapping +Debug Epoch coordinator +node freeze/resume +Wasm safepoints/probes +command process suspend/resume integration +stack/scopes/variables for task args and handles +stdout/stderr output events +restart selected/failed task command +``` + +Acceptance criteria: + +```text +VS Code F5 starts a virtual process +breakpoint in main stops all virtual threads +breakpoint in Linux compile task stops Windows task too +breakpoint in Windows task stops Linux task too +thread list is coherent +user can inspect Target arg and Artifact handle +continue resumes all tasks +task restart works after source edit +``` + +Considerations: + +- This is the riskiest milestone. Start early. +- Use probe-granularity stepping first. +- Make all-stop correctness more important than perfect DWARF locals. + +### Milestone 9: Local-first Git/source snapshots + +Deliverables: + +```text +git source provider module +commit/submodule/dirty overlay manifest +local checkout detection +bind-mount local checkout for local tasks +remote missing-content transfer by hash +scheduler locality scoring +latency regression tests +``` + +Acceptance criteria: + +```text +local Linux build from local checkout does not upload repo +remote Windows build receives only required source content +changing one file transfers only that file/overlay where possible +scheduler prefers node with source checkout and env cache +``` + +Considerations: + +- Keep source provider modular. +- Do not hardwire Git into the VFS core. + +### Milestone 10: Hosted coordinator launch hardening + +Deliverables: + +```text +private hosted Authentik/OIDC login +tenant/project UI +node attach UI +hosted/community quota policy module +authz middleware and private hosted OIDC integration +cross-tenant negative tests +community tier limits +rate limiting +log/artifact caps +admin kill switch +basic plan/policy flags, even if only community exists +``` + +Acceptance criteria: + +```text +public user can sign in +public user can create project +public user can attach own node +public user cannot run hosted native command/container +public user can run task on own node +cross-tenant tests pass +quota tests pass +``` + +Considerations: + +- Do not launch without abuse controls. +- Design quotas as first-class resource accounting. + +### Milestone 11: Launch demo polish + +Deliverables: + +```text +launch-build example +README quickstart +short screencast script +hosted landing page +install script/packages +VS Code extension packaging +error message pass +first-run diagnostics +artifact export UX +fixed coordinator dashboard +operator panel preview scaffold +``` + +Acceptance criteria: + +```text +fresh developer can follow quickstart +Linux node attach works +Windows node attach works +F5 demo works +breakpoint demo works +restart failed task demo works +artifact export works +README explains community tier honestly +``` + +Considerations: + +- Public launch users will try weird setups immediately. +- Error messages matter as much as features. +- Make the first demo resilient. + +--- + +## 19. Testing strategy + +### 19.1 Unit tests + +Required areas: + +```text +DisasmerArg derive allowed/rejected types +environment discovery +bundle digest stability +manifest generation +VFS path normalization +flush manifest generation +sync policy selection +hosted/community quota policy module +scheduler scoring +authz checks +protocol encoding/decoding +``` + +### 19.2 Integration tests + +Local integration tests: + +```text +single-node spawn/join +two Wasm tasks with DAtomic +task writes artifact and parent consumes it +cmd runner fake backend +Debug Epoch with two Wasm tasks +restart failed task with compatible bundle +restart fails with incompatible schema +``` + +Linux integration tests: + +```text +Podman build env +Podman run command +local checkout bind mount +artifact publish/export +process suspend/resume if supported +``` + +Windows integration tests: + +```text +node attach +command execution +log capture +artifact staging +process lifecycle cleanup +``` + +Network integration tests: + +```text +node-to-node QUIC connect +artifact chunk transfer +connection failure clear error +no coordinator bulk relay +``` + +### 19.3 End-to-end tests + +E2E test scenarios: + +```text +F5 local single node +hosted coordinator + attached Linux node +hosted coordinator + Linux + Windows nodes +breakpoint in Linux task all-stops process +breakpoint in Windows task all-stops process +restart failed Windows task +local-first no-upload test +community tier quota enforcement +cross-tenant forbidden access +``` + +### 19.4 Latency regression tests + +Add tests that fail if: + +```text +local source bytes are uploaded during local build +artifact bytes go to coordinator during flush +full repo tarball is created by default +large artifact is transferred before consumer/export/sync needs it +scheduler chooses remote node despite local compatible warm node without reason +``` + +### 19.5 Security tests + +Required negative tests: + +```text +user cannot read another tenant's process +user cannot attach debugger to another tenant's process +node cannot claim another tenant's task +node cannot publish artifact for unauthorized process +community hosted task cannot request cmd.run +community hosted task cannot request network +operator panel cannot submit custom HTML/JS +log cap truncates noisy task +quota rejects spawn storm +``` + +--- + +## 20. Documentation required for MVP + +### 20.1 User docs + +```text +README quickstart +What is Disasmer? +Install CLI +Install VS Code extension +Login to hosted coordinator +Attach a Linux node +Attach a Windows node +Create envs//Containerfile +Write first build program +Debug in VS Code +Publish artifacts with flush() +Export artifacts through an attached receiver node or explicit storage integration +Community tier limits +Security model for user-attached nodes +``` + +### 20.2 Engineering docs + +```text +architecture.md +protocol.md +task-abi.md +vfs.md +debugging.md +transport.md +security.md +operator-panel.md +windows-backend.md +``` + +### 20.3 Error messages as docs + +Invest heavily in errors: + +```text +missing env +node unavailable +environment incompatible +large task arg +host-only type in DisasmerArg +cmd used on coordinator +cmd capability missing +artifact bytes unavailable +QUIC direct connection failed +sync required for export +Debug Epoch freeze failed +restart incompatible after source change +community tier quota exceeded +``` + +--- + +## 21. Key risks and mitigations + +### 21.1 Risk: debugger is the hardest part + +Mitigation: + +- Build DAP skeleton early. +- Implement Debug Epochs before fancy UI. +- Use generated debug probes for reliable breakpoints first. +- Add DWARF/local richness incrementally. +- Define launch acceptance around real breakpoints in the demo code, thread list, task args, handles, stacks, all-stop, continue, and restart. + +Experiment result: `experiments/debugger-gate` has derisked the core VS Code debugging UX. It proves a custom DAP adapter can present a virtual process with `main` and task threads, bind ordinary VS Code gutter breakpoints to Wasmtime-backed Rust/Wasm source locations, hit breakpoints in multiple virtual tasks, support basic stepping/watch/variables, and keep the backend boundary shaped like a replaceable virtual-process runtime. Treat that experiment as a proof and reference, not as implementation foundation: it is intentionally hacky prototype code with hardcoded example-local variable mapping and a simplified local Wasmtime scheduler. + +### 21.2 Risk: Windows isolation is not real yet + +Mitigation: + +- Keep Windows as user-attached node execution only. +- Label backend as `windows-command-dev`. +- Do not offer public managed Windows compute in MVP. +- Keep sandbox backend interface ready. +- Investigate Windows Sandbox/AppContainer after public launch or before managed nodes. + +### 21.3 Risk: hosted community tier becomes compute abuse target + +Mitigation: + +- No community hosted containers. +- No community hosted native commands. +- Zero-cap hosted Wasm control loop only. +- Fuel, wall-clock, memory, logs, and metadata limits. +- Rate-limit every API and event stream. +- Require attached user node for real work. + +### 21.4 Risk: QUIC/NAT becomes launch blocker + +Mitigation: + +- Use coordinator control session for scheduling and metadata. +- Require direct node-to-node only when bytes must move. +- Prefer scheduling where bytes already are. +- Fail clearly when direct transfer is impossible. +- For launch demo, place Linux and Windows nodes in a connectivity setup you control. + +### 21.5 Risk: local-first goal gets lost + +Mitigation: + +- Add tests that fail on unwanted uploads. +- Instrument bytes moved per build. +- Show “bytes moved” in logs/dashboard. +- Make `flush()` metadata-only by default from day one. + +### 21.6 Risk: product sounds too ambitious + +Mitigation: + +- Demo one narrow workflow extremely well. +- Keep the public README focused on build systems. +- Keep architecture docs honest about non-goals. +- Do not claim managed secure compute until it exists. +- Emphasize open-source local-first runtime. + +--- + +## 22. Definition of done for the public-launch MVP + +The public-launch MVP is done when a clean machine can: + +```text +1. Install Disasmer CLI. +2. Install the VS Code extension. +3. Sign in to hosted coordinator through Authentik/OIDC. +4. Create or select a project. +5. Attach a Linux node. +6. Attach a Windows node. +7. Clone the launch-build demo repo. +8. Open it in VS Code. +9. See env!("linux") and env!("windows") recognized. +10. Press F5. +11. See one virtual process start. +12. See Linux and Windows virtual threads in VS Code. +13. Hit a breakpoint in a spawned task. +14. Observe all-stop Debug Epoch behavior. +15. Inspect task args, command status, logs, and artifact handles. +16. Edit source and restart only the failed/selected task. +17. Finish the build. +18. Export artifacts through an attached/local receiver node or explicit storage integration. +19. Confirm local-first behavior in logs/metrics. +20. Confirm hosted coordinator did not run arbitrary native commands. +``` + +--- + +## 23. Suggested implementation order inside each milestone + +When in doubt, build vertical slices rather than broad infrastructure. + +Recommended pattern: + +```text +1. Fake backend, real API. +2. Real backend, local only. +3. Real backend, coordinator split. +4. Real backend, hosted auth/quotas. +5. Polish only after the vertical slice proves the thesis. +``` + +Example for debugging: + +```text +1. DAP shows fake virtual process with fake threads. +2. DAP attaches to local one-node Wasm process. +3. Breakpoint probe freezes one Wasm task. +4. Debug Epoch freezes two Wasm tasks. +5. Debug Epoch freezes Wasm + command task. +6. Same flow across two nodes. +7. VS Code restart selected task. +8. Richer locals/stacks. +``` + +Example for VFS: + +```text +1. In-memory manifest. +2. Local disk overlay. +3. Artifact handle. +4. Metadata-only flush. +5. Export sync. +6. Node-to-node chunk fetch. +7. Explicit receiver-node export. +``` + +--- + +## 24. External technical references to keep nearby + +These are useful implementation references, not product dependencies: + +- Wasmtime Rust embedding API: https://docs.wasmtime.dev/api/wasmtime/ +- Wasmtime Component Model API: https://docs.wasmtime.dev/api/wasmtime/component/index.html +- Quinn repository and docs: https://github.com/quinn-rs/quinn +- Debug Adapter Protocol: https://microsoft.github.io/debug-adapter-protocol/ +- VS Code debugger extension guide: https://code.visualstudio.com/api/extension-guides/debugger-extension +- Authentik OAuth2/OIDC provider docs: https://docs.goauthentik.io/add-secure-apps/providers/oauth2/ +- Podman: https://podman.io/ +- Nix flakes: https://nix.dev/concepts/flakes.html +- Windows Sandbox CLI: https://learn.microsoft.com/en-us/windows/security/application-security/application-isolation/windows-sandbox/windows-sandbox-cli +- Windows AppContainer isolation: https://learn.microsoft.com/en-us/windows/win32/secauthz/appcontainer-isolation +- OWASP API Security Top 10 2023, unrestricted resource consumption: https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/ +- OAuth 2.0 Security Best Current Practice, RFC 9700: https://datatracker.ietf.org/doc/rfc9700/ +- NIST SP 800-190, Application Container Security Guide: https://csrc.nist.gov/pubs/sp/800/190/final + +--- + +## 25. Final guidance + +Do not shrink the ambition of the demo, but aggressively constrain the implementation semantics. + +The MVP should be ambitious in what the user sees: + +```text +one Rust build program +hosted coordinator +Linux + Windows nodes +source-level VS Code debugging +all-stop Debug Epochs +task restart +local-first artifacts +``` + +It should be conservative in what the runtime promises internally: + +```text +no transparent raw remote pointers +no live stack migration +no coordinator bulk data plane +no community hosted native compute +no fake Windows sandbox claims +no arbitrary child-process source debugging +no automatic survival of non-replicated node failure +``` + +That combination is what can make the launch credible: the visible experience is striking, while the implementation model remains honest enough to actually build. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d111b38 --- /dev/null +++ b/README.md @@ -0,0 +1,447 @@ +# Disasmer + +Disasmer is a distributed Wasm runtime for source-debuggable, local-first execution. The flagship workflow is a build written as Rust source code: one virtual process, many virtual threads/tasks, ordinary debugger controls, attached user nodes, and explicit artifact handling. + +The MVP shape is: + +```text +examples/launch-build-demo/envs/linux/Containerfile +examples/launch-build-demo/src/build.rs +``` + +```rust +use disasmer_sdk::env; + +let linux = env!("linux"); +``` + +`envs//Containerfile` or `envs//Dockerfile` defines an environment named ``. Source code references that environment by logical name with `env!("name")`, not by runner label or machine name. + +Bundle metadata is inspectable before launch: + +```bash +disasmer bundle inspect --project examples/launch-build-demo +``` + +The inspection output includes discovered environments, selected input digests, default source-provider choices, and the bundle identity. Container images are not embedded by default. + +The flagship demo lives in `examples/launch-build-demo`. Its build workflow is Rust source, not CI YAML, and its tasks return artifact/source handles rather than copying large bytes through task arguments. + +## Quickstart + +Prerequisites for the local MVP path are a Rust toolchain, Node.js for smoke +scripts, rootless Podman for Linux environment materialization, and VS Code when +debugging through the extension. + +Build the public workspace and install the local command binaries: + +```bash +cargo build --workspace +cargo install --path crates/disasmer-cli --bin disasmer +cargo install --path crates/disasmer-node --bin disasmer-node +cargo install --path crates/disasmer-coordinator --bin disasmer-coordinator +cargo install --path crates/disasmer-dap --bin disasmer-debug-dap +``` + +Install or run the VS Code extension from this checkout: + +```bash +code --extensionDevelopmentPath "$(pwd)/vscode-extension" examples/launch-build-demo +``` + +For a persistent local install, copy or symlink `vscode-extension` into the VS +Code user extension directory under a versioned folder such as +`disasmer.disasmer-vscode-0.1.0`, then restart VS Code. + +Inspect and run the flagship project: + +```bash +disasmer bundle inspect --project examples/launch-build-demo +disasmer run --local --project examples/launch-build-demo build +``` + +For explicit process-boundary inspection, run the local services yourself: + +```bash +disasmer-coordinator --listen 127.0.0.1:7999 +disasmer node attach --coordinator 127.0.0.1:7999 --enrollment-grant local-dev --public-key local-node-key +disasmer run --local --coordinator 127.0.0.1:7999 --project examples/launch-build-demo build +``` + +`disasmer run --local` starts a loopback coordinator and user-attached local +node for the run when no coordinator address is supplied. `disasmer run [entry]` +selects an entrypoint such as `build`; `--project` overrides the project +directory. When the CLI has a hosted login and no local override is selected, +`disasmer run` uses the hosted coordinator. Use `--local` or +`--coordinator ` to force local coordinator mode. + +In VS Code, open `examples/launch-build-demo`, start the `Disasmer: Launch +Virtual Process` configuration or press F5, inspect the Disasmer nodes, +processes, logs, artifacts, and inspector views, and use the ordinary debugger +controls for breakpoints, continue, pause, and restart. Artifact download +behavior is exercised by `node scripts/artifact-download-smoke.js`; final export +is explicit and should go through an attached receiver node or user-provided +storage integration rather than hidden coordinator storage. + +Cleanup for the local quickstart is ordinary process and artifact cleanup: stop +the coordinator process, stop attached node processes, remove any temporary +artifacts under `target/acceptance/`, and remove the local VS Code extension copy +or symlink if one was installed. + +## First-Run Diagnostics + +Use these messages as first checks before debugging infrastructure: + +- Missing nodes: attach a node with `disasmer node attach --coordinator ` or check the Disasmer Nodes view. +- Missing environments: add `envs//Containerfile` or `envs//Dockerfile`; the VS Code extension highlights unknown `env!("name")` references. +- Quota limits: hosted/community denials are returned before dispatch with a specific community tier reason. +- Unavailable artifacts: downloads fail before showing a link when retention, size, authorization, quota, or connectivity makes streaming impossible. +- Auth failures: browser login uses the device/browser flow, while agents and nodes use public-key identity and scoped enrollment grants. +- Failed debug freezes: all-stop reports the participant that could not freeze instead of claiming success. +- Source-provider capability gaps: source preparation stays pending until a node reports `SourceGit` or `SourceFilesystem`. + +## Repository Shape + +The public workspace contains the open-source contract layer: + +```text +crates/disasmer-core shared identities, policy traits, scheduling, VFS/artifacts +crates/disasmer-coordinator local coordinator state model +crates/disasmer-dap Debug Adapter Protocol adapter for VS Code/debug clients +crates/disasmer-macros #[disasmer::main] and #[disasmer::task] +crates/disasmer-node node backend interfaces and local node runtime +crates/disasmer-cli CLI command surface +crates/disasmer-sdk user-facing Rust SDK +scripts/verify-public-split.sh +``` + +Hosted-only policy code lives under `private/**`. The public split is verified by copying the repo without `private/**` and running the public workspace tests. + +## Public Release Dry Run + +The public release dry run uses `https://disasmer.michelpaulissen.com:9443` as the +default operator endpoint for public CLI login and VS Code hosted/dry-run +configuration. The deployment should be a real externally reachable service +that can be shared with selected users, not a loopback-only, local-only, or mock +deployment. During the dry run, the name is intentionally not published in +public DNS until the `disasmer.michelpaulissen.com` DNS record is deployed; once +that record is live, no resolver override is required. + +The default operator at `disasmer.michelpaulissen.com:9443` is the private hosted +coordinator from `private/hosted-policy`, with private hosted modules enabled. +The word `public` in this dry run describes the public Forgejo repository, +release assets, selected-user network reachability, and the public client +protocol used by released binaries. It does not mean deploying the standalone +open-source/public coordinator as the hosted operator. The public coordinator is +for local/self-hosted use; the dry-run operator must remain the private hosted +coordinator. + +The dry-run acceptance validates both coordinator implementations. The private +hosted coordinator is validated through the live default-operator deployment, +service smoke, and public-repository e2e. The standalone public/open-source +coordinator is validated separately from the filtered public repository and +public release binaries in a local/self-hosted coordinator smoke; that validation +does not make it the hosted default operator. + +Human browser login uses the nginx-served +`https://disasmer.michelpaulissen.com/auth/browser/start` path, which redirects +to the hosted Authentik account flow and returns to the CLI's localhost callback. +The `disasmer.michelpaulissen.com` website is deliberately barebones HTML with +no CSS for this dry run; layout and UX optimization belong to a later acceptance +phase. + +The dry-run public repository is a real public repository on the Forgejo +instance at `git.michelpaulissen.com`, produced from the filtered public tree +without `private/**` or `experiments/**`. Its Forgejo Release publishes compiled +assets for the supported public binaries and the VS Code extension package, so a +user can download the appropriate artifacts from Forgejo and start without +building from source. Repeating the release later on a fresh public domain and a +public GitHub release should be mechanically straightforward, but that +fresh-domain/GitHub publication is outside this dry run. + +Prepare the filtered public tree, release binary archive, VS Code extension VSIX, +checksums, and evidence manifest locally with: + +```bash +node scripts/prepare-public-release-dryrun.js +``` + +To embed controlled-resolution fallback instructions in the selected-user assets, +set one of these when DNS is not live yet: + +```bash +DISASMER_PUBLIC_DRYRUN_RESOLVER_INSTRUCTIONS= +DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY=" disasmer.michelpaulissen.com" +DISASMER_PUBLIC_RELEASE_DRYRUN_IP= +``` + +To publish the filtered snapshot to the Forgejo public repository, provide the +explicit remote and opt in to the push: + +```bash +DISASMER_PUBLIC_REPO_REMOTE=ssh://git.michelpaulissen.com:2222//.git \ +DISASMER_PUBLISH_PUBLIC_TREE=1 \ +node scripts/prepare-public-release-dryrun.js +``` + +The manual `Public release dry run assets` Forgejo workflow runs the same +preparation path for Linux and, when the intermittent `windows` runner is +online, for Windows. Upload the produced assets, including +`disasmer-vscode-*.vsix`, and +`public-release-manifest.json` to the Forgejo Release for the public repository. +The generated `DISASMER_PUBLIC_DRYRUN_GETTING_STARTED-*.md` asset is the +selected-user quickstart, and `DISASMER_PUBLIC_DRYRUN_INVITE-*.md` is the short +invite that can be shared with selected external users for installing the +binaries, resolving the public operator DNS name, and connecting to the default +operator. +The workflow artifacts are staging evidence; the actual Forgejo Release is only +accepted once those assets are attached to the public repository's Release. +Publish the Forgejo Release through the API after the filtered public tree has +been pushed: + +```bash +DISASMER_FORGEJO_TOKEN= \ +node scripts/publish-public-release-dryrun.js +``` + +The publisher infers the Forgejo owner and repository name from +`public-release-manifest.json`. Set `DISASMER_PUBLIC_REPO_OWNER=` and +`DISASMER_PUBLIC_REPO_NAME=` only to override that manifest value. + +Before publishing, the non-e2e preflight can verify that the manifest matches +the current commit, the filtered public branch points at the prepared tree, all +local assets exist, and checksums match: + +```bash +node scripts/public-release-dryrun-preflight.js +``` + +Selected external users, such as friends invited to the dry run, should be able +to download those assets from Forgejo, resolve +`disasmer.michelpaulissen.com` through public DNS or the supplied fallback +instructions, and get a realistic product experience without a source build or +private repo access. + +Once the real hosted service is deployed, record private deployment evidence +against that externally reachable service with: + +```bash +node private/hosted-policy/scripts/prepare-public-release-dryrun-deployment.js +DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer.michelpaulissen.com:9443 \ +DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL= \ +DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE= \ +node private/hosted-policy/scripts/public-release-dryrun-service-smoke.js +``` + +The deployment bundle includes `disasmer-hosted-service`, a systemd unit that +binds `0.0.0.0:9443`, and a deployment manifest. Installing and starting that +bundle on the external host is required before the service smoke can be accepted +as real dry-run evidence. + +After the public Forgejo Release, deployment, service smoke, public-operator +compatibility smoke, and standalone public-coordinator smoke have produced +evidence, run the public-repository dry-run e2e against the deployed operator: + +```bash +DISASMER_PUBLIC_RELEASE_DRYRUN_E2E=1 \ +DISASMER_PUBLIC_RELEASE_DRYRUN_SERVICE_ADDR=disasmer.michelpaulissen.com:9443 \ +DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_ISSUER_URL= \ +DISASMER_PUBLIC_RELEASE_DRYRUN_OIDC_CODE= \ +node scripts/public-release-dryrun-e2e.js +``` + +After the public-repository e2e dry run has produced evidence, run the final +consistency verifier: + +```bash +DISASMER_PUBLIC_RELEASE_DRYRUN_FINAL=1 \ +node scripts/public-release-dryrun-final-evidence.js +``` + +The public-repository e2e evidence is +`target/acceptance/public-release-dryrun-e2e.json`. It must record the Forgejo +public repository URL, release name, source commit, filtered public tree +identity, default operator endpoint, controlled service address, commands, tool +versions, and true checks for downloaded release assets, checksum verification, +clean public checkout, build or install from the public repo/assets, default +operator selection, browser or CLI login, user node attachment, flagship +workflow run, VS Code debugger behavior, logs, artifact metadata, and artifact +download or explicit export. + +## Coordinator State + +The coordinator separates durable project and identity state from live runtime state. Tenants, users, projects, node identities, credentials, source-provider configuration, durable service policy records, and explicit project permissions can be stored in Postgres. Active virtual processes, live virtual threads, scheduler state, debug epochs, ephemeral VFS manifests, and transient artifact locations remain in coordinator memory for the MVP and are not represented in the Postgres schema. + +## Local-First Builds + +Disasmer is designed so local source checkouts and large outputs stay node-local unless user code or policy explicitly moves bytes. + +`flush()` publishes metadata and visibility information. It makes produced artifacts visible to downstream tasks without implying durable coordinator storage. + +`sync()` is explicit. It may move bytes to another node or user-provided storage according to code or configured policy. + +User-provided storage/export integrations are ordinary project code or external +commands, such as publishing through a CLI. Disasmer records metadata and +coordinates capability, scheduling, and transfer decisions; it does not provide +or manage an explicit artifact-store feature as part of the MVP. + +Artifacts are best-effort retained on nodes by default. If unsynced node-local bytes are garbage collected or the retaining node is lost, the artifact becomes unavailable instead of silently recovering from coordinator storage. + +Artifact downloads are created only when current retention, size, authorization, and community tier accounting allow it. Download links are scoped to the tenant, project, process, artifact, actor, and policy context, expire after a bounded TTL, can be revoked, and streaming usage is charged before and during transfer. + +On Linux nodes, Containerfile and Dockerfile environments are materialized with rootless user Podman. The node runtime has a concrete runner path for `podman build` followed by `podman run`; tests use a recording runner, while real attached nodes can use the `std::process::Command` runner. A local build from an attached local checkout uses a node-local bind mount into the selected environment; the coordinator does not create a full-repo tarball or route compiler file reads. Command output is associated with the virtual thread that started it and can be staged into the VFS artifact namespace. `node scripts/wasmtime-node-smoke.js` builds the launch example for `wasm32-unknown-unknown`, runs its exported task through the node Wasmtime runtime, and verifies a Wasmtime task can invoke a native command through the node `disasmer.cmd_run` host import without moving command execution into the hosted coordinator. + +## Nodes And Trust + +Users attach their own nodes for real work. The hosted coordinator is a control plane for identity, rendezvous, scheduling metadata, debug sessions, logs, artifact metadata, and operator state. The community tier does not provide arbitrary hosted native commands or hosted containers. + +Agent and worker automation should use enrolled public-key identity. User OAuth or browser session tokens are not task credentials and should not be passed to nodes. + +```bash +disasmer login +disasmer login --browser +disasmer agent enroll --public-key +DISASMER_AGENT_PUBLIC_KEY= disasmer run build +disasmer node attach --enrollment-grant --public-key +disasmer-node --coordinator https://disasmer.michelpaulissen.com:9443 --tenant --project-id --node --public-key --enrollment-grant --worker --emit-ready +``` + +`disasmer login` uses a short-lived human device flow and does not ask users to paste long-lived secrets. `disasmer login --browser` opens the user's browser, sends the user through the hosted account flow, and completes through a local callback. `disasmer login --browser --plan` is the diagnostic JSON login plan path. Node attach exchanges a short-lived enrollment grant for a scoped long-lived node identity and then exits; a long-lived `disasmer-node --worker` process must be running for coordinator-side launches and DAP live-services debugging to place real command/container work on that node. + +Hosted agent public keys are project-scoped records: a signed-in user can +register, list, rotate, and revoke an agent key without giving the agent browser +or OAuth credentials. An enrolled agent can run CLI commands non-interactively +with `DISASMER_AGENT_PUBLIC_KEY`; the CLI records the agent public-key +fingerprint in its run plan instead of starting a browser flow. + +`disasmer node attach` auto-detects OS, architecture, command/container, +VFS/artifact, source-provider, and environment capabilities. `--cap ` is +available as an override for unusual local setups, not as required normal +configuration. + +For local process-boundary testing, the public workspace includes a TCP JSON-line coordinator service and a node runtime binary: + +```bash +cargo run -p disasmer-coordinator -- --listen 127.0.0.1:0 +cargo run -p disasmer-node -- --coordinator --project examples/launch-build-demo +``` + +The local smoke starts those as separate processes, keeps one node-to-coordinator JSON-line session open for attach, heartbeat, process start, reconnect, and completion, has the node run the demo test command, stages stdout as a VFS artifact, and records task completion metadata with the coordinator. The cancellation smoke keeps a node session open while a separate client asks the coordinator to cancel the task; the node polls task control, records a cancelled terminal event, and exits without reporting a false success. + +For self-hosted local clouds, trusted teams, or VPN deployments, run the public +coordinator on the chosen listen address and attach team-owned nodes with their +public keys. This path uses the open-source coordinator, scheduler, node +heartbeat, task metadata, retained-node downloads, and direct node-to-node export +planning without private hosted OIDC or community tier policy modules. +`node scripts/self-hosted-coordinator-smoke.js` exercises that public path with +two trusted Linux nodes. + +## Debugging + +The VS Code extension contributes the normal `disasmer` debug type, refreshes bundle metadata before launch, and starts the DAP adapter. The adapter presents one virtual process with virtual threads/tasks, all-stop breakpoint and pause behavior, thread stacks, task args, handles, command status, recent output, continue, and selected-task restart controls. Compatible source edits restart the selected task from a VFS checkpoint; incompatible edits reject selected-task restart with a whole virtual-process restart message. The F5 and DAP smokes run the adapter in local-services launch mode, so the debug variables include task completion recorded through the local coordinator/node process boundary. Disasmer-specific side views expose nodes, virtual processes, logs, artifacts, and inspector state alongside the normal debugger UI. + +In live-services mode, the DAP adapter starts the coordinator-side virtual +process without requiring a node at launch. When that process reaches a virtual +task with `Command` or container capability requirements, the coordinator places +that task on a capable node; an explicitly attached/running worker node polls +the coordinator for that assignment. + +The built-in operator panel preview is rendered by the coordinator from live +process, task, log, and artifact metadata. It uses typed widgets only, keeps +program UI events scoped and rate-limited, and keeps control-plane actions such +as debug, cancel, restart, and artifact download available when program UI events +are disabled. + +## Windows + +Windows node support uses the same node protocol and capability model as Linux. MVP Windows command execution is user-attached development execution, labeled `windows-command-dev`. Production-grade managed Windows sandboxing is behind an explicit backend stub until it is implemented and validated independently. When the acceptance report shows `windows_validation: "not-run"`, Windows support is best-effort and unvalidated for that release; the public smoke only verifies protocol, placement, metadata, and download behavior for a Windows-capable node identity. + +Real Windows validation runs through the manual Forgejo workflow +`.forgejo/workflows/windows-validation.yml` when the intermittent Windows runner is +online. That workflow records `windows_validation: "forgejo-windows-runner"` in +the acceptance report, runs `disasmer node attach` against a coordinator, starts a +Windows node process, executes a simple `windows-command-dev` command, publishes +artifact metadata, checks Windows placement, and verifies the debugger can show a +Windows virtual thread. + +## Source Providers + +Git is an optional source-provider module included by default. The VFS core depends on source-provider manifests and snapshot handles, not on Git internals. Non-Git source providers can implement the public source-provider interface and run snapshot preparation as node work instead of requiring coordinator filesystem access. + +Source preparation is scheduled as node work. The coordinator can return a +pending source-preparation status while waiting for any capable node, then assign +the work after a node reports `SourceGit` or `SourceFilesystem` capability; it +does not need checkout or provider access itself. + +Source-provider manifests are validated as public input and must declare a +local-first transfer policy: local source bytes stay node-local, the coordinator +does not receive source bytes by default, and remote preparation uses required +content or explicit snapshot chunks rather than a full-repo tarball. + +## Verification + +Run the public workspace checks: + +```bash +scripts/acceptance-public.sh +``` + +Or run the individual public checks: + +```bash +cargo test --workspace +node scripts/acceptance-report-smoke.js +node scripts/public-private-boundary-smoke.js +node scripts/release-blocker-smoke.js +node scripts/docs-smoke.js +node scripts/public-release-dryrun-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/quic-smoke.js +node scripts/flagship-demo-smoke.js +node scripts/dap-smoke.js +node scripts/prepare-public-release-dryrun.js +scripts/verify-public-split.sh +``` + +When the Forgejo Windows runner is online, run the manual `Windows validation` +workflow as the release Windows gate. The local equivalent on a Windows machine is: + +```powershell +$env:DISASMER_WINDOWS_VALIDATION = "forgejo-windows-runner" +node scripts/acceptance-report.js windows +cargo fmt --all --check +cargo test -p disasmer-node windows_backend_is_labeled_user_attached_dev_execution +node scripts/windows-runner-smoke.js +``` + +Run private hosted policy checks separately: + +```bash +scripts/acceptance-private.sh +``` + +The private hosted gate includes `public-operator-compat-smoke.js`, which starts +the hosted dry-run service locally and verifies that the public CLI and public +node runtime can attach, launch work through coordinator task assignment, and +publish debug/log/artifact metadata through the hosted operator protocol bridge. + +Both acceptance scripts write an environment report under `target/acceptance/` +with the commit SHA, toolchain versions, OS/kernel, Podman/Postgres discovery, +browser/VS Code harness metadata, and Windows-validation status. The report +records Podman as `available` only when rootless Podman is discoverable; if it is +missing or not rootless, Linux Podman backend behavior is marked `incomplete` +with the reason and the Podman backend smoke blocks acceptance with the same +incomplete reason. diff --git a/acceptance_criteria.md b/acceptance_criteria.md new file mode 100644 index 0000000..909f990 --- /dev/null +++ b/acceptance_criteria.md @@ -0,0 +1,489 @@ +# Disasmer MVP Acceptance Criteria + +Every criterion below must be covered by automated verification; when behavior crosses coordinator, node, CLI, DAP, browser, storage, or network boundaries, that verification should include integration coverage. The final product should be exercised as much as possible as if it were live infrastructure: separate services, real process boundaries, real auth boundaries, real quota decisions, realistic networking, realistic retention/GC, and realistic failure behavior. Because the main environment is NixOS, the live-infra shape can be credibly simulated with NixOS-based service and VM setups. + +Windows-specific runtime behavior does not need to be validated by the main acceptance suite for the MVP. The Windows implementation should still be best-effort correct, kept behind clean interfaces, avoid false sandboxing claims, and be structured so it can be validated and fixed independently later. + +These criteria intentionally avoid implementation detail. Architecture, protocol, and milestone details live in the MVP and feasibility documents. + +Important reading note: an item marked **Partial** or **Open** does not automatically mean new product code, a new feature, or even actual implementation work is required. It means the item must become a proven fact about the system. Many items describe facts that may already be true, or should have been fundamental invariants from the start, but still need stronger evidence at the required boundary. Before adding implementation, first check whether the fact is already true in the current system and prefer proving, documenting, or tightening that existing behavior over duplicating capability or overengineering a parallel mechanism. + +**Status Prefixes** + +- **Passed:** implemented with focused automated/source-scan coverage or a narrow source-level guard in the current tree; this is not final MVP acceptance until the full release gates run. +- **Partial:** modeled or covered at unit/smoke/source-scan level, but missing live boundary coverage, packaging coverage, hosted coverage, or end-to-end acceptance. +- **Open:** not implemented, not yet wired through the required boundary, or not yet covered by an appropriate acceptance gate. + +For release-blocker lists, **Passed** means the listed blocker condition is currently guarded as absent; **Open** means absence has not been proven by the release gate yet. + +--- + +## 1. Public product bar + +- [x] **Passed:** A user can run Disasmer locally with the open-source runtime, coordinator, node runtime, SDK, CLI, and VS Code extension. + +- [x] **Passed:** A user can use the hosted coordinator as a control plane without receiving arbitrary hosted compute. + +- [x] **Passed:** Public-release dry-run wording must not imply that `disasmer.michelpaulissen.com:9443` runs the standalone public/open-source coordinator. The default operator is the private hosted coordinator from `private/hosted-policy`; `public` refers to public repo/assets, selected-user reachability, and the public client protocol used by released binaries. + +- [ ] **Partial:** Public-release dry-run evidence validates both coordinator implementations: the private hosted coordinator as the live default operator, and the standalone public/open-source coordinator as the local/self-hosted coordinator shipped in the public release assets. Current public-tree/assets preparation is fresh for the current acceptance commit, but the live service, Forgejo Release, and e2e evidence must be rerun for that commit. + +- [x] **Passed:** A user can attach their own node and run actual build work on that node. + +- [x] **Passed:** The flagship build workflow is expressed as Rust source code, not CI YAML. + +- [x] **Passed:** Public-facing docs and repo text avoid naming any external launch forum or traffic source as a product goal. + +- [x] **Passed:** User-facing docs, UI, CLI, and plan language use `community tier` consistently. + +- [x] **Passed:** The public story remains: one virtual process, many virtual threads/tasks, ordinary source-level debugging, and local-first distributed execution. + +--- + +## 2. Repository and release split + +- [x] **Passed:** Public source can be produced by excluding `private/**` without breaking the open-source runtime, local coordinator, node runtime, CLI, SDK, or VS Code extension. + +- [x] **Passed:** Hosted-only Authentik/OIDC, community quota enforcement, hosted zero-capability limits, abuse tooling, and admin controls live in `private/**` or equivalent filterable modules. + +- [x] **Passed:** Public crates expose clear policy/auth interfaces that private hosted modules implement. + +- [x] **Passed:** The open-source coordinator remains useful for local clouds, trusted team use, and VPN deployments without hosted-service-only modules. + +- [x] **Passed:** Hosted and self-hosted nodes use the same node runtime code path; hosted restrictions are policy modules, not a separate node implementation. + +--- + +## 3. Coordinator state model + +- [x] **Passed:** Postgres is used only for data that must survive coordinator restart. + +- [x] **Passed:** Tenants, users, projects, node identities, credentials, source-provider configuration, and durable service policy records persist across coordinator restarts. + +- [x] **Passed:** Active virtual processes, live virtual threads, live scheduler state, debug epochs, ephemeral VFS manifests, and transient artifact locations may live in RAM for MVP. + +- [x] **Passed:** Coordinator restart requires active virtual processes to restart rather than pretending live execution survived. + +- [x] **Passed:** Nodes reconnect cleanly after coordinator restart and cannot continue an old virtual process under stale coordinator state. + +- [x] **Passed:** Persistent project state remains intact after coordinator restart. + +--- + +## 4. Auth and identities + +- [x] **Passed:** Browser login works through the hosted identity flow. + +- [ ] **Partial:** Public released binaries default to a real human browser/account flow for `disasmer login --browser`: source and local binary smoke cover the browser path, account/login handoff, and local callback behavior, and raw JSON login-plan output is opt-in or diagnostic. This remains partial until the current Forgejo Release binaries are published and rerun through the hosted service flow. + +- [x] **Passed:** CLI login works for human users without manually copying long-lived secrets. + +- [x] **Passed:** Agent/worker authentication supports public-key identity without requiring browser interaction on every run. + +- [x] **Passed:** Node enrollment converts a short-lived enrollment grant into a scoped long-lived node identity. + +- [x] **Passed:** User OAuth/session tokens are never passed to nodes as task credentials. + +- [x] **Passed:** Node identity, user identity, project identity, and task identity are distinct in coordinator authorization decisions. + +--- + +## 5. CLI behavior + +- [x] **Passed:** `disasmer run` defaults to the current project directory. + +- [x] **Passed:** `disasmer run --project ` runs a project other than the current directory. + +- [x] **Passed:** `disasmer run` uses the hosted coordinator implicitly when the user is logged in and no local override is selected. + +- [x] **Passed:** `disasmer run [entry]` selects a named entrypoint while preserving a sensible default when omitted. + +- [x] **Passed:** One project can define multiple entrypoints such as build, test, package, release, watch, or custom workflows. + +- [x] **Passed:** `disasmer node attach` auto-detects OS, architecture, environment backends, source providers, and ordinary node capabilities. + +- [x] **Passed:** `disasmer node attach --cap ...` remains available for explicit overrides and advanced cases. + +- [x] **Passed:** CLI commands are usable non-interactively by agents once their public-key identity is enrolled. + +--- + +## 6. Project and bundle model + +- [x] **Passed:** A project works without requiring a hand-written configuration file for the common case. + +- [x] **Passed:** `envs//Containerfile` or `envs//Dockerfile` defines an environment named ``. + +- [x] **Passed:** Source code can reference discovered environments with `env!("name")`. + +- [x] **Passed:** Missing environment references produce clear build/editor diagnostics. + +- [x] **Passed:** Bundle identity changes when Wasm code, task ABI, environment recipe, source-provider manifest, or selected inputs change. + +- [x] **Passed:** Bundles do not embed full container images by default. + +- [x] **Passed:** Generated environment and bundle metadata are inspectable by the user. + +--- + +## 7. Source-provider modularity + +- [x] **Passed:** Git support is an optional source-provider module included by default. + +- [x] **Passed:** The VFS core does not require Git as a hard dependency. + +- [x] **Passed:** Users can add non-Git source providers without rewriting the VFS core. + +- [x] **Passed:** Source snapshot creation can run as a node task instead of requiring coordinator filesystem or Git access. + +- [x] **Passed:** Coordinator-run code remains zero-capability and does not directly inspect the user checkout. + +- [x] **Passed:** A task can wait for any capable node to pick up source-preparation work rather than failing immediately when the coordinator lacks source access. + +--- + +## 8. Wasmtime and task execution + +- [x] **Passed:** Disasmer programs compile to Wasm and run under Wasmtime on node runtimes. + +- [x] **Passed:** `#[disasmer::main]` defines a virtual-process entrypoint. + +- [x] **Passed:** `#[disasmer::task]` defines named, remotely startable task entrypoints. + +- [x] **Passed:** `spawn::task(...).start().await` creates a debugger-visible virtual thread/task. + +- [x] **Passed:** `TaskHandle::join().await` returns small serialized results or runtime handles. + +- [x] **Passed:** Task arguments reject host-only values such as raw pointers, references, local files, sockets, and process handles. + +- [x] **Passed:** Large data crosses task boundaries through handles such as `SourceSnapshot`, `Blob`, `Artifact`, or VFS references. + +- [x] **Passed:** Small data can move into and out of tasks without awkward user ceremony. + +--- + +## 9. Native command execution + +- [x] **Passed:** Native commands are invoked from Wasm tasks through node host capabilities. + +- [x] **Passed:** A virtual process remains Wasmtime-backed even when a task calls into the node to run shell/native commands. + +- [x] **Passed:** Hosted coordinator control-plane code cannot run native commands or containers. + +- [x] **Passed:** Open-source/private deployments can allow normal local-node command capabilities by default. + +- [x] **Passed:** Command stdout and stderr are associated with the virtual thread that started the command. + +- [x] **Passed:** Native command execution is denied when the selected node or task lacks the required capability. + +--- + +## 10. Linux execution backend + +- [x] **Passed:** Linux nodes can materialize Containerfile-based environments with rootless Podman. + +- [x] **Passed:** Linux nodes can run command tasks inside the selected environment. + +- [x] **Passed:** Linux command outputs can be staged into the VFS artifact namespace. + +- [x] **Passed:** Local Linux source checkouts stay local when the local node can run the task. + +- [x] **Passed:** Linux command tasks participate in virtual process lifecycle, cancellation, logging, and debug freeze/resume behavior. + +--- + +## 11. Windows execution backend + +- [x] **Passed:** Windows node support is represented by the same node protocol and capability model as Linux. + +- [x] **Passed:** Windows command execution is clearly labeled as user-attached development execution, not production-grade untrusted sandboxing. + +- [x] **Passed:** Windows sandboxing exists behind an explicit backend interface or stub. + +- [x] **Passed:** Windows-specific code does not infect the Linux backend or generic node runtime model. + +- [x] **Passed:** Windows tasks can publish artifacts through the same VFS/artifact APIs once the backend is enabled. + +--- + +## 12. Scheduler and placement + +- [x] **Passed:** Scheduler placement is based on logical environment requirements, not hard-coded runner names. + +- [x] **Passed:** Scheduler prefers nodes with the source snapshot, environment cache, dependency cache, and required artifacts already local. + +- [x] **Passed:** Scheduler avoids network transfer when a compatible local/warm node exists. + +- [x] **Passed:** Scheduler can place a task on any capable node when the program asks for capability rather than a specific node. + +- [x] **Passed:** Scheduler failure messages explain which capability, environment, source, or connectivity constraint could not be satisfied. + +- [x] **Passed:** The scheduler is a replaceable module with a simple default implementation. + +--- + +## 13. QUIC transport and networking + +- [x] **Passed:** QUIC transport is behind a transport abstraction. + +- [x] **Passed:** The MVP default uses a Rust-native QUIC implementation. + +- [x] **Passed:** Node-to-node bulk transfer uses authenticated direct connections when possible. + +- [x] **Passed:** The coordinator can aid rendezvous but does not silently relay bulk artifact/source/blob data. + +- [x] **Passed:** Failed direct connectivity results in a clear placement/export/sync error. + +- [x] **Passed:** Each data-plane transfer is scoped to tenant, project, process, object, and authorization context. + +--- + +## 14. VFS and local-first behavior + +- [x] **Passed:** `flush()` publishes metadata and visibility information without uploading large bytes by default. + +- [x] **Passed:** `sync()` is explicit and may move bytes according to user code or configured policy. + +- [x] **Passed:** A local build from an existing checkout does not create a full-repo tarball by default. + +- [x] **Passed:** Coordinator-routed compiler file reads are not part of the normal local-first path. + +- [x] **Passed:** Source bytes, build-cache bytes, and artifact bytes remain node-local unless a consumer/export/sync/download path requires movement. + +- [x] **Passed:** VFS overlays provide task-local writes and published manifest epochs. + +- [x] **Passed:** Parent/downstream tasks can consume artifacts after the producing task flushes them. + +- [x] **Passed:** Same-node artifact reuse avoids unnecessary copying where the platform supports it. + +--- + +## 15. Artifact lifecycle and downloads + +- [x] **Passed:** Artifacts and other large blobs are best-effort retained on nodes by default, not durable coordinator objects. + +- [x] **Passed:** Artifact metadata can exist after `flush()` even when artifact bytes are only present on a retaining node. + +- [x] **Passed:** Artifact bytes may be garbage collected according to node retention policy unless explicitly exported or stored by user code. + +- [x] **Passed:** Loss of a node-local unsynced artifact is surfaced as artifact unavailability, not silent recovery. + +- [x] **Passed:** The operator/control panel can expose a download button for a best-effort retained artifact. + +- [x] **Passed:** Artifact download streams from a retaining node or explicit user-provided storage path, not from default durable coordinator artifact storage. + +- [x] **Passed:** A download action is not created when the artifact cannot be downloaded within current retention, location, size, authorization, or community tier limits. + +- [x] **Passed:** A failed download-link creation returns a clear error before showing a user-facing link. + +- [x] **Passed:** Artifact download links are authenticated, authorized, scoped, and not usable merely by guessing a URL. + +- [x] **Passed:** Cross-tenant and cross-project artifact downloads are denied even if an artifact ID or link-like value is known. + +- [x] **Passed:** Download usage is accounted against the relevant limits before and during streaming. + +--- + +## 16. Checkpoint and restart semantics + +- [x] **Passed:** Task restart is based on task entrypoint, serialized args, environment handle, and VFS/artifact checkpoint boundaries. + +- [x] **Passed:** The MVP does not claim live stack migration, live socket checkpointing, or arbitrary hot code replacement. + +- [x] **Passed:** Restarting a failed or selected task after source edit works when task compatibility checks pass. + +- [x] **Passed:** Compatibility failure requires a whole virtual-process restart with a clear message. + +- [x] **Passed:** Unflushed task-local filesystem changes are discarded or explicitly preserved according to documented policy. + +- [x] **Passed:** Best-effort retained artifacts can help restart, but restart never depends on pretending ephemeral artifacts are durable. + +--- + +## 17. Debugging and DAP + +- [x] **Passed:** VS Code F5 can launch a Disasmer virtual process. + +- [ ] **Partial:** F5/live-services starts the coordinator-side virtual process without requiring a node at launch; when that process reaches a virtual task with capability/environment requirements, the coordinator places that task on a capable node. The DAP adapter must not secretly start a worker node or make node availability a prerequisite for starting the coordinator-side process. Missing capable nodes must surface as normal task placement/unavailable-capability state. This is covered by local coordinator/worker DAP smoke, quick-test binary smoke, and live private-hosted-coordinator protocol smoke against `disasmer.michelpaulissen.com:9443`, but remains partial until the released public-repo assets and VS Code/DAP path are rerun end to end against the live private hosted coordinator. + +- [x] **Passed:** The debugger presents one process with multiple virtual threads/tasks. + +- [x] **Passed:** Breakpoints in `main` and spawned task code stop the virtual process. + +- [x] **Passed:** A breakpoint in one virtual thread creates a Debug Epoch for the whole virtual process. + +- [x] **Passed:** Wasm tasks and controlled native-command tasks participate in all-stop debug behavior. + +- [x] **Passed:** If a node cannot freeze a controlled task, the debugger reports failure instead of claiming all-stop succeeded. + +- [x] **Passed:** The debugger shows coherent virtual process identity, virtual thread names, stack frames, task args, Disasmer handles, command status, recent output, and artifact references for the current flagship demo. + +- [x] **Passed:** The normal debugger Variables pane exposes launchable MVP variable categories: task arguments, task return values, target structured fields, selected Rust locals around Disasmer API calls, runtime-captured Wasmtime frame locals, `Artifact`, `SourceSnapshot`, and `Blob` handles, current command spec/status, VFS mounts, and recent stdout/stderr tail. + +- [x] **Passed:** Top-level source locals around Disasmer API calls are visible from virtual-thread runtime state in the shippable DAP path. This is implemented outside `experiments/**` and is not satisfied by hardcoded experiment-local variable mapping. + +- [x] **Passed:** The shippable node/DAP path captures real Wasmtime frame-local slots from guest-debug runtime state and exposes them through a normal DAP Variables scope. This is implemented outside `experiments/**`; the experiment remains proof only, not implementation basis. + +- [x] **Passed:** Arbitrary Rust locals use best-effort source metadata when full value snapshots are unavailable, and variables that cannot be inspected are reported clearly while task args and handles remain visible. + +- [x] **Passed:** The debugger exposes per-task stdout/stderr tails, VFS/artifact state, and command status in normal scopes or optional Disasmer views without requiring users to understand node IDs, transport links, VFS manifests, or environment-cache internals. + +- [x] **Passed:** Continue resumes every frozen participant in the Debug Epoch. + +- [x] **Passed:** Pause requests stop the whole virtual process, not just the currently selected thread. + +- [x] **Passed:** Restart selected/failed task is available from the debugging workflow. + +- [x] **Passed:** The DAP adapter targets VS Code first while remaining ordinary enough for other DAP clients to integrate later. + +- [x] **Passed:** The launch-critical DAP surface required by the MVP is implemented and acceptance-tested: `initialize`, `launch`/`attach`, `setBreakpoints`, `configurationDone`, `threads`, `stackTrace`, `scopes`, `variables`, `continue`, `pause`, `next`/step-over at probe granularity, `disconnect`, `stopped` events, and `continued` events. + +--- + +## 18. VS Code extension + +- [x] **Passed:** The extension discovers environment resources and provides useful diagnostics for `env!(...)` references. + +- [x] **Passed:** The extension builds or refreshes the Disasmer bundle before launch/debug when needed. + +- [x] **Passed:** Normal VS Code thread, breakpoint, stack, output, continue, pause, restart, and Variables-pane flows work for the flagship demo, including selected source-local values around Disasmer API calls. + +- [x] **Passed:** Disasmer-specific views show nodes, virtual processes, logs, artifacts, and inspector state without replacing the normal debugger UX. + +- [x] **Passed:** Error messages guide the user toward missing nodes, missing environments, quota limits, unavailable artifacts, or failed debug freezes. + +--- + +## 19. Operator/control panel + +- [x] **Passed:** The MVP includes protocol and state foundations for immediate-mode operator panels. + +- [x] **Passed:** Program-defined panels use built-in typed widgets only. + +- [x] **Passed:** User-provided panel content cannot inject custom HTML or JavaScript. + +- [x] **Passed:** Panel state is scoped to tenant, project, and virtual process. + +- [x] **Passed:** Panel events are typed, rate-limited, and delivered back to the virtual process only when allowed. + +- [x] **Passed:** A stopped debug process shows the last rendered panel state without executing program UI logic. + +- [x] **Passed:** Control-plane actions such as restart, cancel, debug, and artifact download can remain available while program UI events are disabled. + +--- + +## 20. Hosted/community service policy + +- [x] **Passed:** Community hosted mode can create or use a project, attach user nodes, run tasks on those nodes, debug, and view logs/artifact metadata. + +- [x] **Passed:** Community hosted mode does not provide arbitrary hosted native commands or hosted containers. + +- [x] **Passed:** Hosted zero-capability Wasm has strict fuel, memory, wall-clock, state, log, and event limits. + +- [x] **Passed:** Hosted community policies are enforced before work starts, not only after resource usage is observed. + +- [x] **Passed:** Quota failures are explicit and actionable. + +- [x] **Passed:** Public hosted endpoints treat users, nodes, programs, logs, artifacts, UI events, source manifests, and capabilities as hostile input. + +--- + +## 21. Authorization and tenant isolation + +- [x] **Passed:** Every user-owned or project-owned object has tenant ownership. + +- [x] **Passed:** Users cannot list, inspect, debug, download, or mutate another tenant's nodes, processes, logs, artifacts, projects, or panel state. + +- [x] **Passed:** Nodes cannot claim tasks or publish artifacts outside their authorized tenant/project/process scope. + +- [x] **Passed:** Debug attach requires explicit project permission. + +- [x] **Passed:** Debug memory/variable/handle reads are authorized and scoped. + +- [x] **Passed:** Artifact provenance includes producer task, process, project, and node identity. + +- [x] **Passed:** Tenant isolation failures are treated as release blockers. + +--- + +## 22. Abuse and resource limits + +- [x] **Passed:** API calls, spawns, logs, metadata, debug reads, UI events, rendezvous attempts, and artifact downloads are subject to resource policy. + +- [x] **Passed:** Log output is capped, backpressured, and associated with the producing virtual thread. + +- [x] **Passed:** Large task arguments are rejected or warned before causing accidental distributed copies. + +- [x] **Passed:** Community hosted tasks cannot use network, secrets, host filesystem, native commands, containers, inbound ports, or arbitrary syscalls. + +- [x] **Passed:** Operator panels cannot collect passwords or initiate OAuth-like flows inside user-defined widgets. + +- [x] **Passed:** Admin controls can suspend abusive tenants, revoke node credentials, and stop active hosted processes. + +--- + +## 23. Documentation and examples + +- [x] **Passed:** The README shows the build-system workflow with Rust source, environments, attached nodes, debugging, and artifacts. + +- [x] **Passed:** Docs explain `flush()` versus `sync()` without implying default durability. + +- [x] **Passed:** Docs explain that artifacts are best-effort retained unless explicitly exported or stored. + +- [x] **Passed:** Docs explain node trust, user-attached execution, and Windows sandbox limitations honestly. + +- [x] **Passed:** Docs explain hosted/community limits without framing them as a compute giveaway. + +- [x] **Passed:** Docs explain how agents authenticate with public keys. + +- [x] **Passed:** Docs explain how to add non-Git source-provider support. + +- [x] **Passed:** Example code avoids coordinator-side filesystem, Git, shell, or container assumptions. + +--- + +## 24. Flagship demo acceptance + +- [x] **Passed:** A clean developer setup can install the CLI and VS Code extension. + +- [x] **Passed:** The user can sign in, create or select a project, and attach at least one Linux node. + +- [x] **Passed:** The demo project recognizes `env!("linux")` and, when available, `env!("windows")`. + +- [x] **Passed:** Pressing F5 starts one virtual process through the Disasmer debugger. + +- [ ] **Partial:** The flagship demo requires an explicitly attached/running worker node for command/container-style work; the coordinator-side process remains the limited orchestration path and worker placement is implicit in task launch requirements, not a user-facing direct scheduler call. Local quick-test binaries prove this against a fresh coordinator, and the live private hosted coordinator accepts the current `launch_task` / worker-assignment protocol; hosted dry-run acceptance still requires the released public-repo assets and VS Code/DAP path to be rerun end to end. + +- [x] **Passed:** The Linux build task runs as a node-hosted command from a Wasmtime-backed virtual task. + +- [x] **Passed:** A second task can run concurrently as another virtual thread when a capable node is available. + +- [x] **Passed:** A breakpoint in a spawned task all-stops the virtual process. + +- [x] **Passed:** Task args, command status, logs, and artifact handles are inspectable through synthetic runtime scopes or Disasmer views. + +- [x] **Passed:** The flagship demo lets the user inspect selected locals around Disasmer API calls, task args, command status, logs, stdout/stderr tails, VFS mounts, and artifact handles from the normal VS Code debugging experience. + +- [x] **Passed:** A failed task can be restarted after a source edit when compatibility permits. + +- [x] **Passed:** Final artifacts can be exported, downloaded from retained nodes, or otherwise handled explicitly by user code. + +- [x] **Passed:** The coordinator does not route bulk source or artifact bytes by default during the demo. + +--- + +## 25. Non-goals preserved + +- [x] **Passed:** The MVP does not imply transparent raw remote pointers. + +- [x] **Passed:** The MVP does not imply global consensus on every memory access. + +- [x] **Passed:** The MVP does not imply live native process migration. + +- [x] **Passed:** The MVP does not imply durable artifacts without explicit export/storage policy. + +- [x] **Passed:** The MVP does not imply public hosted compute for arbitrary workloads. + +- [x] **Passed:** The MVP does not imply secure managed Windows compute. + +- [x] **Passed:** The MVP does not require users to understand distributed internals for the normal debug/build path. diff --git a/acceptance_criteria_phase2.md b/acceptance_criteria_phase2.md new file mode 100644 index 0000000..b949d4d --- /dev/null +++ b/acceptance_criteria_phase2.md @@ -0,0 +1,385 @@ +# Disasmer MVP Release Acceptance Criteria + +**Status:** phase 2 superset of `acceptance_criteria.md` +**Purpose:** define the strict release gates for the MVP described by the current MVP plan and supporting design notes. + +This document is intentionally stricter than a milestone checklist. The MVP is accepted only when the product behaves like live infrastructure from a clean checkout, not when the codebase merely contains the right traits, models, mocks, or experiment outputs. + +Important reading note: an item marked **Partial** or **Open** does not automatically mean new product code, a new feature, or even actual implementation work is required. It means the item must become a proven fact about the system. Many items describe facts that may already be true, or should have been fundamental invariants from the start, but still need stronger evidence at the required boundary. Before adding implementation, first check whether the fact is already true in the current system and prefer proving, documenting, or tightening that existing behavior over duplicating capability or overengineering a parallel mechanism. + +**Status Prefixes** + +- **Passed:** implemented with focused automated/source-scan coverage or a narrow source-level guard in the current tree; this is not final MVP acceptance until the full release gates run. +- **Partial:** modeled or covered at unit/smoke/source-scan level, but missing live boundary coverage, packaging coverage, hosted coverage, or end-to-end acceptance. +- **Open:** not implemented, not yet wired through the required boundary, or not yet covered by an appropriate acceptance gate. + +For release-blocker lists, **Passed** means the listed blocker condition is currently guarded as absent; **Open** means absence has not been proven by the release gate yet. + +## Verification contract + +- [ ] **Partial:** Every release-blocking criterion in this document is satisfied by automated acceptance checks or an equivalent reproducible release gate. Current source, local smoke, public split, and public-tree preparation gates pass, but the current Forgejo Release upload, live service smoke, public-operator compatibility smoke, and full public dry-run e2e verification still need to be rerun from the fresh released assets. +- [x] **Passed:** Release-blocking behavior is exercised across real service/process boundaries whenever the feature crosses a coordinator, node, DAP, CLI, browser, database, VFS, or network boundary. +- [x] **Passed:** The acceptance environment is treated as live infrastructure as much as possible; NixOS may be used to make Auth, Postgres, coordinator, nodes, browser-facing services, Podman, networking, and test projects reproducible. +- [x] **Passed:** A criterion cannot be accepted solely because a type, trait, schema, mock, or unit-level model exists. +- [x] **Passed:** The acceptance report records commit SHA, public/private mode, OS/kernel, Rust toolchain, Node.js version, Podman version, Postgres version when used, browser/VS Code harness version, and whether Windows validation was run. +- [x] **Passed:** Windows criteria are best-effort unless explicitly marked release-blocking by the current release target. Windows docs and UI must not imply validated production-grade sandboxing before that validation exists. +- [x] **Passed:** Existing `acceptance_criteria.md` remains required unless it conflicts with this stricter release document; this document wins in conflicts. + +--- + +## 1. Public release split + +- [x] **Passed:** Excluding `private/**` produces a public source tree that builds the SDK, CLI, coordinator, node runtime, DAP adapter, VS Code extension, protocol crates, and examples. +- [x] **Passed:** Public coordinator/local mode remains useful without private hosted modules. +- [x] **Passed:** Private hosted modules are isolated under `private/**` or equivalent publish-filterable paths. +- [x] **Passed:** Public code does not import private modules except through explicit feature-gated boundaries that are absent from public release builds. +- [x] **Passed:** Hosted deployment works when private modules are included. +- [x] **Passed:** OIDC/Auth hosted integration, community tier quota policy, zero-cap hosted enforcement, hosted admin controls, and service-specific policy modules are private-hosted features. +- [x] **Passed:** The node runtime is not forked between public and hosted modes. +- [x] **Passed:** Hosted restrictions are policy/module decisions, not a separate incompatible runtime. +- [x] **Passed:** Public/self-hosted coordinators do not silently apply hosted community tier restrictions to owner-controlled deployments. +- [x] **Passed:** `experiments/**` is excluded from release artifacts and is not imported by runtime, coordinator, node, DAP, CLI, SDK, extension, or hosted private code. +- [x] **Passed:** Release source scans reject known demo-only credentials, placeholder device codes, hard-coded artifact links, hidden local paths, and experiment-specific process names. + +--- + +## 2. Public local MVP workflow + +- [x] **Passed:** From a clean public checkout, a developer can build or install the CLI using documented commands. +- [x] **Passed:** From a clean public checkout, a developer can build or locally install the VS Code extension using documented commands. +- [x] **Passed:** A local coordinator and local node run as separate OS processes. +- [x] **Passed:** `disasmer node attach` enrolls the node through the real identity path. +- [x] **Passed:** `disasmer node attach` auto-detects OS, architecture, backend, source-provider, and environment capabilities. +- [x] **Passed:** `disasmer node attach --cap ...` remains available as an override, not as required normal configuration. +- [x] **Passed:** `disasmer run` defaults to the current project directory. +- [x] **Passed:** `disasmer run --project ` overrides the project directory. +- [x] **Passed:** `disasmer run` uses hosted mode implicitly when the CLI has a hosted login and no local coordinator override is selected. +- [x] **Passed:** `disasmer run --local` or equivalent forces local coordinator mode when both local and hosted contexts exist. +- [x] **Passed:** `disasmer run [entry]` can select an entrypoint from the same project. +- [x] **Passed:** Multiple project entrypoints can be listed and run without duplicating project configuration. +- [x] **Passed:** The flagship local workflow crosses the CLI, coordinator, and node boundary rather than running all work inside the CLI process. +- [x] **Passed:** The local node performs real build work and reports task state, logs, VFS metadata, and artifact metadata to the coordinator. +- [x] **Passed:** The local workflow remains usable after excluding private hosted modules. + +--- + +## 3. Hosted/community control plane + +- [x] **Passed:** A hosted coordinator starts with private hosted modules enabled and public runtime crates unchanged. +- [x] **Passed:** Browser login completes through OIDC against Authentik or a live-compatible test OIDC provider. +- [x] **Passed:** CLI browser login receives a scoped CLI session without exposing provider tokens to nodes. +- [ ] **Partial:** Public released binaries default to a real human browser/account flow for `disasmer login --browser`: source and local binary smoke cover the browser path, account/login handoff, and local callback behavior, and raw JSON login-plan output is opt-in or diagnostic. This remains partial until the current Forgejo Release binaries are published and rerun through the hosted service flow. +- [x] **Passed:** Agent authentication works through public-key identity without requiring an interactive browser session. +- [x] **Passed:** Agent public keys can be registered, listed, rotated, and revoked. +- [x] **Passed:** A signed-in user can create or select a project through hosted API or UI. +- [x] **Passed:** A signed-in user can create a short-lived node enrollment token for a project. +- [x] **Passed:** A user-attached Linux node exchanges the enrollment token for a scoped long-lived node identity. +- [x] **Passed:** The hosted coordinator accepts the node's authenticated persistent session and heartbeat. +- [x] **Passed:** Hosted scheduling targets only authorized user-attached, team-shared, or managed nodes. +- [x] **Passed:** Community tier users can run real work on their own attached nodes. +- [x] **Passed:** Community tier users cannot run arbitrary hosted native commands, hosted containers, host filesystem access, secrets, inbound ports, outbound network, or arbitrary syscalls. +- [x] **Passed:** Hosted zero-capability Wasm is bounded by policy before start: fuel, memory, wall-clock, state, logs, metadata, UI events, and API calls. +- [x] **Passed:** Quota failures are returned before dispatch with a specific actionable reason. +- [x] **Passed:** Admin controls can suspend a tenant, revoke a node credential, and stop an active hosted process. +- [x] **Passed:** Hosted UI or API exposes node status, process status, logs, artifact metadata, and debug-session status. + +--- + +## 4. Auth, identity, and tenant isolation + +- [x] **Passed:** Browser sessions, CLI sessions, agent public-key identities, node identities, task identities, project identities, process identities, and artifact identities are represented distinctly. +- [x] **Passed:** User OAuth/browser/session tokens are never sent to nodes in task payloads, environments, logs, debug variables, panel events, or credential fields. +- [x] **Passed:** Node credentials are scoped to tenant, project or enrollment scope, node, and capability policy. +- [x] **Passed:** A node cannot claim a task for another tenant or project. +- [x] **Passed:** A node cannot publish logs, artifacts, VFS events, debug events, or panel events for an unauthorized process. +- [x] **Passed:** A user cannot list, inspect, debug, download, mutate, or infer another tenant's projects, nodes, processes, logs, artifacts, panels, source manifests, capabilities, or credentials. +- [x] **Passed:** Debug attach requires explicit project permission. +- [x] **Passed:** Debug memory, variables, handles, recent output, and artifact references are scoped to tenant, project, process, and authorized actor. +- [x] **Passed:** Artifact download authorization is checked at link creation and during streaming. +- [x] **Passed:** Negative tenant-isolation checks use the same public/private APIs used by normal clients. + +--- + +## 5. Coordinator persistence and restart semantics + +- [x] **Passed:** The coordinator has an abstract store boundary for durable product state. +- [x] **Passed:** Hosted deployment persists durable product state in Postgres or the configured production store. +- [x] **Passed:** Local development can use an in-memory store when durable projects are not required. +- [x] **Passed:** Durable state is limited to objects that must survive restart: users, tenants, projects, memberships, node identities, credentials, source-provider configuration, durable policy records, audit/security records, and private hosted configuration. +- [x] **Passed:** Active virtual processes, live virtual threads, debug epochs, node sessions, scheduler queues, in-flight task state, transient logs, VFS manifests, and artifact locations are ephemeral unless explicitly promoted to durable product/audit state. +- [x] **Passed:** Coordinator restart invalidates live virtual processes rather than pretending to recover them. +- [x] **Passed:** After coordinator restart, nodes reconnect with their persistent node identity. +- [x] **Passed:** After coordinator restart, nodes cannot continue stale virtual processes under the old coordinator epoch. +- [x] **Passed:** After coordinator restart, a user can re-run the process while retaining project, source-provider, and node identity state. +- [x] **Passed:** Migrations do not add durable tables for live process state without an explicit durability justification. + +--- + +## 6. Node runtime and coordinator session + +- [x] **Passed:** The node runtime opens a persistent authenticated session after enrollment. +- [x] **Passed:** The same session carries registration, heartbeat, capability updates, task assignments, debug commands, log events, VFS metadata, artifact metadata, and completion/failure events. +- [x] **Passed:** Session reconnect preserves node identity but not stale process ownership. +- [x] **Passed:** Coordinator-requested cancellation reaches the node and produces a terminal task state. +- [x] **Passed:** Debug freeze/resume reaches Wasm tasks through the node runtime. +- [x] **Passed:** Controlled native command tasks participate in freeze/resume when the backend supports it. +- [x] **Passed:** If a backend cannot freeze a controlled task, the node reports failure rather than pretending success. +- [x] **Passed:** Hosted and self-hosted nodes execute through the same node runtime code path. +- [x] **Passed:** Node capability claims are reported, authorized, and visible in coordinator/inspector state. + +--- + +## 7. Wasmtime, SDK, task ABI, and spawn/join + +- [x] **Passed:** A Disasmer program compiles to Wasm using the documented SDK and macros. +- [x] **Passed:** `#[disasmer::main]` registers at least one virtual-process entrypoint. +- [x] **Passed:** Multiple `#[disasmer::main]` or named entrypoints can coexist in one project and be selected by `disasmer run [entry]`. +- [x] **Passed:** `#[disasmer::task]` registers named task entrypoints that the runtime can start remotely. +- [x] **Passed:** `spawn::task(...).start().await` creates a debugger-visible virtual thread through the runtime. +- [x] **Passed:** `TaskHandle::join().await` returns small serialized values or runtime handles across a task boundary. +- [x] **Passed:** Raw pointers, borrowed references, local file handles, sockets, native process handles, and unbounded byte buffers are rejected before dispatch. +- [x] **Passed:** Large task inputs and outputs cross boundaries through `SourceSnapshot`, `Blob`, `Artifact`, `VirtualFile`, or VFS handles. +- [x] **Passed:** The flagship workflow uses the public SDK and runtime APIs, not custom demo-only APIs. +- [x] **Passed:** Task compatibility hashes distinguish restartable implementation changes from schema/capability/ABI changes requiring process restart. + +--- + +## 8. Native commands and Linux Podman backend + +- [x] **Passed:** Native command execution is invoked from a Wasmtime-backed virtual task through node host capabilities. +- [x] **Passed:** Native commands are exposed as node host imports or equivalent runtime calls, not as coordinator-side shell execution. +- [x] **Passed:** Hosted coordinator control-plane code cannot run native commands or containers for community users. +- [x] **Passed:** Native command execution is denied when the selected node or task lacks the required capability. +- [x] **Passed:** Linux nodes materialize `envs//Containerfile` or `envs//Dockerfile` environments with rootless Podman. +- [x] **Passed:** Linux command tasks run inside the selected materialized environment. +- [x] **Passed:** Command stdout/stderr are associated with the virtual thread that started the command. +- [x] **Passed:** Command output can be staged into `/vfs/artifacts`. +- [x] **Passed:** Local Linux source checkouts remain local when the local node can run the task. +- [x] **Passed:** The coordinator does not create a full-repo tarball, serve compiler reads, or receive source bytes in the normal local Linux path. +- [x] **Passed:** Linux Podman behavior is marked incomplete if Podman is unavailable in the release gate environment. + +--- + +## 9. Windows attached node best-effort track + +- [x] **Passed:** Windows node support uses the same coordinator session, task protocol, capability model, VFS/artifact API, and debug model as Linux. +- [x] **Passed:** Windows command execution is labeled as user-attached development execution, not production-grade managed sandboxing. +- [x] **Passed:** Windows sandboxing remains behind an explicit backend interface until independently validated. +- [x] **Passed:** The scheduler can represent `env!("windows")` requirements and select a Windows-capable node when one is available. +- [x] **Passed:** Windows tasks publish logs and artifact metadata through the same protocol as Linux. +- [x] **Passed:** VS Code/coordinator views can represent Windows tasks as virtual threads in the same virtual process. +- [x] **Passed:** Windows-specific code does not leak into Linux backend code or generic node runtime abstractions. +- [x] **Passed:** If real Windows validation is not run, docs and UI state that Windows support is unvalidated for the release. +- [x] **Passed:** Real Windows validation is not enabled for this release unless the manual Forgejo `Windows validation` workflow succeeds on the intermittent `windows` runner; that workflow runs `disasmer node attach`, executes a simple `windows-command-dev` command, publishes artifact metadata, checks placement, and verifies the debugger shows the Windows virtual thread. + +--- + +## 10. Source providers and local-first source behavior + +- [x] **Passed:** Git support is an optional source-provider module included by default. +- [x] **Passed:** VFS core builds and runs without depending on Git internals. +- [x] **Passed:** A non-Git source provider can be implemented through the public source-provider interface without changing VFS core code. +- [x] **Passed:** Source preparation can run as node work when the coordinator lacks checkout or provider access. +- [x] **Passed:** Coordinator-hosted capless orchestration can await “any capable node” for source preparation instead of failing immediately. +- [x] **Passed:** The default build example does not require coordinator-side Git, filesystem, shell, or container capability. +- [x] **Passed:** Local source bytes remain local for local capable builds. +- [x] **Passed:** Remote source transfer moves only required content or explicit snapshot chunks, not a default full-repo tarball. +- [x] **Passed:** Locality regressions are release blockers when the coordinator receives source bytes on the default local path. + +--- + +## 11. Scheduler and placement + +- [x] **Passed:** Placement uses logical environment requirements, node capabilities, source locality, environment cache state, dependency cache state, artifact locality, connectivity, and quota/policy state. +- [x] **Passed:** A compatible local/warm node is preferred over remote transfer unless policy or capability requirements prevent it. +- [x] **Passed:** Work can be placed on any capable node when the program asks for a capability rather than a specific node. +- [x] **Passed:** Scheduler errors explain missing capability, missing environment, source unavailability, quota denial, artifact unavailability, or connectivity failure. +- [x] **Passed:** Scheduler behavior crosses at least two node identities in the release gate environment. +- [x] **Passed:** The scheduler remains replaceable through a public module boundary. +- [x] **Passed:** The default scheduler never silently falls back to coordinator bulk relay to make placement succeed. + +--- + +## 12. QUIC, rendezvous, and bulk transfer + +- [x] **Passed:** QUIC transport is behind a transport abstraction. +- [x] **Passed:** The default MVP data-plane implementation uses a Rust-native QUIC stack. +- [x] **Passed:** Public docs and examples do not require a non-Rust QUIC implementation for the MVP default path. +- [x] **Passed:** Two nodes can establish an authenticated direct node-to-node connection when network conditions allow. +- [x] **Passed:** Coordinator-aided rendezvous exchanges endpoint candidates without becoming a silent bulk relay. +- [x] **Passed:** A node can request an authorized artifact, source, blob, or VFS chunk from another node over the data plane. +- [x] **Passed:** Each data-plane stream is scoped to tenant, project, process where applicable, object id, and authorization context. +- [x] **Passed:** Failed direct connectivity produces a clear placement/export/sync/download error. +- [x] **Passed:** The coordinator does not receive bulk source, artifact, blob, or build-cache bytes by default. +- [x] **Passed:** Any explicit coordinator-proxied download path is visible, authorized, metered, and never treated as default artifact storage. + +--- + +## 13. VFS, artifacts, retention, and downloads + +- [x] **Passed:** `flush()` publishes metadata and visibility information without uploading large bytes by default. +- [x] **Passed:** `sync()` is explicit and moves bytes only according to user code or configured policy. +- [x] **Passed:** Task-local VFS writes remain isolated until `flush()`. +- [x] **Passed:** Downstream tasks can consume flushed artifacts through handles. +- [x] **Passed:** Same-node artifact reuse avoids unnecessary copying where the platform supports it. +- [x] **Passed:** Artifact metadata includes tenant, project, process, producer task/thread, producer node, digest, size, retention/location, and flush epoch. +- [x] **Passed:** Artifact bytes are best-effort retained on nodes by default. +- [x] **Passed:** The coordinator has no default durable artifact store for large outputs. +- [x] **Passed:** Loss of an unsynced node-local artifact is surfaced as unavailable. +- [x] **Passed:** Artifact garbage collection is configurable and documented as best-effort retention, not durability. +- [x] **Passed:** Final artifact export works through an attached receiver node or explicit user-provided storage integration. +- [x] **Passed:** Control-panel artifact downloads stream from a retaining node or explicit user-provided storage path. +- [x] **Passed:** Download link creation fails before showing a link when retention, location, size, authorization, quota, connectivity, or community tier limits make the download impossible. +- [x] **Passed:** Download links are authenticated, authorized, scoped, expiring, revocable, and not usable merely by guessing a URL. +- [x] **Passed:** Download links are bound to the tenant, project, process, artifact, actor, and policy context. +- [x] **Passed:** Cross-tenant and cross-project download attempts are denied through the real download endpoint or API. +- [x] **Passed:** Download bytes are accounted before link creation and during streaming. +- [x] **Passed:** A download that becomes impossible mid-stream fails honestly and does not imply durability. + +--- + +## 14. Debugging, DAP, and VS Code + +- [x] **Passed:** VS Code F5 launches a Disasmer virtual process through the extension and DAP adapter. +- [ ] **Partial:** Live/debug launch starts the coordinator-side virtual process without requiring a node at launch; when that process reaches a virtual task with capability/environment requirements, the coordinator places that task on a capable node. The DAP adapter does not secretly spawn a worker node or expose a direct scheduler action as the programming model. Missing capable nodes produce normal placement/unavailable-capability state. Local coordinator/worker DAP smoke and quick-test binary smoke pass, and the deployed private hosted coordinator accepts the current `launch_task` / worker-assignment protocol; this remains partial until the released public-repo assets and VS Code/DAP path are rerun end to end against the live private hosted coordinator. +- [x] **Passed:** The DAP adapter presents one debug target with multiple virtual threads/tasks. +- [x] **Passed:** Ordinary VS Code breakpoints in `main` and spawned task code bind to real runtime stop points. +- [x] **Passed:** A breakpoint in one virtual thread creates a Debug Epoch for the whole virtual process. +- [x] **Passed:** Wasm tasks participate in all-stop behavior. +- [x] **Passed:** Controlled native-command tasks participate in all-stop behavior when supported by the backend. +- [x] **Passed:** If a participant cannot freeze, the debugger reports failure and does not claim all-stop succeeded. +- [x] **Passed:** Continue resumes every frozen participant in the Debug Epoch. +- [x] **Passed:** Pause stops the whole virtual process, not only the selected thread. +- [x] **Passed:** The debugger shows coherent virtual process identity, virtual thread names, stack frames, task args, Disasmer handles, command status, recent output, and artifact references for the current flagship demo. +- [x] **Passed:** The normal debugger Variables pane exposes the MVP launch variable set: task arguments, task return values, target structured fields, selected Rust locals around Disasmer API calls, runtime-captured Wasmtime frame locals, `Artifact`, `SourceSnapshot`, and `Blob` handles, current VFS mounts, current command spec/status, and recent stdout/stderr tail. +- [x] **Passed:** Selected top-level locals around Disasmer API calls are visible in the normal debugger Variables pane from virtual-thread runtime state. This is implemented outside `experiments/**`; experiment-local hardcoded variable mapping does not satisfy this criterion. +- [x] **Passed:** The shippable node/DAP path captures real Wasmtime frame-local slots from guest-debug runtime state and exposes them through a normal DAP Variables scope. This is implemented outside `experiments/**`; the experiment remains proof only, not implementation basis. +- [x] **Passed:** Arbitrary Rust locals use best-effort source metadata when full value snapshots are unavailable, and variables that cannot be inspected are reported clearly while task args and handles remain visible. +- [x] **Passed:** Per-task stdout/stderr tails, VFS/artifact state, and command status are visible through normal scopes or optional Disasmer views without requiring users to understand node IDs, transport links, VFS manifests, or environment-cache internals. +- [x] **Passed:** The debugger does not claim to debug arbitrary native child process internals unless that support exists. +- [x] **Passed:** Restart selected or failed task works after a compatible source edit. +- [x] **Passed:** Incompatible edits require whole-process restart with a clear message. +- [x] **Passed:** The DAP adapter remains a standard DAP server so non-VS Code DAP clients can integrate later without a proprietary protocol. +- [x] **Passed:** The launch-critical DAP surface required by the MVP is implemented and release-tested: `initialize`, `launch`/`attach`, `setBreakpoints`, `configurationDone`, `threads`, `stackTrace`, `scopes`, `variables`, `continue`, `pause`, `next`/step-over at probe granularity, `disconnect`, `stopped` events, and `continued` events. +- [x] **Passed:** VS Code views show nodes, virtual processes, logs, artifacts, and inspector state without replacing the normal debugger UX. + +--- + +## 15. Operator/control panel foundations + +- [x] **Passed:** Protocol and state foundations exist for immediate-mode operator panels. +- [x] **Passed:** The MVP exposes a built-in control panel or panel preview for process status, tasks, logs, artifacts, and control-plane actions. +- [x] **Passed:** Program-defined panel content uses only built-in typed widgets. +- [x] **Passed:** User-provided panel content cannot inject custom HTML or JavaScript. +- [x] **Passed:** Panel state is scoped to tenant, project, and virtual process. +- [x] **Passed:** Panel events are typed, rate-limited, and delivered to the virtual process only when allowed. +- [x] **Passed:** Operator panels cannot collect passwords, tokens, secrets, OAuth codes, or OAuth-like credentials. +- [x] **Passed:** A stopped debug process shows the last rendered panel state without executing program UI logic. +- [x] **Passed:** Control-plane actions such as restart, cancel, debug, and artifact download remain available while program UI events are disabled. +- [x] **Passed:** Artifact download buttons use the same secure download semantics as the artifact API. + +--- + +## 16. Abuse controls and resource limits + +- [x] **Passed:** API calls, spawns, logs, metadata, debug reads, UI events, rendezvous attempts, artifact downloads, and hosted zero-capability Wasm resources are metered through policy before work starts. +- [x] **Passed:** Log output is capped, backpressured, and associated with the producing virtual thread. +- [x] **Passed:** Large task arguments are rejected or warned before dispatch can cause accidental distributed copies. +- [x] **Passed:** Community hosted tasks cannot use network, secrets, host filesystem, native commands, containers, inbound ports, or arbitrary syscalls. +- [x] **Passed:** Quota counters cannot be bypassed by reconnecting a node, restarting a CLI, retrying an API call, or switching tenants/projects without authorization. +- [x] **Passed:** Public hosted endpoints validate users, nodes, programs, logs, artifacts, UI events, source manifests, capabilities, debug requests, and download requests as hostile input. +- [x] **Passed:** Abuse controls are enforced at the running hosted service boundary, not only in internal model code. +- [x] **Passed:** Community tier language is used consistently in user-facing docs and UI. +- [x] **Passed:** Public-facing docs and repository text avoid naming a specific launch forum or traffic source as a product goal. + +--- + +## 17. Packaging, docs, and first-run UX + +- [x] **Passed:** README quickstart covers install/build, local coordinator, node attach, demo run, VS Code debug, artifacts, and cleanup. +- [x] **Passed:** Docs explain `flush()` versus `sync()` without implying default durability. +- [x] **Passed:** Docs explain best-effort artifact retention and explicit export/storage. +- [x] **Passed:** Docs explain secure artifact downloads and why download links may fail before creation. +- [x] **Passed:** Docs explain node trust, user-attached execution, and Windows sandbox limitations honestly. +- [x] **Passed:** Docs explain hosted/community limits without framing community tier as arbitrary hosted compute. +- [x] **Passed:** Docs explain browser login and public-key authentication for agents. +- [x] **Passed:** Docs explain `disasmer run [entry]`, implicit hosted mode, local override, and project override. +- [x] **Passed:** Docs explain auto-detected node capabilities and `--cap` override behavior. +- [x] **Passed:** Docs explain how to add non-Git source-provider support. +- [x] **Passed:** VS Code extension packaging or documented local install works from a clean checkout. +- [x] **Passed:** First-run diagnostics guide users toward missing nodes, missing environments, quota limits, unavailable artifacts, auth failures, failed debug freezes, and source-provider capability gaps. + +--- + +## 18. Flagship release matrix + +- [x] **Passed:** Public local Linux demo: clean checkout, build/install CLI and extension, attach Linux node, run flagship build, debug in VS Code, inspect logs/artifacts, export or download final artifact. +- [x] **Passed:** Hosted/community Linux demo: browser login, project creation, node enrollment, task run on user node, debug, logs, artifact metadata, secure artifact download, and no hosted native/container compute. +- [x] **Passed:** Public split demo: same public local workflow succeeds after excluding `private/**`. +- [x] **Passed:** Hosted private demo: hosted workflow succeeds with private OIDC/policy/admin modules included. +- [x] **Passed:** Breakpoint in main all-stops the virtual process. +- [x] **Passed:** Breakpoint in a Linux task all-stops the virtual process. +- [ ] **Partial:** The release demo lets the user inspect task args, selected locals around Disasmer API calls, runtime-captured Wasmtime frame locals, command status, logs, stdout/stderr tail, VFS mounts, and artifact handles from the normal VS Code debugging experience. Current DAP/F5 evidence covers these debugger behaviors, including selected Disasmer API source-local values and Wasmtime frame-local slots; the public release dry-run e2e still needs to be rerun later with the released public-repo assets. +- [x] **Passed:** Failed task restart works after compatible source edit. +- [x] **Passed:** Incompatible edit requires whole-process restart with a clear message. +- [x] **Passed:** Local-first no-upload assertions pass during the release demo. +- [x] **Passed:** Community tier quota enforcement and cross-tenant forbidden-access checks pass during hosted acceptance. +- [x] **Passed:** Demo and docs avoid coordinator-side filesystem, Git, shell, container, durable artifact store, or hard-coded machine assumptions. +- [x] **Passed:** Windows demo steps are included only when the current release target enables Windows validation. + +--- + +## 18a. Public release dry run + +- [ ] **Partial:** A public release dry run deploys the real platform on a public network path so it is reachable as `disasmer.michelpaulissen.com:9443`; when the DNS record is live, no resolver override is required, and before propagation access may use an explicit test resolver, hosts entry, or equivalent controlled resolution path, but the service is not a loopback-only, local-only, or mock deployment. Earlier service evidence exists, but current-release acceptance remains partial until the live service smoke is rerun for the current acceptance commit. +- [ ] **Partial:** The dry run can be shared with selected external users, such as friends, by giving them the public repository/release assets and either public DNS access or controlled fallback resolution instructions for `disasmer.michelpaulissen.com:9443`; the platform and public repo are live, but current Forgejo Release assets are prepared locally and not yet published. +- [x] **Passed:** The deployed `disasmer.michelpaulissen.com:9443` platform is the default operator endpoint used by the public CLI/extension for the dry run, not a mock, local-only coordinator, or documentation-only placeholder. +- [ ] **Partial:** The deployed default operator is explicitly the private hosted coordinator from `private/hosted-policy`, with private hosted modules enabled. The phrase `public release dry run` means public Forgejo repository, public release assets, selected-user network reachability, and public client protocol compatibility; it does not mean deploying the standalone public/open-source coordinator as the hosted operator. Current-release proof still needs a fresh live service smoke for the current acceptance commit. +- [ ] **Partial:** The dry run validates both coordinator implementations: the private hosted coordinator through the live default-operator deployment and service/e2e evidence, and the standalone public/open-source coordinator through the filtered public repository and public release binaries in a local/self-hosted coordinator smoke. The public tree and local assets are current, but release/e2e evidence is stale and must be rerun from the fresh Forgejo Release. +- [ ] **Partial:** The dry-run platform runs the real hosted/control-plane service path with private hosted modules included and can coordinate browser or CLI login, project selection, node enrollment, task execution, debug state, logs, artifact metadata, and secure artifact download/export. Current-release proof requires rerunning the hosted service smoke. +- [x] **Passed:** The dry run includes a real browser-facing account/login website served from `disasmer.michelpaulissen.com` through the VPS nginx configuration, woven into the same public deployment path rather than hosted as a separate mock site or local-only helper. +- [x] **Passed:** The dry-run website is deliberately barebones functional HTML with no CSS and no layout or UX optimization work hidden in this phase; a later acceptance pass may add explicit good-UX criteria. +- [x] **Passed:** The dry-run public repository is a real public repository on the Forgejo instance at `git.michelpaulissen.com`, produced by filtering out private release-only material, including `private/**` and `experiments/**`, not only a local temporary tree. +- [ ] **Partial:** The Forgejo public repository can build/install the public SDK, CLI, coordinator/node runtime, DAP adapter, VS Code extension, protocol crates, and examples, and can use the deployed default operator without importing private source. The filtered public tree for the current acceptance commit is pushed and local release assets build, but default-operator use still needs current released-asset e2e proof. +- [ ] **Partial:** A Forgejo Release for the dry-run public repository publishes compiled release assets for the supported public binaries and extension package, so a user can download the appropriate artifacts from `git.michelpaulissen.com` and start without building from source. The current source archive, platform binary archive, VS Code extension VSIX, invite, getting-started guide, and checksums are prepared locally for the current acceptance commit, but not yet uploaded as Forgejo Release assets. +- [ ] **Partial:** A full e2e dry-run test uses that filtered public repository against the deployed `disasmer.michelpaulissen.com:9443` service, covering build/install, default operator selection, auth, attaching a user node, running the flagship workflow, VS Code/debugger behavior, logs, artifact metadata, and artifact download or explicit export. Existing e2e evidence is stale; the current release must be verified end to end from the Forgejo Release assets. +- [ ] **Partial:** The dry run records the public source commit, filtered public tree identity, deployed service commit/version, default operator endpoint, DNS-publication state, resolver requirement or lack of required override, environment/tool versions, and acceptance commands/results. Current local preparation records the current source and filtered public tree identities, but final evidence must be regenerated after Forgejo Release upload, deployment/service smoke, and e2e verification. +- [x] **Passed:** The dry-run instructions make a later fresh-domain and public GitHub-release reproduction mechanically straightforward, but actually performing that fresh-domain/public-GitHub release is explicitly out of scope for this dry run. + +--- + +## 19. Non-goals preserved + +- [x] **Passed:** The MVP does not imply transparent raw remote pointers. +- [x] **Passed:** The MVP does not imply global consensus on every memory access. +- [x] **Passed:** The MVP does not imply live native process migration. +- [x] **Passed:** The MVP does not imply live stack migration, live socket checkpointing, or arbitrary hot code replacement. +- [x] **Passed:** The MVP does not imply durable artifacts without explicit export/storage policy. +- [x] **Passed:** The MVP does not imply public hosted compute for arbitrary workloads. +- [x] **Passed:** The MVP does not imply secure managed Windows compute. +- [x] **Passed:** The MVP does not require users to understand distributed internals for the normal build/debug path. +- [x] **Passed:** The MVP does not require the coordinator to own source bytes, artifact bytes, build caches, or command execution in the default path. + +--- + +## 20. Release blockers + +Any item below blocks MVP acceptance until resolved: + +- [x] **Passed (blocker absent):** Public or private runtime behavior depends on `experiments/**` implementation code. +- [x] **Passed (blocker absent):** Public source cannot build and run the public local workflow after excluding `private/**`. +- [x] **Passed (blocker absent):** Hosted coordinator can run arbitrary native commands or containers for a community tier user. +- [x] **Passed (blocker absent):** Hosted capless Wasm can obtain network, host filesystem, secrets, native commands, containers, inbound ports, or arbitrary syscalls. +- [x] **Passed (blocker absent):** Coordinator receives bulk source, artifact, blob, or build-cache bytes on a default path where the MVP says bytes stay node-local or move directly. +- [x] **Passed (blocker absent):** Cross-tenant access succeeds for projects, nodes, processes, logs, artifacts, downloads, debug state, panels, capabilities, source manifests, credentials, or metadata. +- [x] **Passed (blocker absent):** A node can claim or publish outside its authorized tenant, project, process, or task scope. +- [x] **Passed (blocker absent):** A debugger reports all-stop success when any controlled participant failed to freeze. +- [x] **Passed (blocker absent):** Artifact download link creation succeeds when the artifact cannot be downloaded under current retention, location, authorization, connectivity, size, or quota constraints. +- [x] **Passed (blocker absent):** Artifact download links are usable by guessing, sharing to an unauthorized actor, or bypassing tenant/project/process authorization. +- [x] **Passed (blocker absent):** The default system behaves as if a durable coordinator artifact store exists when the user did not explicitly configure one. +- [x] **Passed (blocker absent):** Coordinator restart pretends to preserve live virtual processes or stale node-owned execution state. +- [x] **Passed (blocker absent):** `disasmer run` or the flagship example requires coordinator-side Git, shell, container, or source-checkout access. +- [x] **Passed (blocker absent):** Windows docs or UI imply production-grade managed Windows sandboxing before such sandboxing exists and has been validated. +- [x] **Passed (blocker absent):** The flagship demo requires undocumented manual state, hard-coded local paths, demo-only credentials, or hidden setup. +- [x] **Passed (blocker absent):** User-facing docs, plans, examples, or UI name a specific external launch forum as a product goal. +- [ ] **Partial (blocker absence source-proven, current-release proof pending):** Public released binaries only print a raw JSON login plan for normal human browser login, or the deployed default operator lacks a real browser/account login web surface served through `disasmer.michelpaulissen.com`. Source and local smoke coverage prove the intended browser path, but current Forgejo Release binaries and deployed nginx/Auth evidence must be rerun for the current acceptance commit. diff --git a/crates/disasmer-cli/Cargo.toml b/crates/disasmer-cli/Cargo.toml new file mode 100644 index 0000000..0f37e31 --- /dev/null +++ b/crates/disasmer-cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "disasmer-cli" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "disasmer" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +clap.workspace = true +disasmer-core = { path = "../disasmer-core" } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/disasmer-cli/src/main.rs b/crates/disasmer-cli/src/main.rs new file mode 100644 index 0000000..5aa0fc1 --- /dev/null +++ b/crates/disasmer-cli/src/main.rs @@ -0,0 +1,1631 @@ +use std::io::{BufRead, BufReader, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; +use disasmer_core::{ + BrowserLoginFlow, BundleIdentityInputs, BundleMetadata, Capability, CliLoginFlow, Digest, + NodeCapabilities, ProjectModel, SelectedInput, SourceProviderKind, SourceProviderManifest, +}; +use serde::Serialize; +use serde_json::{json, Value}; + +const DEFAULT_OPERATOR_ENDPOINT: &str = "https://disasmer.michelpaulissen.com:9443"; +const DEFAULT_BROWSER_LOGIN_START: &str = "https://disasmer.michelpaulissen.com/auth/browser/start"; +const DEFAULT_OIDC_ISSUER_URL: &str = "https://auth.michelpaulissen.com"; +const BROWSER_CALLBACK_ADDR: &str = "127.0.0.1:45173"; +const BROWSER_CALLBACK_PATH: &str = "/callback"; +const DEFAULT_BROWSER_LOGIN_CALLBACK_TIMEOUT_SECONDS: u64 = 300; + +#[derive(Clone, Debug, Parser)] +#[command(name = "disasmer")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Clone, Debug, Subcommand)] +enum Commands { + Login(LoginArgs), + Agent { + #[command(subcommand)] + command: AgentCommands, + }, + Bundle { + #[command(subcommand)] + command: BundleCommands, + }, + Run(RunArgs), + Node { + #[command(subcommand)] + command: NodeCommands, + }, +} + +#[derive(Clone, Debug, Parser)] +struct LoginArgs { + #[arg(long)] + browser: bool, + #[arg(long)] + plan: bool, + #[arg(long)] + json: bool, + #[arg(long, default_value_t = default_operator_endpoint())] + coordinator: String, + #[arg(long = "complete-browser-code")] + complete_browser_code: Option, + #[arg(long, default_value = "tenant")] + tenant: String, + #[arg(long = "project-id", default_value = "project")] + project: String, + #[arg(long, default_value = "user")] + user: String, + #[arg(long = "oidc-issuer-url")] + oidc_issuer_url: Option, + #[arg(long = "oidc-client-id", default_value = "disasmer")] + oidc_client_id: String, + #[arg(long)] + state: Option, +} + +#[derive(Clone, Debug, Subcommand)] +enum AgentCommands { + Enroll(AgentEnrollArgs), +} + +#[derive(Clone, Debug, Subcommand)] +enum BundleCommands { + Inspect(BundleInspectArgs), +} + +#[derive(Clone, Debug, Parser)] +struct AgentEnrollArgs { + #[arg(long = "public-key")] + public_key: String, +} + +#[derive(Clone, Debug, Parser)] +struct BundleInspectArgs { + #[arg(long)] + project: Option, +} + +#[derive(Clone, Debug, Parser)] +struct RunArgs { + entry: Option, + #[arg(long)] + project: Option, + #[arg(long)] + coordinator: Option, + #[arg(long)] + local: bool, +} + +#[derive(Clone, Debug, Subcommand)] +enum NodeCommands { + Attach(AttachArgs), +} + +#[derive(Clone, Debug, Parser)] +struct AttachArgs { + #[arg(long)] + coordinator: Option, + #[arg(long, default_value = "tenant")] + tenant: String, + #[arg(long = "project-id", default_value = "project")] + project: String, + #[arg(long)] + node: Option, + #[arg(long = "cap")] + caps: Vec, + #[arg(long = "enrollment-grant")] + enrollment_grant: Option, + #[arg(long = "public-key")] + public_key: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct RunPlan { + project: PathBuf, + entry: String, + coordinator: CoordinatorSelection, + #[serde(skip_serializing_if = "Option::is_none")] + operator_endpoint: Option, + session: CliSession, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +struct RunExecutionReport { + plan: RunPlan, + boundary: RunBoundaryEvidence, + node_report: serde_json::Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct RunBoundaryEvidence { + cli_process_started_node_process: bool, + cli_process_started_coordinator_process: bool, + coordinator_address: String, + #[serde(skip_serializing_if = "Option::is_none")] + coordinator_process_id: Option, + spawned_node_process_id: u32, + node_session_requests: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +enum CoordinatorSelection { + Hosted, + LocalOverride(String), + LocalOnly, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +enum CliSession { + Anonymous, + HumanSession, + AgentPublicKey { + public_key_fingerprint: Digest, + browser_interaction_required: bool, + }, +} + +impl CliSession { + fn is_authenticated(&self) -> bool { + !matches!(self, Self::Anonymous) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct LoginPlan { + coordinator: String, + human_flow: LoginFlowPlan, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +struct LoginCompletionReport { + plan: LoginPlan, + boundary: LoginCompletionBoundaryEvidence, + coordinator_response: Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct LoginCompletionBoundaryEvidence { + cli_contacted_coordinator: bool, + coordinator_address: String, + scoped_cli_session_received: bool, + provider_tokens_exposed_to_cli: bool, + provider_tokens_sent_to_nodes: bool, + coordinator_session_requests: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +enum LoginFlowPlan { + Browser(BrowserLoginFlow), + Device(CliLoginFlow), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct AgentEnrollmentPlan { + public_key_fingerprint: Digest, + browser_interaction_required_each_run: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct BundleInspection { + project: PathBuf, + default_source_providers: Vec, + source_provider_manifest: SourceProviderManifest, + metadata: BundleMetadata, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct NodeAttachPlan { + node: String, + coordinator: Option, + capabilities: NodeCapabilities, + enrollment: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize)] +struct NodeAttachReport { + plan: NodeAttachPlan, + boundary: NodeAttachBoundaryEvidence, + coordinator_response: Value, + heartbeat_response: Value, + capability_response: Value, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct NodeAttachBoundaryEvidence { + cli_contacted_coordinator: bool, + coordinator_address: String, + used_enrollment_exchange: bool, + coordinator_session_requests: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +struct NodeEnrollmentPlan { + grant: String, + public_key_fingerprint: Digest, + exchanges_short_lived_grant_for_long_lived_node_identity: bool, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + match cli.command { + Commands::Login(args) => { + if args.complete_browser_code.is_some() { + let report = execute_browser_login_completion(args)?; + println!("{}", serde_json::to_string_pretty(&report)?); + } else if args.browser && !args.plan { + let json_output = args.json; + let report = execute_interactive_browser_login(args)?; + if json_output { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print_browser_login_success(&report); + } + } else { + let plan = login_plan(args); + println!("{}", serde_json::to_string_pretty(&plan)?); + } + } + Commands::Agent { + command: AgentCommands::Enroll(args), + } => { + let plan = agent_enrollment_plan(args); + println!("{}", serde_json::to_string_pretty(&plan)?); + } + Commands::Bundle { + command: BundleCommands::Inspect(args), + } => { + let inspection = bundle_inspection(args, std::env::current_dir()?)?; + println!("{}", serde_json::to_string_pretty(&inspection)?); + } + Commands::Run(args) => { + let plan = run_plan(args, std::env::current_dir()?, session_from_env())?; + if should_execute_local_node(&plan) { + let report = execute_local_node_run(plan)?; + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}", serde_json::to_string_pretty(&plan)?); + } + } + Commands::Node { + command: NodeCommands::Attach(args), + } => { + if args.coordinator.is_some() { + let report = execute_node_attach(args)?; + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + let plan = attach_plan(args); + println!("{}", serde_json::to_string_pretty(&plan)?); + } + } + } + Ok(()) +} + +fn login_plan(args: LoginArgs) -> LoginPlan { + login_plan_with_nonce(args, login_nonce()) +} + +fn default_operator_endpoint() -> String { + DEFAULT_OPERATOR_ENDPOINT.to_owned() +} + +fn login_plan_with_nonce(args: LoginArgs, nonce: String) -> LoginPlan { + login_plan_with_nonce_and_callback(args, nonce, browser_callback_url()) +} + +fn login_plan_with_nonce_and_callback( + args: LoginArgs, + nonce: String, + callback_path: String, +) -> LoginPlan { + let challenge = Digest::from_parts([ + b"cli-login-challenge:v1".as_slice(), + args.coordinator.as_bytes(), + nonce.as_bytes(), + ]); + let human_flow = if args.browser { + let state = challenge.as_str().to_owned(); + LoginFlowPlan::Browser(BrowserLoginFlow { + authorization_url: browser_authorization_url(&args, &state, &callback_path), + callback_path, + state, + }) + } else { + LoginFlowPlan::Device(CliLoginFlow { + verification_url: format!("{}/auth/device", args.coordinator), + user_code: device_user_code(&challenge), + device_code: challenge.as_str().to_owned(), + expires_in_seconds: 900, + yields_long_lived_secret_directly: false, + }) + }; + + LoginPlan { + coordinator: args.coordinator, + human_flow, + } +} + +fn execute_browser_login_completion(args: LoginArgs) -> Result { + let plan = login_plan_with_nonce(args.clone(), login_nonce()); + execute_browser_login_completion_for_plan(args, plan) +} + +fn execute_browser_login_completion_for_plan( + args: LoginArgs, + plan: LoginPlan, +) -> Result { + if !args.browser { + anyhow::bail!("browser login completion requires --browser"); + } + let authorization_code = args + .complete_browser_code + .clone() + .context("--complete-browser-code requires an authorization code")?; + let issuer_url = args + .oidc_issuer_url + .clone() + .unwrap_or_else(default_oidc_issuer_url); + let coordinator = args.coordinator.clone(); + let tenant = args.tenant.clone(); + let project = args.project.clone(); + let user = args.user.clone(); + let client_id = args.oidc_client_id.clone(); + let LoginFlowPlan::Browser(flow) = &plan.human_flow else { + anyhow::bail!("browser login completion requires a browser flow"); + }; + let state = args.state.clone().unwrap_or_else(|| flow.state.clone()); + + let mut session = JsonLineSession::connect(&coordinator)?; + let coordinator_response = session.request(json!({ + "type": "oidc_browser_login", + "tenant": tenant, + "project": project, + "user": user, + "issuer_url": issuer_url, + "client_id": client_id, + "redirect_path": flow.callback_path.clone(), + "state": state, + "authorization_code": authorization_code, + }))?; + let scoped_cli_session_received = coordinator_response + .pointer("/session/cli_session_credential_kind") + .and_then(Value::as_str) + == Some("CliDeviceSession"); + let provider_tokens_sent_to_nodes = coordinator_response + .pointer("/session/provider_tokens_sent_to_nodes") + .and_then(Value::as_bool) + .unwrap_or(true); + let provider_tokens_exposed_to_cli = contains_provider_token_field(&coordinator_response); + + Ok(LoginCompletionReport { + plan, + boundary: LoginCompletionBoundaryEvidence { + cli_contacted_coordinator: true, + coordinator_address: coordinator, + scoped_cli_session_received, + provider_tokens_exposed_to_cli, + provider_tokens_sent_to_nodes, + coordinator_session_requests: session.requests(), + }, + coordinator_response, + }) +} + +fn execute_interactive_browser_login(mut args: LoginArgs) -> Result { + args.browser = true; + let callback_path = browser_callback_url(); + let listener = TcpListener::bind(BROWSER_CALLBACK_ADDR).with_context(|| { + format!("failed to listen for browser login callback on {BROWSER_CALLBACK_ADDR}") + })?; + let plan = login_plan_with_nonce_and_callback(args.clone(), login_nonce(), callback_path); + let LoginFlowPlan::Browser(flow) = &plan.human_flow else { + anyhow::bail!("browser login requires a browser flow"); + }; + + eprintln!("Opening Disasmer browser login: {}", flow.authorization_url); + eprintln!("Waiting for login callback on {}", flow.callback_path); + open_browser(&flow.authorization_url)?; + + let callback = receive_browser_callback(&listener)?; + if callback.state != flow.state { + anyhow::bail!("browser login callback state did not match the requested login state"); + } + args.complete_browser_code = Some(callback.authorization_code); + args.state = Some(callback.state); + execute_browser_login_completion_for_plan(args, plan) +} + +fn print_browser_login_success(report: &LoginCompletionReport) { + let session = report.coordinator_response.get("session"); + let tenant = session + .and_then(|value| value.get("tenant")) + .and_then(Value::as_str) + .unwrap_or("tenant"); + let project = session + .and_then(|value| value.get("project")) + .and_then(Value::as_str) + .unwrap_or("project"); + let user = session + .and_then(|value| value.get("user")) + .and_then(Value::as_str) + .unwrap_or("user"); + println!( + "Signed in to {} as {user} for {tenant}/{project}.", + report.plan.coordinator + ); + if report.boundary.scoped_cli_session_received { + println!("Received a scoped CLI session from the coordinator."); + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct BrowserCallback { + authorization_code: String, + state: String, +} + +fn browser_callback_url() -> String { + format!("http://{BROWSER_CALLBACK_ADDR}{BROWSER_CALLBACK_PATH}") +} + +fn default_oidc_issuer_url() -> String { + DEFAULT_OIDC_ISSUER_URL.to_owned() +} + +fn browser_authorization_url(args: &LoginArgs, state: &str, callback_path: &str) -> String { + if let Some(issuer_url) = &args.oidc_issuer_url { + return oidc_authorization_url(issuer_url, &args.oidc_client_id, state, callback_path); + } + let start = if args.coordinator == DEFAULT_OPERATOR_ENDPOINT { + DEFAULT_BROWSER_LOGIN_START.to_owned() + } else { + format!( + "{}/auth/browser/start", + args.coordinator.trim_end_matches('/') + ) + }; + format!( + "{start}?client_id={}&state={}&redirect_uri={}", + percent_encode(&args.oidc_client_id), + percent_encode(state), + percent_encode(callback_path) + ) +} + +fn oidc_authorization_url( + issuer_url: &str, + client_id: &str, + state: &str, + callback_path: &str, +) -> String { + format!( + "{}/application/o/authorize/?client_id={}&response_type=code&scope=openid%20profile%20email&state={}&redirect_uri={}", + issuer_url.trim_end_matches('/'), + percent_encode(client_id), + percent_encode(state), + percent_encode(callback_path) + ) +} + +fn open_browser(url: &str) -> Result<()> { + let mut command = if let Some(command) = std::env::var_os("DISASMER_BROWSER_OPEN_COMMAND") { + Command::new(command) + } else { + platform_browser_command() + }; + command.arg(url); + command + .spawn() + .with_context(|| format!("failed to start browser opener for {url}"))?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn platform_browser_command() -> Command { + Command::new("open") +} + +#[cfg(target_os = "windows")] +fn platform_browser_command() -> Command { + let mut command = Command::new("cmd"); + command.args(["/C", "start", ""]); + command +} + +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] +fn platform_browser_command() -> Command { + Command::new("xdg-open") +} + +fn receive_browser_callback(listener: &TcpListener) -> Result { + listener + .set_nonblocking(true) + .context("failed to configure browser callback listener")?; + let timeout = browser_callback_timeout(); + let started = Instant::now(); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if started.elapsed() >= timeout { + anyhow::bail!( + "timed out waiting for browser login callback on {BROWSER_CALLBACK_ADDR}" + ); + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(error) => return Err(error).context("failed to accept browser login callback"), + } + }; + let callback = read_browser_callback_request(&stream); + match &callback { + Ok(_) => write_browser_callback_response( + &mut stream, + "200 OK", + "Disasmer login callback received. You can return to the terminal.", + )?, + Err(error) => write_browser_callback_response( + &mut stream, + "400 Bad Request", + &format!("Disasmer login callback was rejected: {error}"), + )?, + } + callback +} + +fn browser_callback_timeout() -> Duration { + let seconds = std::env::var("DISASMER_BROWSER_LOGIN_TIMEOUT_SECONDS") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|seconds| *seconds > 0) + .unwrap_or(DEFAULT_BROWSER_LOGIN_CALLBACK_TIMEOUT_SECONDS); + Duration::from_secs(seconds) +} + +fn read_browser_callback_request(stream: &TcpStream) -> Result { + let mut reader = BufReader::new( + stream + .try_clone() + .context("failed to read browser callback request")?, + ); + let mut request_line = String::new(); + reader + .read_line(&mut request_line) + .context("failed to read browser callback request line")?; + parse_browser_callback_request_line(&request_line) +} + +fn parse_browser_callback_request_line(line: &str) -> Result { + let mut parts = line.split_whitespace(); + let method = parts.next().context("browser callback omitted method")?; + let target = parts.next().context("browser callback omitted path")?; + if method != "GET" { + anyhow::bail!("browser callback must use GET"); + } + let (path, query) = target + .split_once('?') + .context("browser callback omitted query string")?; + if path != BROWSER_CALLBACK_PATH { + anyhow::bail!("browser callback used unexpected path {path}"); + } + let mut authorization_code = None; + let mut state = None; + for pair in query.split('&').filter(|item| !item.is_empty()) { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + match key { + "code" => authorization_code = Some(percent_decode(value)?), + "state" => state = Some(percent_decode(value)?), + _ => {} + } + } + Ok(BrowserCallback { + authorization_code: authorization_code.context("browser callback omitted code")?, + state: state.context("browser callback omitted state")?, + }) +} + +fn write_browser_callback_response(stream: &mut TcpStream, status: &str, body: &str) -> Result<()> { + let body = html_escape(body); + let html = format!( + "Disasmer login

Disasmer login

{body}

" + ); + write!( + stream, + "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{html}", + html.len() + ) + .context("failed to write browser callback response") +} + +fn percent_encode(input: &str) -> String { + let mut encoded = String::new(); + for byte in input.as_bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + encoded.push(*byte as char); + } else { + encoded.push_str(&format!("%{byte:02X}")); + } + } + encoded +} + +fn percent_decode(input: &str) -> Result { + let mut decoded = Vec::new(); + let bytes = input.as_bytes(); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'+' => { + decoded.push(b' '); + index += 1; + } + b'%' => { + let high = bytes + .get(index + 1) + .copied() + .and_then(hex_digit) + .context("invalid percent-encoded callback value")?; + let low = bytes + .get(index + 2) + .copied() + .and_then(hex_digit) + .context("invalid percent-encoded callback value")?; + decoded.push((high << 4) | low); + index += 3; + } + byte => { + decoded.push(byte); + index += 1; + } + } + } + String::from_utf8(decoded).context("callback value was not UTF-8") +} + +fn hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +fn html_escape(input: &str) -> String { + input + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn contains_provider_token_field(value: &Value) -> bool { + match value { + Value::Object(object) => object.iter().any(|(key, value)| { + matches!( + key.as_str(), + "access_token" | "refresh_token" | "id_token" | "provider_token" | "oauth_token" + ) || contains_provider_token_field(value) + }), + Value::Array(items) => items.iter().any(contains_provider_token_field), + _ => false, + } +} + +fn login_nonce() -> String { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("{now}:{}", std::process::id()) +} + +fn device_user_code(challenge: &Digest) -> String { + let hex = challenge.as_str().trim_start_matches("sha256:"); + let first = hex.get(0..4).unwrap_or(hex).to_ascii_uppercase(); + let second = hex.get(4..8).unwrap_or(hex).to_ascii_uppercase(); + format!("DISASMER-{first}-{second}") +} + +fn agent_enrollment_plan(args: AgentEnrollArgs) -> AgentEnrollmentPlan { + AgentEnrollmentPlan { + public_key_fingerprint: Digest::sha256(args.public_key), + browser_interaction_required_each_run: false, + } +} + +fn bundle_inspection(args: BundleInspectArgs, cwd: PathBuf) -> Result { + let project = args.project.unwrap_or(cwd); + let model = ProjectModel::discover_without_config(&project)?; + let selected_inputs = discover_selected_inputs(&project)?; + let source_provider_manifest = source_provider_manifest(&project); + source_provider_manifest.validate_public_mvp()?; + let identity_inputs = BundleIdentityInputs { + wasm_code: wasm_source_proxy_digest(&selected_inputs), + task_abi: task_abi_digest(&model), + environments: model.environments, + source_provider_manifest: source_provider_manifest.digest.clone(), + selected_inputs, + }; + + Ok(BundleInspection { + project, + default_source_providers: vec![SourceProviderKind::Filesystem, SourceProviderKind::Git], + source_provider_manifest, + metadata: identity_inputs.inspectable_metadata(), + }) +} + +fn discover_selected_inputs(project: &Path) -> Result> { + let mut inputs = Vec::new(); + for path in [ + "Cargo.toml", + "Cargo.lock", + "src/main.rs", + "src/lib.rs", + "src/build.rs", + ] { + let absolute = project.join(path); + if !absolute.is_file() { + continue; + } + let bytes = std::fs::read(&absolute)?; + inputs.push(SelectedInput { + path: path.to_owned(), + digest: Digest::from_parts([ + b"bundle-selected-input:v1".as_slice(), + path.as_bytes(), + bytes.as_slice(), + ]), + }); + } + Ok(inputs) +} + +fn source_provider_manifest(project: &Path) -> SourceProviderManifest { + let kind = if project.join(".git").exists() { + SourceProviderKind::Git + } else { + SourceProviderKind::Filesystem + }; + SourceProviderManifest::local_first( + kind, + "default source provider manifest; snapshot creation can be scheduled as a node task", + ) +} + +fn wasm_source_proxy_digest(selected_inputs: &[SelectedInput]) -> Digest { + let mut parts = vec![b"wasm-source-proxy:v1".to_vec()]; + for input in selected_inputs { + parts.push(input.path.as_bytes().to_vec()); + parts.push(input.digest.as_str().as_bytes().to_vec()); + } + Digest::from_parts(parts) +} + +fn task_abi_digest(model: &ProjectModel) -> Digest { + let mut parts = vec![b"task-abi:v1".to_vec()]; + for entrypoint in model.entrypoints.values() { + parts.push(entrypoint.name.as_bytes().to_vec()); + parts.push(entrypoint.function.as_bytes().to_vec()); + } + Digest::from_parts(parts) +} + +fn run_plan(args: RunArgs, cwd: PathBuf, session: CliSession) -> Result { + let (coordinator, operator_endpoint) = if let Some(url) = args.coordinator { + (CoordinatorSelection::LocalOverride(url), None) + } else if args.local { + (CoordinatorSelection::LocalOnly, None) + } else if session.is_authenticated() { + ( + CoordinatorSelection::Hosted, + Some(default_operator_endpoint()), + ) + } else { + (CoordinatorSelection::LocalOnly, None) + }; + + Ok(RunPlan { + project: args.project.unwrap_or(cwd), + entry: args.entry.unwrap_or_else(|| "build".to_owned()), + coordinator, + operator_endpoint, + session, + }) +} + +fn should_execute_local_node(plan: &RunPlan) -> bool { + match &plan.coordinator { + CoordinatorSelection::LocalOnly => true, + CoordinatorSelection::LocalOverride(coordinator) => !coordinator.contains("://"), + CoordinatorSelection::Hosted => false, + } +} + +fn execute_local_node_run(plan: RunPlan) -> Result { + let local_coordinator = match &plan.coordinator { + CoordinatorSelection::LocalOverride(coordinator) => LocalCoordinator::external(coordinator), + CoordinatorSelection::LocalOnly => LocalCoordinator::start_ephemeral()?, + CoordinatorSelection::Hosted => anyhow::bail!("local node execution requires local mode"), + }; + let coordinator_address = local_coordinator.address.clone(); + let spawned = spawn_node_process(&plan, &coordinator_address)?; + if !spawned.output.status.success() { + anyhow::bail!( + "node process failed with status {}\n{}", + spawned.output.status, + String::from_utf8_lossy(&spawned.output.stderr) + ); + } + let stdout = String::from_utf8(spawned.output.stdout).context("node stdout was not UTF-8")?; + let line = stdout + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .context("node did not print a JSON report")?; + let node_report: serde_json::Value = + serde_json::from_str(line).context("node report was not JSON")?; + let node_session_requests = node_report + .get("session_requests") + .and_then(serde_json::Value::as_u64) + .unwrap_or_default(); + + Ok(RunExecutionReport { + plan, + boundary: RunBoundaryEvidence { + cli_process_started_node_process: true, + cli_process_started_coordinator_process: local_coordinator.process_id.is_some(), + coordinator_address, + coordinator_process_id: local_coordinator.process_id, + spawned_node_process_id: spawned.process_id, + node_session_requests, + }, + node_report, + }) +} + +struct SpawnedNodeOutput { + process_id: u32, + output: std::process::Output, +} + +fn spawn_node_process(plan: &RunPlan, coordinator: &str) -> Result { + let mut command = node_command()?; + command.args([ + "--coordinator", + coordinator, + "--tenant", + "tenant", + "--project-id", + "project", + "--node", + "node-cli-local", + "--process", + "vp-cli-local", + "--task", + "compile-linux", + "--project", + ]); + command.arg(&plan.project); + command.args(["--artifact", "/vfs/artifacts/cli-run-output.txt"]); + command.stdout(Stdio::piped()); + command.stderr(Stdio::piped()); + let child = command.spawn().context("failed to spawn node process")?; + let process_id = child.id(); + let output = child + .wait_with_output() + .context("failed to wait for node process")?; + Ok(SpawnedNodeOutput { process_id, output }) +} + +fn node_command() -> Result { + if let Some(path) = std::env::var_os("DISASMER_NODE_BIN") { + return Ok(Command::new(path)); + } + + let mut sibling = std::env::current_exe().context("cannot locate current executable")?; + sibling.set_file_name(format!("disasmer-node{}", std::env::consts::EXE_SUFFIX)); + if sibling.is_file() { + return Ok(Command::new(sibling)); + } + + let mut command = Command::new("cargo"); + command.args([ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-node", + "--", + ]); + Ok(command) +} + +struct LocalCoordinator { + address: String, + process_id: Option, + child: Option, +} + +impl LocalCoordinator { + fn external(address: &str) -> Self { + Self { + address: address.to_owned(), + process_id: None, + child: None, + } + } + + fn start_ephemeral() -> Result { + let mut command = coordinator_command()?; + command.args(["--listen", "127.0.0.1:0"]); + command.stdout(Stdio::piped()); + command.stderr(Stdio::inherit()); + + let mut child = command + .spawn() + .context("failed to spawn local coordinator process")?; + let process_id = child.id(); + let address = match read_coordinator_ready_address(&mut child) { + Ok(address) => address, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(error); + } + }; + + Ok(Self { + address, + process_id: Some(process_id), + child: Some(child), + }) + } +} + +fn read_coordinator_ready_address(child: &mut std::process::Child) -> Result { + let stdout = child + .stdout + .take() + .context("local coordinator stdout was not captured")?; + let mut ready_line = String::new(); + BufReader::new(stdout) + .read_line(&mut ready_line) + .context("failed to read local coordinator ready line")?; + if ready_line.trim().is_empty() { + anyhow::bail!("local coordinator exited before reporting its listen address"); + } + let ready: Value = + serde_json::from_str(&ready_line).context("local coordinator ready line was not JSON")?; + ready + .get("listen") + .and_then(Value::as_str) + .map(str::to_owned) + .context("local coordinator did not report a listen address") +} + +impl Drop for LocalCoordinator { + fn drop(&mut self) { + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +fn coordinator_command() -> Result { + if let Some(path) = std::env::var_os("DISASMER_COORDINATOR_BIN") { + return Ok(Command::new(path)); + } + + let mut sibling = std::env::current_exe().context("cannot locate current executable")?; + sibling.set_file_name(format!( + "disasmer-coordinator{}", + std::env::consts::EXE_SUFFIX + )); + if sibling.is_file() { + return Ok(Command::new(sibling)); + } + + let mut command = Command::new("cargo"); + command.args([ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + ]); + Ok(command) +} + +fn session_from_env() -> CliSession { + if let Some(public_key) = std::env::var_os("DISASMER_AGENT_PUBLIC_KEY") { + let public_key = public_key.to_string_lossy(); + return CliSession::AgentPublicKey { + public_key_fingerprint: Digest::sha256(public_key.as_bytes()), + browser_interaction_required: false, + }; + } + if std::env::var_os("DISASMER_TOKEN").is_some() { + return CliSession::HumanSession; + } + CliSession::Anonymous +} + +fn attach_plan(args: AttachArgs) -> NodeAttachPlan { + let mut capabilities = NodeCapabilities::detect_current(); + for cap in &args.caps { + if let Some(parsed) = parse_capability(&cap) { + capabilities.capabilities.insert(parsed); + } + } + let node = args.node.unwrap_or_else(default_node_id); + let public_key = args + .public_key + .unwrap_or_else(|| format!("{node}-public-key")); + let enrollment = args.enrollment_grant.map(|grant| NodeEnrollmentPlan { + grant, + public_key_fingerprint: Digest::sha256(public_key), + exchanges_short_lived_grant_for_long_lived_node_identity: true, + }); + + NodeAttachPlan { + node, + coordinator: args.coordinator, + capabilities, + enrollment, + } +} + +fn execute_node_attach(args: AttachArgs) -> Result { + let coordinator = args + .coordinator + .clone() + .context("node attach execution requires --coordinator")?; + let tenant = args.tenant.clone(); + let project = args.project.clone(); + let node = args.node.clone().unwrap_or_else(default_node_id); + let public_key = args + .public_key + .clone() + .unwrap_or_else(|| format!("{node}-public-key")); + let plan = attach_plan(args); + + let mut session = JsonLineSession::connect(&coordinator)?; + let used_enrollment_exchange = plan.enrollment.is_some(); + let coordinator_response = if let Some(enrollment) = &plan.enrollment { + session.request(json!({ + "type": "exchange_node_enrollment_grant", + "tenant": &tenant, + "project": &project, + "node": &node, + "public_key": &public_key, + "enrollment_grant": enrollment.grant, + "now_epoch_seconds": 0, + }))? + } else { + session.request(json!({ + "type": "attach_node", + "tenant": &tenant, + "project": &project, + "node": &node, + "public_key": &public_key, + }))? + }; + let heartbeat_response = session.request(json!({ + "type": "node_heartbeat", + "node": &plan.node, + }))?; + let capability_response = session.request(json!({ + "type": "report_node_capabilities", + "tenant": &tenant, + "project": &project, + "node": &plan.node, + "capabilities": &plan.capabilities, + "cached_environment_digests": [], + "dependency_cache_digests": [], + "source_snapshots": [], + "artifact_locations": [], + "direct_connectivity": true, + "online": false, + }))?; + + Ok(NodeAttachReport { + plan, + boundary: NodeAttachBoundaryEvidence { + cli_contacted_coordinator: true, + coordinator_address: coordinator, + used_enrollment_exchange, + coordinator_session_requests: session.requests(), + }, + coordinator_response, + heartbeat_response, + capability_response, + }) +} + +struct JsonLineSession { + writer: TcpStream, + reader: BufReader, + requests: u64, +} + +impl JsonLineSession { + fn connect(addr: &str) -> Result { + let transport_addr = json_line_transport_addr(addr); + let writer = TcpStream::connect(&transport_addr) + .with_context(|| format!("failed to connect to {addr} via {transport_addr}"))?; + let reader = BufReader::new(writer.try_clone()?); + Ok(Self { + writer, + reader, + requests: 0, + }) + } + + fn request(&mut self, value: Value) -> Result { + serde_json::to_writer(&mut self.writer, &value)?; + self.writer.write_all(b"\n")?; + self.writer.flush()?; + + let mut line = String::new(); + if self.reader.read_line(&mut line)? == 0 { + anyhow::bail!("coordinator closed session without a response"); + } + self.requests += 1; + let response: Value = serde_json::from_str(&line)?; + if response.get("type").and_then(Value::as_str) == Some("error") { + anyhow::bail!("coordinator error: {response}"); + } + Ok(response) + } + + fn requests(&self) -> u64 { + self.requests + } +} + +fn json_line_transport_addr(endpoint: &str) -> String { + let endpoint = endpoint.trim(); + for (scheme, default_port) in [("https://", 443), ("http://", 80)] { + if let Some(rest) = endpoint.strip_prefix(scheme) { + let authority = rest.split('/').next().unwrap_or(rest); + if authority.contains(':') { + return authority.to_owned(); + } + return format!("{authority}:{default_port}"); + } + } + endpoint.to_owned() +} + +fn default_node_id() -> String { + std::env::var("DISASMER_NODE_ID") + .or_else(|_| std::env::var("HOSTNAME")) + .or_else(|_| std::env::var("COMPUTERNAME")) + .unwrap_or_else(|_| "node-local".to_owned()) +} + +fn parse_capability(cap: &str) -> Option { + match cap { + "command" => Some(Capability::Command), + "containers" => Some(Capability::Containers), + "rootless-podman" => Some(Capability::RootlessPodman), + "source-filesystem" => Some(Capability::SourceFilesystem), + "source-git" => Some(Capability::SourceGit), + "vfs-artifacts" => Some(Capability::VfsArtifacts), + "wasmtime" => Some(Capability::Wasmtime), + "windows-command-dev" => Some(Capability::WindowsCommandDev), + "quic-direct" => Some(Capability::QuicDirect), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + fn parse(args: &[&str]) -> Cli { + Cli::parse_from(args) + } + + #[test] + fn run_defaults_to_current_project_and_build_entry() { + let Cli { + command: Commands::Run(args), + } = parse(&["disasmer", "run"]) + else { + panic!("wrong command"); + }; + let plan = run_plan(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap(); + + assert_eq!(plan.project, PathBuf::from("/repo")); + assert_eq!(plan.entry, "build"); + assert_eq!(plan.coordinator, CoordinatorSelection::LocalOnly); + assert_eq!(plan.operator_endpoint, None); + assert_eq!(plan.session, CliSession::Anonymous); + } + + #[test] + fn run_project_and_named_entry_are_respected() { + let Cli { + command: Commands::Run(args), + } = parse(&["disasmer", "run", "test", "--project", "/other"]) + else { + panic!("wrong command"); + }; + let plan = run_plan(args, PathBuf::from("/repo"), CliSession::HumanSession).unwrap(); + + assert_eq!(plan.project, PathBuf::from("/other")); + assert_eq!(plan.entry, "test"); + assert_eq!(plan.coordinator, CoordinatorSelection::Hosted); + assert_eq!( + plan.operator_endpoint.as_deref(), + Some(DEFAULT_OPERATOR_ENDPOINT) + ); + assert_eq!(plan.session, CliSession::HumanSession); + } + + #[test] + fn node_attach_detects_and_accepts_capability_overrides() { + let Cli { + command: + Commands::Node { + command: NodeCommands::Attach(args), + }, + } = parse(&["disasmer", "node", "attach", "--cap", "quic-direct"]) + else { + panic!("wrong command"); + }; + let plan = attach_plan(args); + + assert!(plan + .capabilities + .capabilities + .contains(&Capability::QuicDirect)); + assert!(!plan.capabilities.arch.is_empty()); + } + + #[test] + fn agents_can_select_hosted_with_public_key_identity() { + let args = RunArgs { + entry: None, + project: None, + coordinator: None, + local: false, + }; + let plan = run_plan( + args, + PathBuf::from("/repo"), + CliSession::AgentPublicKey { + public_key_fingerprint: Digest::sha256("agent-key"), + browser_interaction_required: false, + }, + ) + .unwrap(); + + assert_eq!(plan.coordinator, CoordinatorSelection::Hosted); + assert_eq!( + plan.operator_endpoint.as_deref(), + Some(DEFAULT_OPERATOR_ENDPOINT) + ); + assert_eq!( + plan.session, + CliSession::AgentPublicKey { + public_key_fingerprint: Digest::sha256("agent-key"), + browser_interaction_required: false, + } + ); + } + + #[test] + fn run_local_flag_overrides_logged_in_hosted_selection() { + let Cli { + command: Commands::Run(args), + } = parse(&["disasmer", "run", "--local"]) + else { + panic!("wrong command"); + }; + let plan = run_plan(args, PathBuf::from("/repo"), CliSession::HumanSession).unwrap(); + + assert_eq!(plan.coordinator, CoordinatorSelection::LocalOnly); + assert_eq!(plan.operator_endpoint, None); + } + + #[test] + fn local_only_run_executes_ephemeral_local_services() { + let Cli { + command: Commands::Run(args), + } = parse(&["disasmer", "run", "--local"]) + else { + panic!("wrong command"); + }; + let plan = run_plan(args, PathBuf::from("/repo"), CliSession::Anonymous).unwrap(); + + assert!(should_execute_local_node(&plan)); + } + + #[test] + fn login_uses_device_flow_without_long_lived_secret_copying() { + let Cli { + command: Commands::Login(args), + } = parse(&["disasmer", "login"]) + else { + panic!("wrong command"); + }; + let plan = login_plan(args); + + assert_eq!(plan.coordinator, DEFAULT_OPERATOR_ENDPOINT); + match plan.human_flow { + LoginFlowPlan::Device(flow) => { + assert!(!flow.yields_long_lived_secret_directly); + assert!(flow.verification_url.contains("/auth/device")); + assert_ne!(flow.user_code, format!("DISASMER-{}", "DEMO")); + assert!(flow.user_code.starts_with("DISASMER-")); + assert!(flow.device_code.starts_with("sha256:")); + } + LoginFlowPlan::Browser(_) => panic!("expected device flow"), + } + } + + #[test] + fn browser_login_flow_is_available_for_humans() { + let Cli { + command: Commands::Login(args), + } = parse(&["disasmer", "login", "--browser"]) + else { + panic!("wrong command"); + }; + let plan = login_plan(args); + + let LoginFlowPlan::Browser(flow) = plan.human_flow else { + panic!("expected browser flow"); + }; + assert!(flow.authorization_url.contains("/auth/browser/start")); + assert!(flow.state.starts_with("sha256:")); + } + + #[test] + fn browser_login_completion_detects_raw_provider_token_fields() { + assert!(contains_provider_token_field(&json!({ + "session": { + "access_token": "secret" + } + }))); + assert!(!contains_provider_token_field(&json!({ + "session": { + "cli_session_credential_kind": "CliDeviceSession", + "oidc_token_exchange": { + "received_access_token": true, + "received_id_token": true + } + } + }))); + } + + #[test] + fn agent_enroll_uses_public_key_without_browser_each_run() { + let Cli { + command: + Commands::Agent { + command: AgentCommands::Enroll(args), + }, + } = parse(&["disasmer", "agent", "enroll", "--public-key", "agent-key"]) + else { + panic!("wrong command"); + }; + let plan = agent_enrollment_plan(args); + + assert!(!plan.browser_interaction_required_each_run); + assert!(plan.public_key_fingerprint.as_str().starts_with("sha256:")); + } + + #[test] + fn bundle_inspect_discovers_environments_selected_inputs_and_source_providers() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("envs/linux")).unwrap(); + fs::create_dir_all(temp.path().join("src")).unwrap(); + fs::write( + temp.path().join("envs/linux/Containerfile"), + "FROM alpine\n", + ) + .unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").unwrap(); + + let Cli { + command: + Commands::Bundle { + command: BundleCommands::Inspect(args), + }, + } = parse(&[ + "disasmer", + "bundle", + "inspect", + "--project", + temp.path().to_str().unwrap(), + ]) + else { + panic!("wrong command"); + }; + let inspection = bundle_inspection(args, PathBuf::from("/unused")).unwrap(); + + assert_eq!(inspection.project, temp.path()); + assert!(inspection + .default_source_providers + .contains(&SourceProviderKind::Git)); + assert!(inspection + .source_provider_manifest + .description + .contains("node task")); + assert!( + !inspection + .source_provider_manifest + .coordinator_requires_checkout_access + ); + assert!( + !inspection + .source_provider_manifest + .transfer_policy + .default_full_repo_tarball + ); + assert_eq!(inspection.metadata.environments[0].name, "linux"); + assert!(!inspection.metadata.embeds_full_container_images); + assert!(inspection + .metadata + .selected_inputs + .iter() + .any(|input| input.path == "Cargo.toml")); + assert!(inspection + .metadata + .selected_inputs + .iter() + .any(|input| input.path == "src/main.rs")); + } + + #[test] + fn bundle_identity_changes_when_selected_input_file_changes() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + + let first = bundle_inspection( + BundleInspectArgs { + project: Some(temp.path().to_path_buf()), + }, + PathBuf::from("/unused"), + ) + .unwrap(); + fs::write( + temp.path().join("Cargo.toml"), + "[package]\nname='changed'\n", + ) + .unwrap(); + let second = bundle_inspection( + BundleInspectArgs { + project: Some(temp.path().to_path_buf()), + }, + PathBuf::from("/unused"), + ) + .unwrap(); + + assert_ne!(first.metadata.identity, second.metadata.identity); + } + + #[test] + fn source_provider_manifest_digest_does_not_include_local_project_path() { + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + fs::write(first.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + fs::write(second.path().join("Cargo.toml"), "[package]\nname='demo'\n").unwrap(); + + let first = bundle_inspection( + BundleInspectArgs { + project: Some(first.path().to_path_buf()), + }, + PathBuf::from("/unused"), + ) + .unwrap(); + let second = bundle_inspection( + BundleInspectArgs { + project: Some(second.path().to_path_buf()), + }, + PathBuf::from("/unused"), + ) + .unwrap(); + + assert_eq!( + first.source_provider_manifest.digest, + second.source_provider_manifest.digest + ); + } + + #[test] + fn node_attach_can_exchange_enrollment_grant() { + let Cli { + command: + Commands::Node { + command: NodeCommands::Attach(args), + }, + } = parse(&[ + "disasmer", + "node", + "attach", + "--enrollment-grant", + "grant", + "--public-key", + "node-key", + ]) + else { + panic!("wrong command"); + }; + let plan = attach_plan(args); + + assert!( + plan.enrollment + .unwrap() + .exchanges_short_lived_grant_for_long_lived_node_identity + ); + } + + #[test] + fn node_attach_enrollment_uses_default_public_key_when_not_explicit() { + let Cli { + command: + Commands::Node { + command: NodeCommands::Attach(args), + }, + } = parse(&[ + "disasmer", + "node", + "attach", + "--node", + "node-default-key", + "--enrollment-grant", + "grant", + ]) + else { + panic!("wrong command"); + }; + let plan = attach_plan(args); + + let enrollment = plan.enrollment.unwrap(); + assert_eq!(enrollment.grant, "grant"); + assert_eq!( + enrollment.public_key_fingerprint, + Digest::sha256("node-default-key-public-key") + ); + } + + #[test] + fn hosted_operator_url_maps_to_json_line_transport_address() { + assert_eq!( + json_line_transport_addr(DEFAULT_OPERATOR_ENDPOINT), + "disasmer.michelpaulissen.com:9443" + ); + assert_eq!( + json_line_transport_addr("https://disasmer.michelpaulissen.com:9443/auth/device"), + "disasmer.michelpaulissen.com:9443" + ); + assert_eq!( + json_line_transport_addr("http://operator.example.test"), + "operator.example.test:80" + ); + assert_eq!(json_line_transport_addr("127.0.0.1:7999"), "127.0.0.1:7999"); + } +} diff --git a/crates/disasmer-coordinator/Cargo.toml b/crates/disasmer-coordinator/Cargo.toml new file mode 100644 index 0000000..3865b51 --- /dev/null +++ b/crates/disasmer-coordinator/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "disasmer-coordinator" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +disasmer-core = { path = "../disasmer-core" } +postgres.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true diff --git a/crates/disasmer-coordinator/src/lib.rs b/crates/disasmer-coordinator/src/lib.rs new file mode 100644 index 0000000..b78118c --- /dev/null +++ b/crates/disasmer-coordinator/src/lib.rs @@ -0,0 +1,709 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use disasmer_core::{ + Actor, AuthContext, Authorization, CredentialKind, Digest, EnrollmentError, EnrollmentGrant, + NodeCredential, NodeId, ProcessId, ProjectId, SourceProviderKind, TenantId, UserId, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub mod postgres_store; +pub mod service; +pub use postgres_store::{ + PostgresDurableStore, PostgresStoreError, PostgresTable, POSTGRES_DURABLE_TABLES, +}; +pub use service::{ + CoordinatorRequest, CoordinatorResponse, CoordinatorService, CoordinatorServiceError, + SourcePreparationDisposition, SourcePreparationStatus, TaskAssignment, TaskCompletionEvent, + TaskTerminalState, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TenantRecord { + pub id: TenantId, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct UserRecord { + pub id: UserId, + pub tenant: TenantId, + pub credential_kind: CredentialKind, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectRecord { + pub id: ProjectId, + pub tenant: TenantId, + pub name: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeIdentityRecord { + pub id: NodeId, + pub tenant: TenantId, + pub project: ProjectId, + pub public_key: String, + pub enrollment_scope: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CredentialRecord { + pub subject: String, + pub tenant: TenantId, + pub project: Option, + pub kind: CredentialKind, + pub public_key_fingerprint: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceProviderConfigRecord { + pub tenant: TenantId, + pub project: ProjectId, + pub provider: SourceProviderKind, + pub manifest_digest: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServicePolicyRecord { + pub tenant: TenantId, + pub name: String, + pub digest: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectPermissionRecord { + pub tenant: TenantId, + pub project: ProjectId, + pub user: UserId, + pub can_debug: bool, +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct DurableState { + pub tenants: BTreeMap, + pub users: BTreeMap, + pub projects: BTreeMap, + pub node_identities: BTreeMap, + pub credentials: BTreeMap, + pub source_provider_configs: + BTreeMap<(TenantId, ProjectId, String), SourceProviderConfigRecord>, + pub service_policy_records: BTreeMap<(TenantId, String), ServicePolicyRecord>, + pub project_permissions: BTreeMap<(TenantId, ProjectId, UserId), ProjectPermissionRecord>, +} + +pub trait DurableStore { + fn load(&self) -> DurableState; + fn save(&mut self, state: DurableState); +} + +pub trait FallibleDurableStore { + type Error; + + fn load_state(&mut self) -> Result; + fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error>; +} + +#[derive(Clone, Debug, Default)] +pub struct InMemoryDurableStore { + state: DurableState, +} + +impl DurableStore for InMemoryDurableStore { + fn load(&self) -> DurableState { + self.state.clone() + } + + fn save(&mut self, state: DurableState) { + self.state = state; + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActiveProcess { + pub id: ProcessId, + pub tenant: TenantId, + pub project: ProjectId, + pub connected_nodes: BTreeSet, + pub coordinator_epoch: u64, +} + +#[derive(Clone, Debug)] +pub struct Coordinator { + durable: DurableState, + active_processes: BTreeMap, + coordinator_epoch: u64, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum CoordinatorError { + #[error("node identity is not enrolled")] + UnknownNode, + #[error("node enrollment failed: {0:?}")] + Enrollment(EnrollmentError), + #[error("stale virtual process state from coordinator epoch {stale_epoch}; current epoch is {current_epoch}")] + StaleProcessEpoch { + stale_epoch: u64, + current_epoch: u64, + }, + #[error("unauthorized coordinator action: {0}")] + Unauthorized(String), +} + +impl Coordinator { + pub fn boot(store: &impl DurableStore, coordinator_epoch: u64) -> Self { + Self { + durable: store.load(), + active_processes: BTreeMap::new(), + coordinator_epoch, + } + } + + pub fn try_boot( + store: &mut S, + coordinator_epoch: u64, + ) -> Result { + Ok(Self { + durable: store.load_state()?, + active_processes: BTreeMap::new(), + coordinator_epoch, + }) + } + + pub fn persist(&self, store: &mut impl DurableStore) { + store.save(self.durable.clone()); + } + + pub fn try_persist(&self, store: &mut S) -> Result<(), S::Error> { + store.save_state(&self.durable) + } + + pub fn coordinator_epoch(&self) -> u64 { + self.coordinator_epoch + } + + pub fn upsert_tenant(&mut self, id: TenantId) { + self.durable.tenants.insert(id.clone(), TenantRecord { id }); + } + + pub fn upsert_user(&mut self, tenant: TenantId, id: UserId, credential_kind: CredentialKind) { + self.durable.users.insert( + id.clone(), + UserRecord { + id, + tenant, + credential_kind, + }, + ); + } + + pub fn upsert_project(&mut self, tenant: TenantId, id: ProjectId, name: impl Into) { + self.durable.projects.insert( + id.clone(), + ProjectRecord { + id, + tenant, + name: name.into(), + }, + ); + } + + pub fn enroll_node( + &mut self, + tenant: TenantId, + project: ProjectId, + node: NodeId, + public_key: impl Into, + enrollment_scope: impl Into, + ) { + self.durable.node_identities.insert( + node.clone(), + NodeIdentityRecord { + id: node, + tenant, + project, + public_key: public_key.into(), + enrollment_scope: enrollment_scope.into(), + }, + ); + } + + pub fn create_node_enrollment_grant( + &self, + tenant: TenantId, + project: ProjectId, + grant_id: impl Into, + scope: impl Into, + expires_at_epoch_seconds: u64, + ) -> EnrollmentGrant { + EnrollmentGrant { + tenant, + project, + grant_id: grant_id.into(), + scope: scope.into(), + expires_at_epoch_seconds, + consumed: false, + } + } + + pub fn exchange_node_enrollment_grant( + &mut self, + grant: &mut EnrollmentGrant, + node: NodeId, + public_key: &str, + requested_scope: &str, + now_epoch_seconds: u64, + ) -> Result { + let credential = grant + .exchange_for_node_identity( + node.clone(), + public_key, + requested_scope, + now_epoch_seconds, + ) + .map_err(CoordinatorError::Enrollment)?; + self.enroll_node( + credential.tenant.clone(), + credential.project.clone(), + node.clone(), + public_key, + credential.scope.clone(), + ); + self.durable.credentials.insert( + format!("node:{node}"), + CredentialRecord { + subject: format!("node:{node}"), + tenant: credential.tenant.clone(), + project: Some(credential.project.clone()), + kind: credential.credential_kind.clone(), + public_key_fingerprint: Some(credential.public_key_fingerprint.clone()), + }, + ); + Ok(credential) + } + + pub fn upsert_source_provider_config( + &mut self, + tenant: TenantId, + project: ProjectId, + provider: SourceProviderKind, + manifest_digest: Digest, + ) { + let provider_key = format!("{provider:?}"); + self.durable.source_provider_configs.insert( + (tenant.clone(), project.clone(), provider_key), + SourceProviderConfigRecord { + tenant, + project, + provider, + manifest_digest, + }, + ); + } + + pub fn upsert_service_policy_record( + &mut self, + tenant: TenantId, + name: impl Into, + digest: Digest, + ) { + let name = name.into(); + self.durable.service_policy_records.insert( + (tenant.clone(), name.clone()), + ServicePolicyRecord { + tenant, + name, + digest, + }, + ); + } + + pub fn grant_project_debug(&mut self, tenant: TenantId, project: ProjectId, user: UserId) { + self.durable.project_permissions.insert( + (tenant.clone(), project.clone(), user.clone()), + ProjectPermissionRecord { + tenant, + project, + user, + can_debug: true, + }, + ); + } + + pub fn start_process( + &mut self, + tenant: TenantId, + project: ProjectId, + id: ProcessId, + ) -> ActiveProcess { + let process = ActiveProcess { + id: id.clone(), + tenant, + project, + connected_nodes: BTreeSet::new(), + coordinator_epoch: self.coordinator_epoch, + }; + self.active_processes.insert(id, process.clone()); + process + } + + pub fn authorize_node_for_process( + &self, + node: &NodeId, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + ) -> Result<(), CoordinatorError> { + let identity = self + .durable + .node_identities + .get(node) + .ok_or(CoordinatorError::UnknownNode)?; + if &identity.tenant != tenant || &identity.project != project { + return Err(CoordinatorError::Unauthorized( + "node identity is outside the requested tenant/project scope".to_owned(), + )); + } + let Some(active) = self.active_processes.get(process) else { + return Err(CoordinatorError::Unauthorized( + "virtual process is not active in coordinator memory".to_owned(), + )); + }; + if &active.tenant != tenant || &active.project != project { + return Err(CoordinatorError::Unauthorized( + "node cannot claim tasks or publish artifacts outside its process scope".to_owned(), + )); + } + Ok(()) + } + + pub fn reconnect_node( + &mut self, + node: &NodeId, + process: Option<(&ProcessId, u64)>, + ) -> Result<(), CoordinatorError> { + if !self.durable.node_identities.contains_key(node) { + return Err(CoordinatorError::UnknownNode); + } + + if let Some((process_id, stale_epoch)) = process { + if stale_epoch != self.coordinator_epoch { + return Err(CoordinatorError::StaleProcessEpoch { + stale_epoch, + current_epoch: self.coordinator_epoch, + }); + } + if let Some(active) = self.active_processes.get_mut(process_id) { + active.connected_nodes.insert(node.clone()); + } + } + + Ok(()) + } + + pub fn list_projects(&self, context: &AuthContext) -> Vec { + self.durable + .projects + .values() + .filter(|project| project.tenant == context.tenant) + .cloned() + .collect() + } + + pub fn authorize_debug_attach( + &self, + context: &AuthContext, + process: &ProcessId, + ) -> Authorization { + let Some(active) = self.active_processes.get(process) else { + return Authorization::deny("virtual process is not active"); + }; + if active.tenant != context.tenant || active.project != context.project { + return Authorization::deny("tenant or project mismatch"); + } + let Actor::User(user) = &context.actor else { + return Authorization::deny("debug attach requires a user identity"); + }; + let permission = self.durable.project_permissions.get(&( + active.tenant.clone(), + active.project.clone(), + user.clone(), + )); + if !permission.is_some_and(|permission| permission.can_debug) { + return Authorization::deny("debug attach requires explicit project permission"); + } + Authorization::allow("debug attach authorized for project") + } + + pub fn project(&self, id: &ProjectId) -> Option<&ProjectRecord> { + self.durable.projects.get(id) + } + + pub fn active_process(&self, id: &ProcessId) -> Option<&ActiveProcess> { + self.active_processes.get(id) + } + + pub fn active_process_count(&self) -> usize { + self.active_processes.len() + } + + pub fn node_identity(&self, id: &NodeId) -> Option<&NodeIdentityRecord> { + self.durable.node_identities.get(id) + } + + pub fn source_provider_config( + &self, + tenant: &TenantId, + project: &ProjectId, + provider: &str, + ) -> Option<&SourceProviderConfigRecord> { + self.durable.source_provider_configs.get(&( + tenant.clone(), + project.clone(), + provider.to_owned(), + )) + } + + pub fn service_policy_record( + &self, + tenant: &TenantId, + name: &str, + ) -> Option<&ServicePolicyRecord> { + self.durable + .service_policy_records + .get(&(tenant.clone(), name.to_owned())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordinator_restart_preserves_project_but_not_live_processes() { + let mut store = InMemoryDurableStore::default(); + let mut first = Coordinator::boot(&store, 1); + first.upsert_tenant(TenantId::from("tenant")); + first.upsert_user( + TenantId::from("tenant"), + UserId::from("user"), + CredentialKind::CliDeviceSession, + ); + first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo"); + first.upsert_source_provider_config( + TenantId::from("tenant"), + ProjectId::from("project"), + SourceProviderKind::Git, + Digest::sha256("git-manifest"), + ); + first.upsert_service_policy_record( + TenantId::from("tenant"), + "community tier", + Digest::sha256("policy"), + ); + let mut grant = first.create_node_enrollment_grant( + TenantId::from("tenant"), + ProjectId::from("project"), + "grant", + "node:attach", + 100, + ); + first + .exchange_node_enrollment_grant( + &mut grant, + NodeId::from("node"), + "public-key", + "node:attach", + 99, + ) + .unwrap(); + first.start_process( + TenantId::from("tenant"), + ProjectId::from("project"), + ProcessId::from("process"), + ); + first.persist(&mut store); + + let mut restarted = Coordinator::boot(&store, 2); + + assert!(restarted + .durable + .tenants + .contains_key(&TenantId::from("tenant"))); + assert!(restarted.durable.users.contains_key(&UserId::from("user"))); + assert!(restarted.project(&ProjectId::from("project")).is_some()); + assert!(restarted.node_identity(&NodeId::from("node")).is_some()); + assert_eq!( + restarted + .durable + .credentials + .get("node:node") + .map(|credential| &credential.kind), + Some(&CredentialKind::NodeCredential) + ); + assert!(restarted + .source_provider_config( + &TenantId::from("tenant"), + &ProjectId::from("project"), + "Git" + ) + .is_some()); + assert!(restarted + .service_policy_record(&TenantId::from("tenant"), "community tier") + .is_some()); + assert_eq!(restarted.active_process_count(), 0); + + let process = ProcessId::from("process-rerun"); + let rerun = restarted.start_process( + TenantId::from("tenant"), + ProjectId::from("project"), + process.clone(), + ); + assert_eq!(rerun.coordinator_epoch, 2); + restarted + .reconnect_node(&NodeId::from("node"), Some((&process, 2))) + .unwrap(); + assert!(restarted + .active_process(&process) + .unwrap() + .connected_nodes + .contains(&NodeId::from("node"))); + } + + #[test] + fn node_reconnect_rejects_stale_process_epoch_after_restart() { + let mut store = InMemoryDurableStore::default(); + let mut first = Coordinator::boot(&store, 1); + first.upsert_tenant(TenantId::from("tenant")); + first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo"); + first.enroll_node( + TenantId::from("tenant"), + ProjectId::from("project"), + NodeId::from("node"), + "public-key", + "node", + ); + first.persist(&mut store); + + let mut restarted = Coordinator::boot(&store, 2); + restarted + .reconnect_node(&NodeId::from("node"), None) + .unwrap(); + + let error = restarted + .reconnect_node( + &NodeId::from("node"), + Some((&ProcessId::from("process"), 1)), + ) + .unwrap_err(); + + assert!(matches!(error, CoordinatorError::StaleProcessEpoch { .. })); + } + + #[test] + fn node_enrollment_grant_becomes_persistent_node_identity() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + let mut grant = coordinator.create_node_enrollment_grant( + TenantId::from("tenant"), + ProjectId::from("project"), + "grant", + "node:attach", + 100, + ); + + let credential = coordinator + .exchange_node_enrollment_grant( + &mut grant, + NodeId::from("node"), + "public-key", + "node:attach", + 99, + ) + .unwrap(); + + assert_eq!(credential.credential_kind, CredentialKind::NodeCredential); + assert!(coordinator.node_identity(&NodeId::from("node")).is_some()); + } + + #[test] + fn project_listing_is_filtered_by_tenant() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + coordinator.upsert_project( + TenantId::from("tenant-a"), + ProjectId::from("project-a"), + "a", + ); + coordinator.upsert_project( + TenantId::from("tenant-b"), + ProjectId::from("project-b"), + "b", + ); + + let projects = coordinator.list_projects(&AuthContext { + tenant: TenantId::from("tenant-a"), + project: ProjectId::from("project-a"), + actor: Actor::User(UserId::from("user-a")), + }); + + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id, ProjectId::from("project-a")); + } + + #[test] + fn node_cannot_claim_process_outside_authorized_scope() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + coordinator.enroll_node( + TenantId::from("tenant-a"), + ProjectId::from("project-a"), + NodeId::from("node-a"), + "public-key", + "node", + ); + coordinator.start_process( + TenantId::from("tenant-b"), + ProjectId::from("project-b"), + ProcessId::from("process-b"), + ); + + let error = coordinator + .authorize_node_for_process( + &NodeId::from("node-a"), + &TenantId::from("tenant-b"), + &ProjectId::from("project-b"), + &ProcessId::from("process-b"), + ) + .unwrap_err(); + + assert!(matches!(error, CoordinatorError::Unauthorized(_))); + } + + #[test] + fn debug_attach_requires_explicit_project_permission() { + let store = InMemoryDurableStore::default(); + let mut coordinator = Coordinator::boot(&store, 1); + coordinator.start_process( + TenantId::from("tenant"), + ProjectId::from("project"), + ProcessId::from("process"), + ); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + + let denied = coordinator.authorize_debug_attach(&context, &ProcessId::from("process")); + coordinator.grant_project_debug( + TenantId::from("tenant"), + ProjectId::from("project"), + UserId::from("user"), + ); + let allowed = coordinator.authorize_debug_attach(&context, &ProcessId::from("process")); + + assert!(!denied.allowed); + assert!(denied.reason.contains("explicit project permission")); + assert!(allowed.allowed); + } +} diff --git a/crates/disasmer-coordinator/src/main.rs b/crates/disasmer-coordinator/src/main.rs new file mode 100644 index 0000000..dacb14f --- /dev/null +++ b/crates/disasmer-coordinator/src/main.rs @@ -0,0 +1,22 @@ +use std::io::Write; + +use disasmer_coordinator::{service::bind_listener, CoordinatorService}; +use serde_json::json; + +fn main() -> Result<(), Box> { + let mut listen = "127.0.0.1:0".to_owned(); + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if arg == "--listen" { + listen = args.next().ok_or("--listen requires an address")?; + } + } + + let (listener, addr) = bind_listener(&listen)?; + println!("{}", json!({ "listen": addr.to_string() })); + std::io::stdout().flush()?; + + let service = CoordinatorService::new(1); + service.serve_tcp(listener)?; + Ok(()) +} diff --git a/crates/disasmer-coordinator/src/postgres_store.rs b/crates/disasmer-coordinator/src/postgres_store.rs new file mode 100644 index 0000000..fe944af --- /dev/null +++ b/crates/disasmer-coordinator/src/postgres_store.rs @@ -0,0 +1,459 @@ +use postgres::{Client, NoTls}; +use serde::{de::DeserializeOwned, Serialize}; +use thiserror::Error; + +use crate::{ + CredentialRecord, DurableState, FallibleDurableStore, NodeIdentityRecord, + ProjectPermissionRecord, ProjectRecord, ServicePolicyRecord, SourceProviderConfigRecord, + TenantRecord, UserRecord, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PostgresTable { + pub name: &'static str, + pub durable_record: &'static str, + pub restart_surviving: bool, +} + +pub const POSTGRES_DURABLE_TABLES: &[PostgresTable] = &[ + PostgresTable { + name: "disasmer_tenants", + durable_record: "tenants", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_users", + durable_record: "users", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_projects", + durable_record: "projects", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_node_identities", + durable_record: "node identities", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_credentials", + durable_record: "credentials", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_source_provider_configs", + durable_record: "source-provider configuration", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_service_policy_records", + durable_record: "durable service policy records", + restart_surviving: true, + }, + PostgresTable { + name: "disasmer_project_permissions", + durable_record: "explicit project permissions", + restart_surviving: true, + }, +]; + +#[derive(Debug, Error)] +pub enum PostgresStoreError { + #[error("postgres durable store error: {0}")] + Postgres(#[from] postgres::Error), + #[error("durable state serialization error: {0}")] + Serialization(#[from] serde_json::Error), +} + +pub struct PostgresDurableStore { + client: Client, +} + +impl PostgresDurableStore { + pub fn connect(connection_string: &str) -> Result { + let mut store = Self { + client: Client::connect(connection_string, NoTls)?, + }; + store.migrate()?; + Ok(store) + } + + pub fn from_client(client: Client) -> Result { + let mut store = Self { client }; + store.migrate()?; + Ok(store) + } + + pub fn schema_sql() -> &'static str { + POSTGRES_SCHEMA_SQL + } + + pub fn durable_tables() -> &'static [PostgresTable] { + POSTGRES_DURABLE_TABLES + } + + pub fn migrate(&mut self) -> Result<(), PostgresStoreError> { + self.client.batch_execute(Self::schema_sql())?; + Ok(()) + } + + fn query_records( + &mut self, + sql: &str, + ) -> Result, PostgresStoreError> { + self.client + .query(sql, &[])? + .into_iter() + .map(|row| { + let value: serde_json::Value = row.get("record"); + Ok(serde_json::from_value(value)?) + }) + .collect() + } + + fn record_value(record: &impl Serialize) -> Result { + Ok(serde_json::to_value(record)?) + } +} + +impl FallibleDurableStore for PostgresDurableStore { + type Error = PostgresStoreError; + + fn load_state(&mut self) -> Result { + let mut state = DurableState::default(); + + for record in self.query_records::( + "SELECT record FROM disasmer_tenants ORDER BY tenant_id", + )? { + state.tenants.insert(record.id.clone(), record); + } + for record in + self.query_records::("SELECT record FROM disasmer_users ORDER BY user_id")? + { + state.users.insert(record.id.clone(), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_projects ORDER BY project_id", + )? { + state.projects.insert(record.id.clone(), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_node_identities ORDER BY node_id", + )? { + state.node_identities.insert(record.id.clone(), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_credentials ORDER BY subject", + )? { + state.credentials.insert(record.subject.clone(), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_source_provider_configs ORDER BY tenant_id, project_id, provider_key", + )? { + let provider_key = format!("{:?}", record.provider); + state.source_provider_configs.insert( + (record.tenant.clone(), record.project.clone(), provider_key), + record, + ); + } + for record in self.query_records::( + "SELECT record FROM disasmer_service_policy_records ORDER BY tenant_id, name", + )? { + state + .service_policy_records + .insert((record.tenant.clone(), record.name.clone()), record); + } + for record in self.query_records::( + "SELECT record FROM disasmer_project_permissions ORDER BY tenant_id, project_id, user_id", + )? { + state.project_permissions.insert( + ( + record.tenant.clone(), + record.project.clone(), + record.user.clone(), + ), + record, + ); + } + + Ok(state) + } + + fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> { + let mut tx = self.client.transaction()?; + tx.batch_execute( + " + DELETE FROM disasmer_project_permissions; + DELETE FROM disasmer_service_policy_records; + DELETE FROM disasmer_source_provider_configs; + DELETE FROM disasmer_credentials; + DELETE FROM disasmer_node_identities; + DELETE FROM disasmer_projects; + DELETE FROM disasmer_users; + DELETE FROM disasmer_tenants; + ", + )?; + + for record in state.tenants.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_tenants (tenant_id, record) VALUES ($1, $2)", + &[&record.id.as_str(), &value], + )?; + } + for record in state.users.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_users (user_id, tenant_id, record) VALUES ($1, $2, $3)", + &[&record.id.as_str(), &record.tenant.as_str(), &value], + )?; + } + for record in state.projects.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_projects (project_id, tenant_id, record) VALUES ($1, $2, $3)", + &[&record.id.as_str(), &record.tenant.as_str(), &value], + )?; + } + for record in state.node_identities.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_node_identities (node_id, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)", + &[ + &record.id.as_str(), + &record.tenant.as_str(), + &record.project.as_str(), + &value, + ], + )?; + } + for record in state.credentials.values() { + let value = Self::record_value(record)?; + let project_id = record.project.as_ref().map(|project| project.as_str()); + tx.execute( + "INSERT INTO disasmer_credentials (subject, tenant_id, project_id, record) VALUES ($1, $2, $3, $4)", + &[&record.subject.as_str(), &record.tenant.as_str(), &project_id, &value], + )?; + } + for ((_, _, provider_key), record) in &state.source_provider_configs { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_source_provider_configs (tenant_id, project_id, provider_key, record) VALUES ($1, $2, $3, $4)", + &[ + &record.tenant.as_str(), + &record.project.as_str(), + &provider_key.as_str(), + &value, + ], + )?; + } + for record in state.service_policy_records.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_service_policy_records (tenant_id, name, record) VALUES ($1, $2, $3)", + &[&record.tenant.as_str(), &record.name.as_str(), &value], + )?; + } + for record in state.project_permissions.values() { + let value = Self::record_value(record)?; + tx.execute( + "INSERT INTO disasmer_project_permissions (tenant_id, project_id, user_id, record) VALUES ($1, $2, $3, $4)", + &[ + &record.tenant.as_str(), + &record.project.as_str(), + &record.user.as_str(), + &value, + ], + )?; + } + + tx.commit()?; + Ok(()) + } +} + +const POSTGRES_SCHEMA_SQL: &str = r#" +CREATE TABLE IF NOT EXISTS disasmer_tenants ( + tenant_id TEXT PRIMARY KEY, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_users ( + user_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_projects ( + project_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_node_identities ( + node_id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_credentials ( + subject TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + record JSONB NOT NULL +); + +CREATE TABLE IF NOT EXISTS disasmer_source_provider_configs ( + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + provider_key TEXT NOT NULL, + record JSONB NOT NULL, + PRIMARY KEY (tenant_id, project_id, provider_key) +); + +CREATE TABLE IF NOT EXISTS disasmer_service_policy_records ( + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + name TEXT NOT NULL, + record JSONB NOT NULL, + PRIMARY KEY (tenant_id, name) +); + +CREATE TABLE IF NOT EXISTS disasmer_project_permissions ( + tenant_id TEXT NOT NULL REFERENCES disasmer_tenants(tenant_id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES disasmer_projects(project_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES disasmer_users(user_id) ON DELETE CASCADE, + record JSONB NOT NULL, + PRIMARY KEY (tenant_id, project_id, user_id) +); +"#; + +#[cfg(test)] +mod tests { + use disasmer_core::{ + CredentialKind, Digest, NodeId, ProjectId, SourceProviderKind, TenantId, UserId, + }; + + use super::*; + use crate::{Coordinator, DurableStore, FallibleDurableStore, InMemoryDurableStore}; + + #[test] + fn postgres_schema_contains_only_restart_surviving_durable_tables() { + let names = PostgresDurableStore::durable_tables() + .iter() + .map(|table| table.name) + .collect::>(); + + assert_eq!(names.len(), 8); + assert!(names.contains(&"disasmer_tenants")); + assert!(names.contains(&"disasmer_users")); + assert!(names.contains(&"disasmer_projects")); + assert!(names.contains(&"disasmer_node_identities")); + assert!(names.contains(&"disasmer_credentials")); + assert!(names.contains(&"disasmer_source_provider_configs")); + assert!(names.contains(&"disasmer_service_policy_records")); + assert!(names.contains(&"disasmer_project_permissions")); + assert!(PostgresDurableStore::durable_tables() + .iter() + .all(|table| table.restart_surviving)); + + for runtime_only in [ + "active_process", + "virtual_thread", + "scheduler_state", + "debug_epoch", + "vfs_manifest", + "transient_artifact_location", + ] { + assert!( + !PostgresDurableStore::schema_sql().contains(runtime_only), + "{runtime_only} must remain outside Postgres durable state" + ); + } + } + + #[test] + fn fallible_store_boot_uses_durable_state_and_still_drops_live_processes() { + #[derive(Default)] + struct FallibleMemoryStore { + inner: InMemoryDurableStore, + } + + impl FallibleDurableStore for FallibleMemoryStore { + type Error = std::convert::Infallible; + + fn load_state(&mut self) -> Result { + Ok(self.inner.load()) + } + + fn save_state(&mut self, state: &DurableState) -> Result<(), Self::Error> { + self.inner.save(state.clone()); + Ok(()) + } + } + + let mut store = FallibleMemoryStore::default(); + let mut first = Coordinator::try_boot(&mut store, 1).unwrap(); + first.upsert_tenant(TenantId::from("tenant")); + first.upsert_user( + TenantId::from("tenant"), + UserId::from("user"), + CredentialKind::CliDeviceSession, + ); + first.upsert_project(TenantId::from("tenant"), ProjectId::from("project"), "demo"); + first.enroll_node( + TenantId::from("tenant"), + ProjectId::from("project"), + NodeId::from("node"), + "public-key", + "node:attach", + ); + first.upsert_source_provider_config( + TenantId::from("tenant"), + ProjectId::from("project"), + SourceProviderKind::Git, + Digest::sha256("git-manifest"), + ); + first.start_process( + TenantId::from("tenant"), + ProjectId::from("project"), + disasmer_core::ProcessId::from("process"), + ); + first.try_persist(&mut store).unwrap(); + + let restarted = Coordinator::try_boot(&mut store, 2).unwrap(); + + assert!(restarted.project(&ProjectId::from("project")).is_some()); + assert!(restarted.node_identity(&NodeId::from("node")).is_some()); + assert_eq!(restarted.active_process_count(), 0); + } + + #[test] + fn postgres_round_trip_runs_when_dsn_is_configured() { + let Ok(dsn) = std::env::var("DISASMER_TEST_POSTGRES") else { + return; + }; + + let mut store = PostgresDurableStore::connect(&dsn).unwrap(); + let mut state = DurableState::default(); + state.tenants.insert( + TenantId::from("tenant"), + TenantRecord { + id: TenantId::from("tenant"), + }, + ); + state.projects.insert( + ProjectId::from("project"), + ProjectRecord { + id: ProjectId::from("project"), + tenant: TenantId::from("tenant"), + name: "demo".to_owned(), + }, + ); + store.save_state(&state).unwrap(); + let loaded = store.load_state().unwrap(); + + assert!(loaded.projects.contains_key(&ProjectId::from("project"))); + } +} diff --git a/crates/disasmer-coordinator/src/service.rs b/crates/disasmer-coordinator/src/service.rs new file mode 100644 index 0000000..69459db --- /dev/null +++ b/crates/disasmer-coordinator/src/service.rs @@ -0,0 +1,3591 @@ +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use std::io::{BufRead, BufReader, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::sync::{Arc, Mutex}; + +use disasmer_core::{ + Actor, ArtifactId, ArtifactRegistry, Capability, CapabilityReportError, CredentialKind, + DataPlaneObject, DataPlaneScope, DefaultScheduler, Digest, DirectBulkTransferPlan, + DownloadLink, DownloadPolicy, EnvironmentRequirements, LimitError, LimitKind, + NativeQuicTransport, NodeCapabilities, NodeDescriptor, NodeEndpoint, NodeId, PanelEvent, + PanelEventKind, PanelState, PanelWidget, PanelWidgetKind, Placement, PlacementRequest, + ProcessId, ProjectId, RateLimit, RendezvousRequest, ResourceLimits, ResourceMeter, Scheduler, + SourcePreparation, SourceProviderKind, StorageLocation, TaskId, TenantId, TransportError, + UserId, VfsPath, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{Coordinator, CoordinatorError, InMemoryDurableStore, ProjectRecord}; + +const MAX_TASK_LOG_TAIL_BYTES: usize = 256 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CoordinatorRequest { + Ping, + CreateProject { + tenant: String, + actor_user: String, + project: String, + name: String, + }, + SelectProject { + tenant: String, + actor_user: String, + project: String, + }, + ListProjects { + tenant: String, + actor_user: String, + }, + AttachNode { + tenant: String, + project: String, + node: String, + public_key: String, + }, + CreateNodeEnrollmentGrant { + tenant: String, + project: String, + actor_user: String, + grant: String, + #[serde(default)] + now_epoch_seconds: u64, + #[serde(default = "default_node_enrollment_ttl_seconds")] + ttl_seconds: u64, + }, + ExchangeNodeEnrollmentGrant { + tenant: String, + project: String, + node: String, + public_key: String, + enrollment_grant: String, + #[serde(default)] + now_epoch_seconds: u64, + }, + NodeHeartbeat { + node: String, + }, + ReportNodeCapabilities { + tenant: String, + project: String, + node: String, + capabilities: NodeCapabilities, + cached_environment_digests: Vec, + #[serde(default)] + dependency_cache_digests: Vec, + source_snapshots: Vec, + artifact_locations: Vec, + direct_connectivity: bool, + online: bool, + }, + ListNodeDescriptors { + tenant: String, + project: String, + actor_user: String, + }, + ScheduleTask { + tenant: String, + project: String, + environment: Option, + environment_digest: Option, + required_capabilities: Vec, + #[serde(default)] + dependency_cache: Option, + source_snapshot: Option, + required_artifacts: Vec, + #[serde(default = "default_true")] + quota_available: bool, + #[serde(default = "default_true")] + policy_allowed: bool, + prefer_node: Option, + }, + LaunchTask { + tenant: String, + project: String, + actor_user: String, + process: String, + task: String, + environment: Option, + environment_digest: Option, + required_capabilities: Vec, + #[serde(default)] + dependency_cache: Option, + source_snapshot: Option, + required_artifacts: Vec, + #[serde(default = "default_true")] + quota_available: bool, + #[serde(default = "default_true")] + policy_allowed: bool, + command: String, + #[serde(default)] + command_args: Vec, + artifact_path: String, + }, + PollTaskAssignment { + tenant: String, + project: String, + node: String, + }, + RequestRendezvous { + scope: DataPlaneScope, + source: NodeEndpoint, + destination: NodeEndpoint, + direct_connectivity: bool, + failure_reason: String, + }, + RequestSourcePreparation { + tenant: String, + project: String, + provider: SourceProviderKind, + }, + CompleteSourcePreparation { + tenant: String, + project: String, + node: String, + provider: SourceProviderKind, + source_snapshot: Digest, + }, + StartProcess { + tenant: String, + project: String, + process: String, + }, + ReconnectNode { + node: String, + process: String, + epoch: u64, + }, + CancelTask { + tenant: String, + project: String, + process: String, + node: String, + task: String, + }, + PollTaskControl { + tenant: String, + project: String, + process: String, + node: String, + task: String, + }, + PollDebugCommand { + tenant: String, + project: String, + process: String, + node: String, + task: String, + }, + ReportTaskLog { + tenant: String, + project: String, + process: String, + node: String, + task: String, + stdout_bytes: u64, + stderr_bytes: u64, + #[serde(default)] + stdout_tail: String, + #[serde(default)] + stderr_tail: String, + stdout_truncated: bool, + stderr_truncated: bool, + backpressured: bool, + }, + ReportVfsMetadata { + tenant: String, + project: String, + process: String, + node: String, + task: String, + artifact_path: Option, + artifact_digest: Option, + artifact_size_bytes: Option, + large_bytes_uploaded: bool, + }, + TaskCompleted { + tenant: String, + project: String, + process: String, + node: String, + task: String, + #[serde(default)] + terminal_state: Option, + status_code: Option, + stdout_bytes: u64, + stderr_bytes: u64, + #[serde(default)] + stdout_tail: String, + #[serde(default)] + stderr_tail: String, + #[serde(default)] + stdout_truncated: bool, + #[serde(default)] + stderr_truncated: bool, + artifact_path: Option, + artifact_digest: Option, + artifact_size_bytes: Option, + }, + ListTaskEvents { + tenant: String, + project: String, + actor_user: String, + #[serde(default)] + process: Option, + }, + RenderOperatorPanel { + tenant: String, + project: String, + process: String, + actor_user: String, + max_download_bytes: u64, + stopped: bool, + }, + SubmitPanelEvent { + tenant: String, + project: String, + process: String, + widget_id: String, + kind: PanelEventKind, + max_events: u64, + }, + CreateArtifactDownloadLink { + tenant: String, + project: String, + actor_user: String, + artifact: String, + max_bytes: u64, + token_nonce: String, + #[serde(default)] + now_epoch_seconds: u64, + #[serde(default = "default_download_ttl_seconds")] + ttl_seconds: u64, + }, + OpenArtifactDownloadStream { + tenant: String, + project: String, + actor_user: String, + artifact: String, + max_bytes: u64, + token_nonce: String, + token_digest: Digest, + #[serde(default)] + now_epoch_seconds: u64, + chunk_bytes: u64, + }, + RevokeArtifactDownloadLink { + tenant: String, + project: String, + actor_user: String, + artifact: String, + token_digest: Digest, + }, + ExportArtifactToNode { + tenant: String, + project: String, + actor_user: String, + artifact: String, + receiver_node: String, + direct_connectivity: bool, + failure_reason: String, + }, +} + +type TaskControlKey = (TenantId, ProjectId, ProcessId, NodeId, TaskId); +type TaskAssignmentKey = (TenantId, ProjectId, NodeId); +type PanelStopKey = (TenantId, ProjectId, ProcessId); +type EnrollmentGrantKey = (TenantId, ProjectId, String); + +fn default_download_ttl_seconds() -> u64 { + 900 +} + +fn default_node_enrollment_ttl_seconds() -> u64 { + 900 +} + +fn default_true() -> bool { + true +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TaskTerminalState { + Completed, + Failed, + Cancelled, +} + +impl TaskTerminalState { + fn from_status_code(status_code: Option) -> Self { + match status_code { + Some(0) => Self::Completed, + _ => Self::Failed, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskCompletionEvent { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub node: NodeId, + pub task: TaskId, + pub terminal_state: TaskTerminalState, + pub status_code: Option, + pub stdout_bytes: u64, + pub stderr_bytes: u64, + pub stdout_tail: String, + pub stderr_tail: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub artifact_path: Option, + pub artifact_digest: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskAssignment { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub task: TaskId, + pub node: NodeId, + pub epoch: u64, + pub command: String, + pub command_args: Vec, + pub artifact_path: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourcePreparationDisposition { + Pending { reason: String }, + Assigned { node: NodeId }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourcePreparationStatus { + pub preparation: SourcePreparation, + pub disposition: SourcePreparationDisposition, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum CoordinatorResponse { + Pong { + epoch: u64, + }, + ProjectCreated { + project: ProjectRecord, + actor: UserId, + }, + ProjectSelected { + project: ProjectRecord, + actor: UserId, + }, + Projects { + projects: Vec, + actor: UserId, + }, + NodeAttached { + node: NodeId, + tenant: TenantId, + project: ProjectId, + }, + NodeEnrollmentGrantCreated { + tenant: TenantId, + project: ProjectId, + grant: String, + scope: String, + expires_at_epoch_seconds: u64, + }, + NodeEnrollmentExchanged { + node: NodeId, + tenant: TenantId, + project: ProjectId, + credential: disasmer_core::NodeCredential, + }, + NodeHeartbeat { + node: NodeId, + epoch: u64, + }, + NodeCapabilitiesRecorded { + node: NodeId, + node_descriptors: usize, + }, + NodeDescriptors { + descriptors: Vec, + actor: UserId, + }, + TaskPlacement { + placement: Placement, + }, + TaskLaunched { + process: ProcessId, + task: TaskId, + placement: Placement, + assignment: TaskAssignment, + }, + TaskAssignment { + assignment: Option, + }, + RendezvousPlan { + plan: DirectBulkTransferPlan, + charged_rendezvous_attempts: u64, + }, + SourcePreparation { + status: SourcePreparationStatus, + }, + SourcePreparationCompleted { + node: NodeId, + provider: SourceProviderKind, + source_snapshot: Digest, + }, + ProcessStarted { + process: ProcessId, + epoch: u64, + }, + NodeReconnected { + node: NodeId, + process: ProcessId, + }, + TaskCancellationRequested { + process: ProcessId, + task: TaskId, + node: NodeId, + }, + TaskControl { + process: ProcessId, + task: TaskId, + cancel_requested: bool, + }, + DebugCommand { + process: ProcessId, + task: TaskId, + command: Option, + }, + TaskLogRecorded { + process: ProcessId, + task: TaskId, + stdout_bytes: u64, + stderr_bytes: u64, + stdout_tail: String, + stderr_tail: String, + backpressured: bool, + }, + VfsMetadataRecorded { + process: ProcessId, + task: TaskId, + artifact_path: Option, + large_bytes_uploaded: bool, + }, + TaskRecorded { + process: ProcessId, + task: TaskId, + events_recorded: usize, + }, + TaskEvents { + events: Vec, + }, + OperatorPanel { + panel: PanelState, + }, + PanelEventAccepted { + used_events: u64, + max_events: u64, + }, + ArtifactDownloadLink { + link: DownloadLink, + }, + ArtifactDownloadLinkRevoked { + link: DownloadLink, + }, + ArtifactDownloadStream { + link: DownloadLink, + streamed_bytes: u64, + charged_download_bytes: u64, + }, + ArtifactExportPlan { + plan: DirectBulkTransferPlan, + source_node: NodeId, + receiver_node: NodeId, + }, + Error { + message: String, + }, +} + +#[derive(Debug, Error)] +pub enum CoordinatorServiceError { + #[error("coordinator protocol I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("coordinator protocol JSON error: {0}")] + Json(#[from] serde_json::Error), + #[error("coordinator request failed: {0}")] + Coordinator(#[from] CoordinatorError), + #[error("artifact download request failed: {0}")] + Download(#[from] disasmer_core::DownloadError), + #[error("scheduler placement failed: {0}")] + Scheduler(#[from] disasmer_core::PlacementError), + #[error("transport request failed: {0}")] + Transport(#[from] TransportError), + #[error("resource limit failed: {0}")] + Resource(#[from] LimitError), + #[error("operator panel request failed: {0}")] + Panel(#[from] disasmer_core::PanelError), + #[error("invalid node capability report: {0}")] + CapabilityReport(#[from] CapabilityReportError), + #[error("invalid VFS artifact path reported by node: {0}")] + InvalidArtifactPath(String), + #[error("invalid task log tail reported by node: {0}")] + InvalidTaskLogTail(String), +} + +#[derive(Clone, Debug)] +pub struct CoordinatorService { + coordinator: Coordinator, + store: InMemoryDurableStore, + node_descriptors: BTreeMap, + enrollment_grants: BTreeMap, + task_events: Vec, + task_assignments: BTreeMap>, + task_cancellations: BTreeSet, + panel_snapshots: BTreeMap, + stopped_panels: BTreeSet, + panel_event_limits: BTreeMap<(TenantId, ProjectId, ProcessId, String), RateLimit>, + artifact_registry: ArtifactRegistry, + transport: NativeQuicTransport, + rendezvous_limits: ResourceLimits, + rendezvous_meter: ResourceMeter, + download_limits: ResourceLimits, + download_meter: ResourceMeter, +} + +impl CoordinatorService { + pub fn new(coordinator_epoch: u64) -> Self { + let store = InMemoryDurableStore::default(); + let coordinator = Coordinator::boot(&store, coordinator_epoch); + Self { + coordinator, + store, + node_descriptors: BTreeMap::new(), + enrollment_grants: BTreeMap::new(), + task_events: Vec::new(), + task_assignments: BTreeMap::new(), + task_cancellations: BTreeSet::new(), + panel_snapshots: BTreeMap::new(), + stopped_panels: BTreeSet::new(), + panel_event_limits: BTreeMap::new(), + artifact_registry: ArtifactRegistry::default(), + transport: NativeQuicTransport, + rendezvous_limits: ResourceLimits::community_tier_defaults(), + rendezvous_meter: ResourceMeter::default(), + download_limits: ResourceLimits::community_tier_defaults(), + download_meter: ResourceMeter::default(), + } + } + + pub fn handle_request( + &mut self, + request: CoordinatorRequest, + ) -> Result { + match request { + CoordinatorRequest::Ping => Ok(CoordinatorResponse::Pong { + epoch: self.coordinator.coordinator_epoch(), + }), + CoordinatorRequest::CreateProject { + tenant, + actor_user, + project, + name, + } => { + let tenant = TenantId::new(tenant); + let actor = UserId::new(actor_user); + let project = ProjectId::new(project); + if let Some(existing) = self.coordinator.project(&project) { + if existing.tenant != tenant { + return Err(CoordinatorError::Unauthorized( + "project id is outside the signed-in tenant scope".to_owned(), + ) + .into()); + } + } + self.coordinator.upsert_tenant(tenant.clone()); + self.coordinator.upsert_user( + tenant.clone(), + actor.clone(), + CredentialKind::BrowserSession, + ); + self.coordinator + .upsert_project(tenant.clone(), project.clone(), name); + self.coordinator.grant_project_debug( + tenant.clone(), + project.clone(), + actor.clone(), + ); + self.coordinator.persist(&mut self.store); + let project = self + .coordinator + .project(&project) + .expect("project was just created") + .clone(); + Ok(CoordinatorResponse::ProjectCreated { project, actor }) + } + CoordinatorRequest::SelectProject { + tenant, + actor_user, + project, + } => { + let tenant = TenantId::new(tenant); + let actor = UserId::new(actor_user); + let project_id = ProjectId::new(project); + let project = self + .coordinator + .project(&project_id) + .ok_or_else(|| { + CoordinatorError::Unauthorized( + "project is not visible to the signed-in user".to_owned(), + ) + })? + .clone(); + if project.tenant != tenant { + return Err(CoordinatorError::Unauthorized( + "project is outside the signed-in tenant scope".to_owned(), + ) + .into()); + } + self.coordinator + .upsert_user(tenant, actor.clone(), CredentialKind::BrowserSession); + self.coordinator.persist(&mut self.store); + Ok(CoordinatorResponse::ProjectSelected { project, actor }) + } + CoordinatorRequest::ListProjects { tenant, actor_user } => { + let tenant = TenantId::new(tenant); + let actor = UserId::new(actor_user); + let context = disasmer_core::AuthContext { + tenant: tenant.clone(), + project: ProjectId::from("__project_listing__"), + actor: Actor::User(actor.clone()), + }; + self.coordinator + .upsert_user(tenant, actor.clone(), CredentialKind::BrowserSession); + self.coordinator.persist(&mut self.store); + Ok(CoordinatorResponse::Projects { + projects: self.coordinator.list_projects(&context), + actor, + }) + } + CoordinatorRequest::AttachNode { + tenant, + project, + node, + public_key, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + self.coordinator.upsert_tenant(tenant.clone()); + self.coordinator.upsert_user( + tenant.clone(), + UserId::from("local-user"), + CredentialKind::CliDeviceSession, + ); + self.coordinator + .upsert_project(tenant.clone(), project.clone(), "local"); + self.coordinator.upsert_source_provider_config( + tenant.clone(), + project.clone(), + SourceProviderKind::Filesystem, + Digest::sha256("local-filesystem"), + ); + self.coordinator.enroll_node( + tenant.clone(), + project.clone(), + node.clone(), + public_key, + "node:attach", + ); + self.coordinator.persist(&mut self.store); + Ok(CoordinatorResponse::NodeAttached { + node, + tenant, + project, + }) + } + CoordinatorRequest::CreateNodeEnrollmentGrant { + tenant, + project, + actor_user, + grant, + now_epoch_seconds, + ttl_seconds, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + self.coordinator.upsert_tenant(tenant.clone()); + self.coordinator.upsert_user( + tenant.clone(), + actor, + CredentialKind::CliDeviceSession, + ); + self.coordinator + .upsert_project(tenant.clone(), project.clone(), "local"); + self.coordinator.upsert_source_provider_config( + tenant.clone(), + project.clone(), + SourceProviderKind::Filesystem, + Digest::sha256("local-filesystem"), + ); + let scope = "node:attach".to_owned(); + let expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds); + let enrollment = self.coordinator.create_node_enrollment_grant( + tenant.clone(), + project.clone(), + grant.clone(), + scope.clone(), + expires_at_epoch_seconds, + ); + self.enrollment_grants + .insert(enrollment_grant_key(&tenant, &project, &grant), enrollment); + self.coordinator.persist(&mut self.store); + Ok(CoordinatorResponse::NodeEnrollmentGrantCreated { + tenant, + project, + grant, + scope, + expires_at_epoch_seconds, + }) + } + CoordinatorRequest::ExchangeNodeEnrollmentGrant { + tenant, + project, + node, + public_key, + enrollment_grant, + now_epoch_seconds, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + let grant = self + .enrollment_grants + .get_mut(&enrollment_grant_key(&tenant, &project, &enrollment_grant)) + .ok_or(CoordinatorError::Enrollment( + disasmer_core::EnrollmentError::Expired, + ))?; + let credential = self.coordinator.exchange_node_enrollment_grant( + grant, + node.clone(), + &public_key, + "node:attach", + now_epoch_seconds, + )?; + self.coordinator.persist(&mut self.store); + Ok(CoordinatorResponse::NodeEnrollmentExchanged { + node, + tenant, + project, + credential, + }) + } + CoordinatorRequest::NodeHeartbeat { node } => { + let node = NodeId::new(node); + self.coordinator + .node_identity(&node) + .ok_or(CoordinatorError::UnknownNode)?; + Ok(CoordinatorResponse::NodeHeartbeat { + node, + epoch: self.coordinator.coordinator_epoch(), + }) + } + CoordinatorRequest::ReportNodeCapabilities { + tenant, + project, + node, + capabilities, + cached_environment_digests, + dependency_cache_digests, + source_snapshots, + artifact_locations, + direct_connectivity, + online, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + let identity = self + .coordinator + .node_identity(&node) + .ok_or(CoordinatorError::UnknownNode)?; + if identity.tenant != tenant || identity.project != project { + return Err(CoordinatorError::Unauthorized( + "node capability report is outside the enrolled tenant/project scope" + .to_owned(), + ) + .into()); + } + capabilities.validate_public_report()?; + + self.node_descriptors.insert( + node.clone(), + NodeDescriptor { + id: node.clone(), + tenant, + project, + capabilities, + cached_environments: cached_environment_digests.into_iter().collect(), + dependency_caches: dependency_cache_digests.into_iter().collect(), + source_snapshots: source_snapshots.into_iter().collect(), + artifact_locations: artifact_locations + .into_iter() + .map(ArtifactId::new) + .collect(), + direct_connectivity, + online, + }, + ); + Ok(CoordinatorResponse::NodeCapabilitiesRecorded { + node, + node_descriptors: self.node_descriptors.len(), + }) + } + CoordinatorRequest::ListNodeDescriptors { + tenant, + project, + actor_user, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + self.coordinator.upsert_user( + tenant.clone(), + actor.clone(), + CredentialKind::BrowserSession, + ); + let descriptors = self + .node_descriptors + .values() + .filter(|descriptor| { + descriptor.tenant == tenant && descriptor.project == project + }) + .cloned() + .collect(); + Ok(CoordinatorResponse::NodeDescriptors { descriptors, actor }) + } + CoordinatorRequest::ScheduleTask { + tenant, + project, + environment, + environment_digest, + required_capabilities, + dependency_cache, + source_snapshot, + required_artifacts, + quota_available, + policy_allowed, + prefer_node, + } => { + let request = PlacementRequest { + tenant: TenantId::new(tenant), + project: ProjectId::new(project), + environment, + environment_digest, + required_capabilities: required_capabilities.into_iter().collect(), + dependency_cache, + source_snapshot, + required_artifacts: required_artifacts + .into_iter() + .map(ArtifactId::new) + .collect(), + quota_available, + policy_allowed, + prefer_node: prefer_node.map(NodeId::new), + }; + let nodes = self.node_descriptors.values().cloned().collect::>(); + let placement = DefaultScheduler.place(&nodes, &request)?; + Ok(CoordinatorResponse::TaskPlacement { placement }) + } + CoordinatorRequest::LaunchTask { + tenant, + project, + actor_user, + process, + task, + environment, + environment_digest, + required_capabilities, + dependency_cache, + source_snapshot, + required_artifacts, + quota_available, + policy_allowed, + command, + command_args, + artifact_path, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let task = TaskId::new(task); + let actor = UserId::new(actor_user); + self.coordinator + .upsert_user(tenant.clone(), actor, CredentialKind::BrowserSession); + let active = self.coordinator.active_process(&process).ok_or_else(|| { + CoordinatorError::Unauthorized( + "task launch requires an active coordinator-side virtual process" + .to_owned(), + ) + })?; + if active.tenant != tenant || active.project != project { + return Err(CoordinatorError::Unauthorized( + "task launch is outside the virtual process tenant/project scope" + .to_owned(), + ) + .into()); + } + let request = PlacementRequest { + tenant: tenant.clone(), + project: project.clone(), + environment, + environment_digest, + required_capabilities: required_capabilities.into_iter().collect(), + dependency_cache, + source_snapshot, + required_artifacts: required_artifacts + .into_iter() + .map(ArtifactId::new) + .collect(), + quota_available, + policy_allowed, + prefer_node: None, + }; + let nodes = self.node_descriptors.values().cloned().collect::>(); + let placement = DefaultScheduler.place(&nodes, &request)?; + let assignment = TaskAssignment { + tenant: tenant.clone(), + project: project.clone(), + process: process.clone(), + task: task.clone(), + node: placement.node.clone(), + epoch: active.coordinator_epoch, + command, + command_args, + artifact_path, + }; + self.task_assignments + .entry((tenant, project, placement.node.clone())) + .or_default() + .push_back(assignment.clone()); + Ok(CoordinatorResponse::TaskLaunched { + process, + task, + placement, + assignment, + }) + } + CoordinatorRequest::PollTaskAssignment { + tenant, + project, + node, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + let identity = self + .coordinator + .node_identity(&node) + .ok_or(CoordinatorError::UnknownNode)?; + if identity.tenant != tenant || identity.project != project { + return Err(CoordinatorError::Unauthorized( + "task assignment poll is outside the enrolled tenant/project scope" + .to_owned(), + ) + .into()); + } + let assignment = self + .task_assignments + .get_mut(&(tenant, project, node)) + .and_then(VecDeque::pop_front); + Ok(CoordinatorResponse::TaskAssignment { assignment }) + } + CoordinatorRequest::RequestRendezvous { + scope, + source, + destination, + direct_connectivity, + failure_reason, + } => { + self.rendezvous_meter.charge( + &self.rendezvous_limits, + LimitKind::RendezvousAttempt, + 1, + )?; + let plan = self.transport.plan_authenticated_direct_bulk_transfer( + RendezvousRequest { + scope, + source, + destination, + }, + direct_connectivity, + failure_reason, + )?; + Ok(CoordinatorResponse::RendezvousPlan { + plan, + charged_rendezvous_attempts: self + .rendezvous_meter + .used(&LimitKind::RendezvousAttempt), + }) + } + CoordinatorRequest::RequestSourcePreparation { + tenant, + project, + provider, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let preparation = + SourcePreparation::node_task(tenant.clone(), project.clone(), provider); + let request = PlacementRequest { + tenant, + project, + environment: None, + environment_digest: None, + required_capabilities: preparation.required_capabilities.clone(), + dependency_cache: None, + source_snapshot: None, + required_artifacts: Default::default(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + let nodes = self.node_descriptors.values().cloned().collect::>(); + let disposition = match DefaultScheduler.place(&nodes, &request) { + Ok(placement) => SourcePreparationDisposition::Assigned { + node: placement.node, + }, + Err(err) => SourcePreparationDisposition::Pending { + reason: if err.message.is_empty() { + "waiting for any capable node to prepare source".to_owned() + } else { + err.message + }, + }, + }; + Ok(CoordinatorResponse::SourcePreparation { + status: SourcePreparationStatus { + preparation, + disposition, + }, + }) + } + CoordinatorRequest::CompleteSourcePreparation { + tenant, + project, + node, + provider, + source_snapshot, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let node = NodeId::new(node); + let identity = self + .coordinator + .node_identity(&node) + .ok_or(CoordinatorError::UnknownNode)?; + if identity.tenant != tenant || identity.project != project { + return Err(CoordinatorError::Unauthorized( + "source preparation completion is outside the enrolled tenant/project scope" + .to_owned(), + ) + .into()); + } + let descriptor = self.node_descriptors.get_mut(&node).ok_or_else(|| { + CoordinatorError::Unauthorized( + "source preparation completion requires a node capability report" + .to_owned(), + ) + })?; + descriptor.source_snapshots.insert(source_snapshot.clone()); + Ok(CoordinatorResponse::SourcePreparationCompleted { + node, + provider, + source_snapshot, + }) + } + CoordinatorRequest::StartProcess { + tenant, + project, + process, + } => { + let process = ProcessId::new(process); + self.coordinator.start_process( + TenantId::new(tenant), + ProjectId::new(project), + process.clone(), + ); + Ok(CoordinatorResponse::ProcessStarted { + process, + epoch: self.coordinator.coordinator_epoch(), + }) + } + CoordinatorRequest::ReconnectNode { + node, + process, + epoch, + } => { + let node = NodeId::new(node); + let process = ProcessId::new(process); + self.coordinator + .reconnect_node(&node, Some((&process, epoch)))?; + Ok(CoordinatorResponse::NodeReconnected { node, process }) + } + CoordinatorRequest::CancelTask { + tenant, + project, + process, + node, + task, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskId::new(task); + self.coordinator + .authorize_node_for_process(&node, &tenant, &project, &process)?; + let active = self.coordinator.active_process(&process).ok_or_else(|| { + CoordinatorError::Unauthorized( + "task cancellation requires an active virtual process".to_owned(), + ) + })?; + if !active.connected_nodes.contains(&node) { + return Err(CoordinatorError::Unauthorized( + "task cancellation target node is not connected to the virtual process" + .to_owned(), + ) + .into()); + } + self.task_cancellations + .insert(task_control_key(&tenant, &project, &process, &node, &task)); + Ok(CoordinatorResponse::TaskCancellationRequested { + process, + task, + node, + }) + } + CoordinatorRequest::PollTaskControl { + tenant, + project, + process, + node, + task, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskId::new(task); + self.coordinator + .authorize_node_for_process(&node, &tenant, &project, &process)?; + let cancel_requested = self + .task_cancellations + .contains(&task_control_key(&tenant, &project, &process, &node, &task)); + Ok(CoordinatorResponse::TaskControl { + process, + task, + cancel_requested, + }) + } + CoordinatorRequest::PollDebugCommand { + tenant, + project, + process, + node, + task, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskId::new(task); + self.coordinator + .authorize_node_for_process(&node, &tenant, &project, &process)?; + Ok(CoordinatorResponse::DebugCommand { + process, + task, + command: None, + }) + } + CoordinatorRequest::ReportTaskLog { + tenant, + project, + process, + node, + task, + stdout_bytes, + stderr_bytes, + stdout_tail, + stderr_tail, + stdout_truncated, + stderr_truncated, + backpressured, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskId::new(task); + self.coordinator + .authorize_node_for_process(&node, &tenant, &project, &process)?; + validate_task_log_tail("stdout_tail", &stdout_tail)?; + validate_task_log_tail("stderr_tail", &stderr_tail)?; + Ok(CoordinatorResponse::TaskLogRecorded { + process, + task, + stdout_bytes, + stderr_bytes, + stdout_tail: if stdout_truncated { + format!("{stdout_tail}\n... truncated") + } else { + stdout_tail + }, + stderr_tail: if stderr_truncated { + format!("{stderr_tail}\n... truncated") + } else { + stderr_tail + }, + backpressured, + }) + } + CoordinatorRequest::ReportVfsMetadata { + tenant, + project, + process, + node, + task, + artifact_path, + artifact_digest, + artifact_size_bytes, + large_bytes_uploaded, + } => { + let artifact_path = artifact_path + .map(VfsPath::new) + .transpose() + .map_err(|err| CoordinatorServiceError::InvalidArtifactPath(err.to_string()))?; + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let node = NodeId::new(node); + let task = TaskId::new(task); + self.coordinator + .authorize_node_for_process(&node, &tenant, &project, &process)?; + if let (Some(path), Some(digest)) = (&artifact_path, artifact_digest) { + self.artifact_registry.flush_metadata( + artifact_id_from_path(path), + tenant, + project, + process.clone(), + task.clone(), + node, + digest, + artifact_size_bytes.unwrap_or_default(), + ); + } + Ok(CoordinatorResponse::VfsMetadataRecorded { + process, + task, + artifact_path, + large_bytes_uploaded, + }) + } + CoordinatorRequest::TaskCompleted { + tenant, + project, + process, + node, + task, + terminal_state, + status_code, + stdout_bytes, + stderr_bytes, + stdout_tail, + stderr_tail, + stdout_truncated, + stderr_truncated, + artifact_path, + artifact_digest, + artifact_size_bytes, + } => { + validate_task_log_tail("stdout_tail", &stdout_tail)?; + validate_task_log_tail("stderr_tail", &stderr_tail)?; + let artifact_path = artifact_path + .map(VfsPath::new) + .transpose() + .map_err(|err| CoordinatorServiceError::InvalidArtifactPath(err.to_string()))?; + let event = TaskCompletionEvent { + tenant: TenantId::new(tenant), + project: ProjectId::new(project), + process: ProcessId::new(process), + node: NodeId::new(node), + task: TaskId::new(task), + terminal_state: terminal_state + .unwrap_or_else(|| TaskTerminalState::from_status_code(status_code)), + status_code, + stdout_bytes, + stderr_bytes, + stdout_tail, + stderr_tail, + stdout_truncated, + stderr_truncated, + artifact_path, + artifact_digest: artifact_digest.clone(), + }; + self.coordinator.authorize_node_for_process( + &event.node, + &event.tenant, + &event.project, + &event.process, + )?; + if let (Some(path), Some(digest)) = (&event.artifact_path, artifact_digest) { + self.artifact_registry.flush_metadata( + artifact_id_from_path(path), + event.tenant.clone(), + event.project.clone(), + event.process.clone(), + event.task.clone(), + event.node.clone(), + digest, + artifact_size_bytes.unwrap_or(stdout_bytes), + ); + } + self.task_cancellations.remove(&task_control_key( + &event.tenant, + &event.project, + &event.process, + &event.node, + &event.task, + )); + self.task_events.push(event.clone()); + Ok(CoordinatorResponse::TaskRecorded { + process: event.process, + task: event.task, + events_recorded: self.task_events.len(), + }) + } + CoordinatorRequest::ListTaskEvents { + tenant, + project, + actor_user, + process, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let actor = UserId::new(actor_user); + let process = process.map(ProcessId::new); + self.coordinator + .upsert_user(tenant.clone(), actor, CredentialKind::BrowserSession); + let events = self + .task_events + .iter() + .filter(|event| { + event.tenant == tenant + && event.project == project + && process + .as_ref() + .map_or(true, |process| event.process == *process) + }) + .cloned() + .collect(); + Ok(CoordinatorResponse::TaskEvents { events }) + } + CoordinatorRequest::RenderOperatorPanel { + tenant, + project, + process, + actor_user, + max_download_bytes, + stopped, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let stop_key = panel_stop_key(&tenant, &project, &process); + + let panel = if stopped { + self.ensure_operator_panel_scope(&tenant, &project, &process)?; + self.stopped_panels.insert(stop_key); + let stop_key = panel_stop_key(&tenant, &project, &process); + let panel = match self.panel_snapshots.get(&stop_key).cloned() { + Some(panel) => stopped_panel_snapshot(panel), + None => self.render_operator_panel( + tenant.clone(), + project.clone(), + process.clone(), + UserId::new(actor_user), + max_download_bytes, + true, + )?, + }; + self.panel_snapshots.insert(stop_key, panel.clone()); + panel + } else { + self.stopped_panels.remove(&stop_key); + let panel = self.render_operator_panel( + tenant.clone(), + project.clone(), + process.clone(), + UserId::new(actor_user), + max_download_bytes, + false, + )?; + self.panel_snapshots.insert(stop_key, panel.clone()); + panel + }; + Ok(CoordinatorResponse::OperatorPanel { panel }) + } + CoordinatorRequest::SubmitPanelEvent { + tenant, + project, + process, + widget_id, + kind, + max_events, + } => { + let tenant = TenantId::new(tenant); + let project = ProjectId::new(project); + let process = ProcessId::new(process); + let stop_key = panel_stop_key(&tenant, &project, &process); + let stopped = self.stopped_panels.contains(&stop_key); + let panel = if stopped { + match self.panel_snapshots.get(&stop_key).cloned() { + Some(panel) => stopped_panel_snapshot(panel), + None => { + let panel = self.render_operator_panel( + tenant.clone(), + project.clone(), + process.clone(), + UserId::from("panel-user"), + self.download_limits + .limit(&LimitKind::ArtifactDownloadBytes), + true, + )?; + self.panel_snapshots.insert(stop_key.clone(), panel.clone()); + panel + } + } + } else { + let panel = self.render_operator_panel( + tenant.clone(), + project.clone(), + process.clone(), + UserId::from("panel-user"), + self.download_limits + .limit(&LimitKind::ArtifactDownloadBytes), + false, + )?; + self.panel_snapshots.insert(stop_key, panel.clone()); + panel + }; + let event = PanelEvent { + tenant: tenant.clone(), + project: project.clone(), + process: process.clone(), + widget_id: widget_id.clone(), + kind, + }; + let limit = self + .panel_event_limits + .entry((tenant, project, process, widget_id)) + .or_insert(RateLimit { + max_events, + used_events: 0, + }); + limit.max_events = max_events; + panel.accept_event(&event, limit)?; + Ok(CoordinatorResponse::PanelEventAccepted { + used_events: limit.used_events, + max_events: limit.max_events, + }) + } + CoordinatorRequest::CreateArtifactDownloadLink { + tenant, + project, + actor_user, + artifact, + max_bytes, + token_nonce, + now_epoch_seconds, + ttl_seconds, + } => { + let context = user_context(tenant, project, actor_user); + let artifact = ArtifactId::new(artifact); + let policy = DownloadPolicy { max_bytes }; + let action = self + .artifact_registry + .download_action(&context, &artifact, &policy)?; + self.ensure_download_source_connectivity(&action.source)?; + let downloadable_size = self + .artifact_registry + .downloadable_size(&context, &artifact, &policy)?; + self.download_meter.can_charge( + &self.download_limits, + LimitKind::ArtifactDownloadBytes, + downloadable_size, + )?; + let link = self.artifact_registry.create_download_link( + &context, + &artifact, + &policy, + &token_nonce, + now_epoch_seconds, + ttl_seconds, + )?; + Ok(CoordinatorResponse::ArtifactDownloadLink { link }) + } + CoordinatorRequest::OpenArtifactDownloadStream { + tenant, + project, + actor_user, + artifact, + max_bytes, + token_nonce, + token_digest, + now_epoch_seconds, + chunk_bytes, + } => { + let _ = token_nonce; + let context = user_context(tenant, project, actor_user); + let mut stream = self.artifact_registry.open_download_stream( + &context, + &ArtifactId::new(artifact), + &DownloadPolicy { max_bytes }, + &token_digest, + now_epoch_seconds, + &self.download_limits, + &mut self.download_meter, + )?; + self.ensure_download_source_connectivity(&stream.link.source)?; + self.artifact_registry.stream_download_chunk( + &mut stream, + &self.download_limits, + &mut self.download_meter, + chunk_bytes, + )?; + let charged_download_bytes = + self.download_meter.used(&LimitKind::ArtifactDownloadBytes); + Ok(CoordinatorResponse::ArtifactDownloadStream { + link: stream.link, + streamed_bytes: stream.streamed_bytes, + charged_download_bytes, + }) + } + CoordinatorRequest::RevokeArtifactDownloadLink { + tenant, + project, + actor_user, + artifact, + token_digest, + } => { + let context = user_context(tenant, project, actor_user); + let link = self.artifact_registry.revoke_download_link( + &context, + &ArtifactId::new(artifact), + &token_digest, + )?; + Ok(CoordinatorResponse::ArtifactDownloadLinkRevoked { link }) + } + CoordinatorRequest::ExportArtifactToNode { + tenant, + project, + actor_user, + artifact, + receiver_node, + direct_connectivity, + failure_reason, + } => { + let context = user_context(tenant, project, actor_user); + let artifact = ArtifactId::new(artifact); + let receiver_node = NodeId::new(receiver_node); + let action = self.artifact_registry.download_action( + &context, + &artifact, + &DownloadPolicy { + max_bytes: u64::MAX, + }, + )?; + let StorageLocation::RetainedNode(source_node) = action.source else { + return Err(disasmer_core::DownloadError::Unavailable.into()); + }; + let metadata = self + .artifact_registry + .metadata(&artifact) + .ok_or(disasmer_core::DownloadError::NotFound)?; + let source = + self.export_endpoint(&source_node, &context.tenant, &context.project)?; + let destination = + self.export_endpoint(&receiver_node, &context.tenant, &context.project)?; + let plan = self.transport.plan_authenticated_direct_bulk_transfer( + RendezvousRequest { + scope: DataPlaneScope { + tenant: context.tenant.clone(), + project: context.project.clone(), + process: metadata.process.clone(), + object: DataPlaneObject::Artifact(artifact.clone()), + authorization_subject: format!( + "artifact-export:{}-to-{}", + source_node, receiver_node + ), + }, + source, + destination, + }, + direct_connectivity, + failure_reason, + )?; + Ok(CoordinatorResponse::ArtifactExportPlan { + plan, + source_node, + receiver_node, + }) + } + } + } + + fn export_endpoint( + &self, + node: &NodeId, + tenant: &TenantId, + project: &ProjectId, + ) -> Result { + let identity = self + .coordinator + .node_identity(node) + .ok_or(CoordinatorError::UnknownNode)?; + if &identity.tenant != tenant || &identity.project != project { + return Err(CoordinatorError::Unauthorized( + "artifact export node is outside the tenant/project scope".to_owned(), + ) + .into()); + } + let descriptor = self.node_descriptors.get(node).ok_or_else(|| { + disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + "node {node} has not reported export connectivity" + )) + })?; + if descriptor.tenant != *tenant || descriptor.project != *project { + return Err(CoordinatorError::Unauthorized( + "artifact export node descriptor is outside the tenant/project scope".to_owned(), + ) + .into()); + } + if !descriptor.online { + return Err( + disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + "node {node} is offline for artifact export" + )) + .into(), + ); + } + if !descriptor.direct_connectivity { + return Err( + disasmer_core::DownloadError::DirectConnectivityUnavailable(format!( + "direct connectivity unavailable to node {node} for artifact export" + )) + .into(), + ); + } + Ok(NodeEndpoint { + node: node.clone(), + advertised_addr: format!("{node}.mesh.invalid:4433"), + public_key_fingerprint: Digest::sha256(&identity.public_key), + }) + } + + fn ensure_download_source_connectivity( + &self, + source: &StorageLocation, + ) -> Result<(), disasmer_core::DownloadError> { + let StorageLocation::RetainedNode(node) = source else { + return Ok(()); + }; + let Some(descriptor) = self.node_descriptors.get(node) else { + return Ok(()); + }; + if !descriptor.online { + return Err(disasmer_core::DownloadError::DirectConnectivityUnavailable( + format!("retaining node {node} is offline for artifact download"), + )); + } + if !descriptor.direct_connectivity { + return Err(disasmer_core::DownloadError::DirectConnectivityUnavailable( + format!("direct connectivity unavailable to retaining node {node} for artifact download"), + )); + } + Ok(()) + } + + fn render_operator_panel( + &self, + tenant: TenantId, + project: ProjectId, + process: ProcessId, + actor_user: UserId, + max_download_bytes: u64, + stopped: bool, + ) -> Result { + self.ensure_operator_panel_scope(&tenant, &project, &process)?; + + let events = self + .task_events + .iter() + .filter(|event| { + event.tenant == tenant && event.project == project && event.process == process + }) + .collect::>(); + let completed = events + .iter() + .filter(|event| event.status_code == Some(0)) + .count() as u64; + let total = events.len().max(1) as u64; + let stdout_bytes = events.iter().map(|event| event.stdout_bytes).sum::(); + let stderr_bytes = events.iter().map(|event| event.stderr_bytes).sum::(); + let last_task = events.last().map(|event| event.task.clone()); + + let mut panel = PanelState::new(tenant.clone(), project.clone(), process.clone()); + if stopped { + panel.freeze_program_ui_events(); + } + panel.add_widget(PanelWidget { + id: "process-status".to_owned(), + label: "Process Status".to_owned(), + kind: PanelWidgetKind::Text { + value: if stopped { + "stopped".to_owned() + } else { + "running".to_owned() + }, + }, + })?; + panel.add_widget(PanelWidget { + id: "task-progress".to_owned(), + label: "Tasks".to_owned(), + kind: PanelWidgetKind::Progress { + current: completed, + total, + }, + })?; + panel.add_widget(PanelWidget { + id: "task-summary".to_owned(), + label: "Task Summary".to_owned(), + kind: PanelWidgetKind::Text { + value: if events.is_empty() { + "no task events recorded".to_owned() + } else { + events + .iter() + .map(|event| { + format!("{}:{:?}:{}", event.task, event.status_code, event.node) + }) + .collect::>() + .join(", ") + }, + }, + })?; + panel.add_widget(PanelWidget { + id: "recent-logs".to_owned(), + label: "Recent Logs".to_owned(), + kind: PanelWidgetKind::Text { + value: format!("stdout={stdout_bytes} stderr={stderr_bytes}"), + }, + })?; + panel.add_widget(PanelWidget { + id: "debug-process".to_owned(), + label: "Debug Process".to_owned(), + kind: PanelWidgetKind::Button { + action: "debug-process".to_owned(), + }, + })?; + panel.add_widget(PanelWidget { + id: "cancel-process".to_owned(), + label: "Cancel Process".to_owned(), + kind: PanelWidgetKind::Button { + action: "cancel-process".to_owned(), + }, + })?; + if last_task.is_some() { + panel.add_widget(PanelWidget { + id: "restart-selected-task".to_owned(), + label: "Restart Selected Task".to_owned(), + kind: PanelWidgetKind::Button { + action: "restart-task".to_owned(), + }, + })?; + } + + let mut actions = vec![ + disasmer_core::ControlPlaneAction::DebugProcess, + disasmer_core::ControlPlaneAction::CancelProcess, + ]; + if let Some(task) = last_task.clone() { + actions.push(disasmer_core::ControlPlaneAction::RestartTask(task)); + } + panel.set_control_plane_actions(actions); + + if let Some(path) = events + .iter() + .rev() + .find_map(|event| event.artifact_path.as_ref()) + { + let artifact = artifact_id_from_path(path); + let context = disasmer_core::AuthContext { + tenant, + project, + actor: Actor::User(actor_user), + }; + panel.add_download_widget_from_action( + "download-artifact", + "Download Artifact", + self.artifact_registry.download_action( + &context, + &artifact, + &DownloadPolicy { + max_bytes: max_download_bytes, + }, + ), + )?; + } + + Ok(panel) + } + + fn ensure_operator_panel_scope( + &self, + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + ) -> Result<(), CoordinatorServiceError> { + let active = self.coordinator.active_process(process).ok_or_else(|| { + CoordinatorError::Unauthorized( + "operator panel requires an active virtual process".to_owned(), + ) + })?; + if active.tenant != *tenant || active.project != *project { + return Err(CoordinatorError::Unauthorized( + "operator panel scope does not match the active virtual process".to_owned(), + ) + .into()); + } + Ok(()) + } + + pub fn serve_tcp(self, listener: TcpListener) -> Result<(), CoordinatorServiceError> { + let shared = Arc::new(Mutex::new(self)); + for stream in listener.incoming() { + let stream = stream?; + let service = Arc::clone(&shared); + std::thread::spawn(move || { + if let Err(err) = handle_shared_stream(service, stream) { + eprintln!("coordinator stream failed: {err}"); + } + }); + } + Ok(()) + } + + pub fn handle_stream(&mut self, stream: TcpStream) -> Result<(), CoordinatorServiceError> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut writer = stream; + loop { + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + return Ok(()); + } + if line.trim().is_empty() { + continue; + } + let response = match serde_json::from_str::(&line) { + Ok(request) => match self.handle_request(request) { + Ok(response) => response, + Err(err) => CoordinatorResponse::Error { + message: err.to_string(), + }, + }, + Err(err) => CoordinatorResponse::Error { + message: err.to_string(), + }, + }; + serde_json::to_writer(&mut writer, &response)?; + writer.write_all(b"\n")?; + writer.flush()?; + } + } +} + +fn handle_shared_stream( + service: Arc>, + stream: TcpStream, +) -> Result<(), CoordinatorServiceError> { + let mut reader = BufReader::new(stream.try_clone()?); + let mut writer = stream; + loop { + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + return Ok(()); + } + if line.trim().is_empty() { + continue; + } + let response = match serde_json::from_str::(&line) { + Ok(request) => match service.lock() { + Ok(mut service) => match service.handle_request(request) { + Ok(response) => response, + Err(err) => CoordinatorResponse::Error { + message: err.to_string(), + }, + }, + Err(_) => CoordinatorResponse::Error { + message: "coordinator service lock poisoned".to_owned(), + }, + }, + Err(err) => CoordinatorResponse::Error { + message: err.to_string(), + }, + }; + serde_json::to_writer(&mut writer, &response)?; + writer.write_all(b"\n")?; + writer.flush()?; + } +} + +pub fn bind_listener(addr: &str) -> Result<(TcpListener, SocketAddr), CoordinatorServiceError> { + let listener = TcpListener::bind(addr)?; + let addr = listener.local_addr()?; + Ok((listener, addr)) +} + +fn user_context(tenant: String, project: String, actor_user: String) -> disasmer_core::AuthContext { + disasmer_core::AuthContext { + tenant: TenantId::new(tenant), + project: ProjectId::new(project), + actor: Actor::User(UserId::new(actor_user)), + } +} + +fn artifact_id_from_path(path: &VfsPath) -> ArtifactId { + let value = path + .as_str() + .strip_prefix("/vfs/artifacts/") + .unwrap_or(path.as_str()) + .replace('/', ":"); + ArtifactId::new(value) +} + +fn validate_task_log_tail(kind: &str, value: &str) -> Result<(), CoordinatorServiceError> { + if value.len() > MAX_TASK_LOG_TAIL_BYTES { + return Err(CoordinatorServiceError::InvalidTaskLogTail(format!( + "{kind} is {} bytes; max is {MAX_TASK_LOG_TAIL_BYTES}", + value.len() + ))); + } + Ok(()) +} + +fn task_control_key( + tenant: &TenantId, + project: &ProjectId, + process: &ProcessId, + node: &NodeId, + task: &TaskId, +) -> TaskControlKey { + ( + tenant.clone(), + project.clone(), + process.clone(), + node.clone(), + task.clone(), + ) +} + +fn panel_stop_key(tenant: &TenantId, project: &ProjectId, process: &ProcessId) -> PanelStopKey { + (tenant.clone(), project.clone(), process.clone()) +} + +fn enrollment_grant_key(tenant: &TenantId, project: &ProjectId, grant: &str) -> EnrollmentGrantKey { + (tenant.clone(), project.clone(), grant.to_owned()) +} + +fn stopped_panel_snapshot(mut panel: PanelState) -> PanelState { + panel.freeze_program_ui_events(); + if let Some(status) = panel.widgets.get_mut("process-status") { + status.kind = PanelWidgetKind::Text { + value: "stopped".to_owned(), + }; + } + panel +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + + use disasmer_core::{DataPlaneObject, EnvironmentBackend, Os}; + + use super::*; + + fn linux_capabilities() -> NodeCapabilities { + NodeCapabilities { + os: Os::Linux, + arch: "x86_64".to_owned(), + capabilities: BTreeSet::from([ + Capability::Command, + Capability::Containers, + Capability::RootlessPodman, + Capability::VfsArtifacts, + ]), + environment_backends: BTreeSet::from([EnvironmentBackend::Container]), + source_providers: BTreeSet::from(["filesystem".to_owned()]), + } + } + + fn windows_capabilities() -> NodeCapabilities { + NodeCapabilities { + os: Os::Windows, + arch: "x86_64".to_owned(), + capabilities: BTreeSet::from([ + Capability::Command, + Capability::WindowsCommandDev, + Capability::VfsArtifacts, + ]), + environment_backends: BTreeSet::from([EnvironmentBackend::WindowsCommandDev]), + source_providers: BTreeSet::from(["filesystem".to_owned()]), + } + } + + fn endpoint(name: &str) -> NodeEndpoint { + NodeEndpoint { + node: NodeId::from(name), + advertised_addr: format!("{name}.mesh.invalid:4433"), + public_key_fingerprint: Digest::sha256(format!("{name}-public-key")), + } + } + + fn data_plane_scope(project: &str) -> DataPlaneScope { + DataPlaneScope { + tenant: TenantId::from("tenant"), + project: ProjectId::from(project), + process: ProcessId::from("process"), + object: DataPlaneObject::Artifact(ArtifactId::from("artifact")), + authorization_subject: "node-a-to-node-b".to_owned(), + } + } + + #[test] + fn service_creates_selects_and_lists_signed_in_user_projects() { + let mut service = CoordinatorService::new(7); + + let CoordinatorResponse::ProjectCreated { project, actor } = service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant-a".to_owned(), + actor_user: "user-a".to_owned(), + project: "project-a".to_owned(), + name: "Demo".to_owned(), + }) + .unwrap() + else { + panic!("expected project creation"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert_eq!(project.tenant, TenantId::from("tenant-a")); + assert_eq!(project.id, ProjectId::from("project-a")); + assert_eq!(project.name, "Demo"); + + let CoordinatorResponse::ProjectSelected { project, actor } = service + .handle_request(CoordinatorRequest::SelectProject { + tenant: "tenant-a".to_owned(), + actor_user: "user-a".to_owned(), + project: "project-a".to_owned(), + }) + .unwrap() + else { + panic!("expected project selection"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert_eq!(project.id, ProjectId::from("project-a")); + + service + .handle_request(CoordinatorRequest::CreateProject { + tenant: "tenant-b".to_owned(), + actor_user: "user-b".to_owned(), + project: "project-b".to_owned(), + name: "Other".to_owned(), + }) + .unwrap(); + + let CoordinatorResponse::Projects { projects, actor } = service + .handle_request(CoordinatorRequest::ListProjects { + tenant: "tenant-a".to_owned(), + actor_user: "user-a".to_owned(), + }) + .unwrap() + else { + panic!("expected project list"); + }; + assert_eq!(actor, UserId::from("user-a")); + assert_eq!(projects.len(), 1); + assert_eq!(projects[0].id, ProjectId::from("project-a")); + + let cross_tenant = service + .handle_request(CoordinatorRequest::SelectProject { + tenant: "tenant-a".to_owned(), + actor_user: "user-a".to_owned(), + project: "project-b".to_owned(), + }) + .unwrap_err(); + assert!(cross_tenant.to_string().contains("tenant scope")); + } + + #[test] + fn service_attaches_node_starts_process_and_records_scoped_task_event() { + let mut service = CoordinatorService::new(7); + + let attached = service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: "public-key".to_owned(), + }) + .unwrap(); + assert!(matches!(attached, CoordinatorResponse::NodeAttached { .. })); + + let started = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + }) + .unwrap(); + assert_eq!( + started, + CoordinatorResponse::ProcessStarted { + process: ProcessId::from("process"), + epoch: 7 + } + ); + + let heartbeat = service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "node".to_owned(), + }) + .unwrap(); + assert_eq!( + heartbeat, + CoordinatorResponse::NodeHeartbeat { + node: NodeId::from("node"), + epoch: 7, + } + ); + + service + .handle_request(CoordinatorRequest::ReconnectNode { + node: "node".to_owned(), + process: "process".to_owned(), + epoch: 7, + }) + .unwrap(); + let recorded = service + .handle_request(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 12, + stderr_bytes: 0, + stdout_tail: "build ok".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/app.txt".to_owned()), + artifact_digest: Some(Digest::sha256("artifact")), + artifact_size_bytes: Some(12), + }) + .unwrap(); + + assert_eq!( + recorded, + CoordinatorResponse::TaskRecorded { + process: ProcessId::from("process"), + task: TaskId::from("compile-linux"), + events_recorded: 1 + } + ); + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert_eq!(events.len(), 1); + assert_eq!(events[0].node, NodeId::from("node")); + assert_eq!(events[0].stdout_tail, "build ok"); + assert_eq!(events[0].stderr_tail, ""); + assert!(!events[0].stdout_truncated); + + let oversized_tail = "x".repeat(MAX_TASK_LOG_TAIL_BYTES + 1); + let oversized_log = service + .handle_request(CoordinatorRequest::ReportTaskLog { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + stdout_bytes: oversized_tail.len() as u64, + stderr_bytes: 0, + stdout_tail: oversized_tail.clone(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + backpressured: false, + }) + .unwrap_err(); + assert!(oversized_log.to_string().contains("stdout_tail")); + + let oversized_completion = service + .handle_request(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: oversized_tail.len() as u64, + stderr_bytes: 0, + stdout_tail: oversized_tail, + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + }) + .unwrap_err(); + assert!(oversized_completion.to_string().contains("stdout_tail")); + + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "other".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert!(events.is_empty()); + + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("other-process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert!(events.is_empty()); + } + + #[test] + fn service_delivers_cancellation_to_connected_node_and_records_terminal_state() { + let mut service = CoordinatorService::new(7); + + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: "public-key".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::ReconnectNode { + node: "node".to_owned(), + process: "process".to_owned(), + epoch: 7, + }) + .unwrap(); + + let cancelled = service + .handle_request(CoordinatorRequest::CancelTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap(); + assert_eq!( + cancelled, + CoordinatorResponse::TaskCancellationRequested { + process: ProcessId::from("process"), + task: TaskId::from("compile-linux"), + node: NodeId::from("node"), + } + ); + + let control = service + .handle_request(CoordinatorRequest::PollTaskControl { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap(); + assert_eq!( + control, + CoordinatorResponse::TaskControl { + process: ProcessId::from("process"), + task: TaskId::from("compile-linux"), + cancel_requested: true, + } + ); + + service + .handle_request(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: Some(TaskTerminalState::Cancelled), + status_code: None, + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + artifact_digest: None, + artifact_size_bytes: None, + }) + .unwrap(); + + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert_eq!(events[0].terminal_state, TaskTerminalState::Cancelled); + + let control = service + .handle_request(CoordinatorRequest::PollTaskControl { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + }) + .unwrap(); + assert_eq!( + control, + CoordinatorResponse::TaskControl { + process: ProcessId::from("process"), + task: TaskId::from("compile-linux"), + cancel_requested: false, + } + ); + } + + #[test] + fn service_download_links_are_scoped_and_streaming_is_metered() { + let mut service = CoordinatorService::new(7); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: "public-key".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 32, + stderr_bytes: 0, + stdout_tail: "artifact bytes".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/app.txt".to_owned()), + artifact_digest: Some(Digest::sha256("artifact")), + artifact_size_bytes: Some(32), + }) + .unwrap(); + + service.download_limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 31)]), + }; + let quota_denied_link = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "quota-denied".to_owned(), + now_epoch_seconds: 10, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(quota_denied_link + .to_string() + .contains("ArtifactDownloadBytes")); + service.download_limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 64)]), + }; + + service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: false, + online: true, + }) + .unwrap(); + let disconnected_link = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "disconnected".to_owned(), + now_epoch_seconds: 10, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(disconnected_link + .to_string() + .contains("direct connectivity unavailable")); + + service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + + let CoordinatorResponse::ArtifactDownloadLink { link } = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + now_epoch_seconds: 10, + ttl_seconds: 60, + }) + .unwrap() + else { + panic!("expected download link"); + }; + + service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: false, + online: true, + }) + .unwrap(); + let disconnected_open = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + token_digest: link.scoped_token_digest.clone(), + now_epoch_seconds: 11, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(disconnected_open + .to_string() + .contains("direct connectivity unavailable")); + + service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + + assert_eq!(link.tenant, TenantId::from("tenant")); + assert_eq!(link.project, ProjectId::from("project")); + assert_eq!(link.process, ProcessId::from("process")); + assert_eq!(link.actor, Actor::User(UserId::from("user"))); + assert_eq!(link.max_bytes, 64); + assert!(link.policy_context_digest.is_valid_sha256()); + assert_eq!(link.expires_at_epoch_seconds, 70); + assert!(link.url_path.contains("/artifacts/tenant/project/process")); + + let cross_tenant = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "other".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + now_epoch_seconds: 10, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(cross_tenant.to_string().contains("tenant mismatch")); + + let cross_project = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "other-project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + now_epoch_seconds: 10, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(cross_project.to_string().contains("project mismatch")); + + let cross_tenant_open = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "other".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + token_digest: link.scoped_token_digest.clone(), + now_epoch_seconds: 11, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(cross_tenant_open.to_string().contains("tenant mismatch")); + + let cross_project_open = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "other-project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + token_digest: link.scoped_token_digest.clone(), + now_epoch_seconds: 11, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(cross_project_open.to_string().contains("project mismatch")); + + let guessed = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + token_digest: Digest::sha256("guessed"), + now_epoch_seconds: 11, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(guessed.to_string().contains("token is invalid")); + + let cross_actor_open = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "other-user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + token_digest: link.scoped_token_digest.clone(), + now_epoch_seconds: 11, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(cross_actor_open.to_string().contains("token is invalid")); + + let expired = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + token_digest: link.scoped_token_digest.clone(), + now_epoch_seconds: 71, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(expired.to_string().contains("expired")); + + let CoordinatorResponse::ArtifactDownloadStream { + streamed_bytes, + charged_download_bytes, + .. + } = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + token_digest: link.scoped_token_digest.clone(), + now_epoch_seconds: 11, + chunk_bytes: 16, + }) + .unwrap() + else { + panic!("expected download stream"); + }; + + assert_eq!(streamed_bytes, 16); + assert_eq!(charged_download_bytes, 16); + + let cross_actor_revoke = service + .handle_request(CoordinatorRequest::RevokeArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "other-user".to_owned(), + artifact: "app.txt".to_owned(), + token_digest: link.scoped_token_digest.clone(), + }) + .unwrap_err(); + assert!(cross_actor_revoke.to_string().contains("token is invalid")); + + let CoordinatorResponse::ArtifactDownloadLinkRevoked { link: revoked } = service + .handle_request(CoordinatorRequest::RevokeArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + token_digest: link.scoped_token_digest.clone(), + }) + .unwrap() + else { + panic!("expected revoked download link"); + }; + assert_eq!(revoked.scoped_token_digest, link.scoped_token_digest); + + let revoked = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 64, + token_nonce: "nonce".to_owned(), + token_digest: link.scoped_token_digest, + now_epoch_seconds: 12, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(revoked.to_string().contains("revoked")); + } + + #[test] + fn download_quota_cannot_be_reset_by_reconnect_retry_or_scope_switch() { + let mut service = CoordinatorService::new(7); + service.download_limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 16)]), + }; + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: "public-key".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 16, + stderr_bytes: 0, + stdout_tail: "artifact bytes".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/app.txt".to_owned()), + artifact_digest: Some(Digest::sha256("artifact")), + artifact_size_bytes: Some(16), + }) + .unwrap(); + + let CoordinatorResponse::ArtifactDownloadLink { link } = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + token_nonce: "first-cli-session".to_owned(), + now_epoch_seconds: 10, + ttl_seconds: 60, + }) + .unwrap() + else { + panic!("expected initial download link"); + }; + + let CoordinatorResponse::ArtifactDownloadStream { + charged_download_bytes, + .. + } = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + token_nonce: "first-cli-session".to_owned(), + token_digest: link.scoped_token_digest.clone(), + now_epoch_seconds: 11, + chunk_bytes: 16, + }) + .unwrap() + else { + panic!("expected initial download stream"); + }; + assert_eq!(charged_download_bytes, 16); + + service + .handle_request(CoordinatorRequest::ReconnectNode { + node: "node".to_owned(), + process: "process".to_owned(), + epoch: 7, + }) + .unwrap(); + let after_reconnect = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + token_nonce: "after-reconnect".to_owned(), + now_epoch_seconds: 12, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(after_reconnect + .to_string() + .contains("ArtifactDownloadBytes")); + + let restarted_cli_retry = service + .handle_request(CoordinatorRequest::CreateArtifactDownloadLink { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + token_nonce: "second-cli-session".to_owned(), + now_epoch_seconds: 13, + ttl_seconds: 60, + }) + .unwrap_err(); + assert!(restarted_cli_retry + .to_string() + .contains("ArtifactDownloadBytes")); + + let cross_tenant_retry = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "other-tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + token_nonce: "first-cli-session".to_owned(), + token_digest: link.scoped_token_digest.clone(), + now_epoch_seconds: 14, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(cross_tenant_retry.to_string().contains("tenant mismatch")); + + let cross_project_retry = service + .handle_request(CoordinatorRequest::OpenArtifactDownloadStream { + tenant: "tenant".to_owned(), + project: "other-project".to_owned(), + actor_user: "user".to_owned(), + artifact: "app.txt".to_owned(), + max_bytes: 16, + token_nonce: "first-cli-session".to_owned(), + token_digest: link.scoped_token_digest, + now_epoch_seconds: 14, + chunk_bytes: 1, + }) + .unwrap_err(); + assert!(cross_project_retry.to_string().contains("project mismatch")); + } + + #[test] + fn windows_task_events_share_the_virtual_process_scope() { + let mut service = CoordinatorService::new(7); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "windows-node".to_owned(), + public_key: "public-key".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "windows-node".to_owned(), + capabilities: windows_capabilities(), + cached_environment_digests: vec![Digest::sha256("env-windows-command-dev")], + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + }) + .unwrap(); + + let recorded = service + .handle_request(CoordinatorRequest::TaskCompleted { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "process".to_owned(), + node: "windows-node".to_owned(), + task: "windows-command-dev".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 24, + stderr_bytes: 0, + stdout_tail: "windows output".to_owned(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/windows-output.txt".to_owned()), + artifact_digest: Some(Digest::sha256("windows-artifact")), + artifact_size_bytes: Some(24), + }) + .unwrap(); + assert_eq!( + recorded, + CoordinatorResponse::TaskRecorded { + process: ProcessId::from("process"), + task: TaskId::from("windows-command-dev"), + events_recorded: 1, + } + ); + + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert_eq!(events.len(), 1); + assert_eq!(events[0].process, ProcessId::from("process")); + assert_eq!(events[0].node, NodeId::from("windows-node")); + assert_eq!(events[0].task, TaskId::from("windows-command-dev")); + assert_eq!( + events[0].artifact_path, + Some(VfsPath::new("/vfs/artifacts/windows-output.txt").unwrap()) + ); + } + + #[test] + fn service_schedules_task_across_reported_node_descriptors() { + let mut service = CoordinatorService::new(7); + for node in ["cold-node", "warm-node"] { + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: node.to_owned(), + public_key: format!("{node}-public-key"), + }) + .unwrap(); + } + + let recorded = service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "cold-node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: false, + online: true, + }) + .unwrap(); + assert_eq!( + recorded, + CoordinatorResponse::NodeCapabilitiesRecorded { + node: NodeId::from("cold-node"), + node_descriptors: 1, + } + ); + + service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "warm-node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: vec![Digest::sha256("env")], + dependency_cache_digests: vec![Digest::sha256("deps")], + source_snapshots: vec![Digest::sha256("source")], + artifact_locations: vec!["build-output".to_owned()], + direct_connectivity: true, + online: true, + }) + .unwrap(); + + let CoordinatorResponse::NodeDescriptors { descriptors, actor } = service + .handle_request(CoordinatorRequest::ListNodeDescriptors { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "operator".to_owned(), + }) + .unwrap() + else { + panic!("expected node descriptor inspector state"); + }; + assert_eq!(actor, UserId::from("operator")); + assert_eq!(descriptors.len(), 2); + let warm = descriptors + .iter() + .find(|descriptor| descriptor.id == NodeId::from("warm-node")) + .expect("warm node descriptor is visible to inspector state"); + assert!(warm + .capabilities + .capabilities + .contains(&Capability::Command)); + assert!(warm.cached_environments.contains(&Digest::sha256("env"))); + assert!(warm.dependency_caches.contains(&Digest::sha256("deps"))); + assert!(warm.source_snapshots.contains(&Digest::sha256("source"))); + assert!(warm + .artifact_locations + .contains(&ArtifactId::from("build-output"))); + + let CoordinatorResponse::TaskPlacement { placement } = service + .handle_request(CoordinatorRequest::ScheduleTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + environment: Some(EnvironmentRequirements::linux_container()), + environment_digest: Some(Digest::sha256("env")), + required_capabilities: vec![Capability::Command], + dependency_cache: Some(Digest::sha256("deps")), + source_snapshot: Some(Digest::sha256("source")), + required_artifacts: vec!["build-output".to_owned()], + quota_available: true, + policy_allowed: true, + prefer_node: None, + }) + .unwrap() + else { + panic!("expected task placement"); + }; + assert_eq!(placement.node, NodeId::from("warm-node")); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("environment"))); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("source"))); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("dependency"))); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("artifact"))); + + let error = service + .handle_request(CoordinatorRequest::ScheduleTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::WindowsCommandDev], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }) + .unwrap_err(); + assert!(error.to_string().contains("WindowsCommandDev")); + + let quota_error = service + .handle_request(CoordinatorRequest::ScheduleTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::Command], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + quota_available: false, + policy_allowed: true, + prefer_node: None, + }) + .unwrap_err(); + assert!(quota_error + .to_string() + .contains("quota unavailable for placement")); + + let policy_error = service + .handle_request(CoordinatorRequest::ScheduleTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::Command], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + quota_available: true, + policy_allowed: false, + prefer_node: None, + }) + .unwrap_err(); + assert!(policy_error.to_string().contains("policy denied placement")); + } + + #[test] + fn coordinator_side_task_launch_queues_worker_assignment() { + let mut service = CoordinatorService::new(9); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + public_key: "worker-linux-public-key".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap(); + let CoordinatorResponse::ProcessStarted { epoch, .. } = service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "vp-control".to_owned(), + }) + .unwrap() + else { + panic!("expected coordinator-side process start"); + }; + + let CoordinatorResponse::TaskLaunched { + process, + task, + placement, + assignment, + } = service + .handle_request(CoordinatorRequest::LaunchTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "vp-control".to_owned(), + task: "compile-linux".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::Command], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + quota_available: true, + policy_allowed: true, + command: "cargo".to_owned(), + command_args: vec!["test".to_owned()], + artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(), + }) + .unwrap() + else { + panic!("expected launched task"); + }; + assert_eq!(process, ProcessId::from("vp-control")); + assert_eq!(task, TaskId::from("compile-linux")); + assert_eq!(placement.node, NodeId::from("worker-linux")); + assert_eq!(assignment.node, NodeId::from("worker-linux")); + assert_eq!(assignment.epoch, epoch); + + let CoordinatorResponse::TaskAssignment { assignment } = service + .handle_request(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + }) + .unwrap() + else { + panic!("expected worker assignment poll"); + }; + let assignment = assignment.expect("worker should receive queued assignment"); + assert_eq!(assignment.process, ProcessId::from("vp-control")); + assert_eq!(assignment.task, TaskId::from("compile-linux")); + assert_eq!(assignment.command, "cargo"); + assert_eq!(assignment.command_args, vec!["test"]); + + let CoordinatorResponse::TaskAssignment { assignment } = service + .handle_request(CoordinatorRequest::PollTaskAssignment { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "worker-linux".to_owned(), + }) + .unwrap() + else { + panic!("expected empty worker assignment poll"); + }; + assert!(assignment.is_none()); + } + + #[test] + fn coordinator_side_task_launch_fails_cleanly_without_capable_worker() { + let mut service = CoordinatorService::new(10); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + process: "vp-control".to_owned(), + }) + .unwrap(); + + let error = service + .handle_request(CoordinatorRequest::LaunchTask { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + actor_user: "user".to_owned(), + process: "vp-control".to_owned(), + task: "compile-linux".to_owned(), + environment: None, + environment_digest: None, + required_capabilities: vec![Capability::Command], + dependency_cache: None, + source_snapshot: None, + required_artifacts: Vec::new(), + quota_available: true, + policy_allowed: true, + command: "cargo".to_owned(), + command_args: vec!["test".to_owned()], + artifact_path: "/vfs/artifacts/dap-output.txt".to_owned(), + }) + .unwrap_err(); + + assert!(error.to_string().contains("no capable node")); + } + + #[test] + fn service_rejects_malformed_node_capability_report() { + let mut service = CoordinatorService::new(1); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: "public-key".to_owned(), + }) + .unwrap(); + + let mut capabilities = linux_capabilities(); + capabilities + .source_providers + .insert("../checkout".to_owned()); + let error = service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + capabilities, + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap_err(); + + assert!(error.to_string().contains("source provider id")); + assert!(service.node_descriptors.is_empty()); + } + + #[test] + fn service_meters_rendezvous_before_direct_transfer_plan() { + let mut service = CoordinatorService::new(7); + service.rendezvous_limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::RendezvousAttempt, 2)]), + }; + + let CoordinatorResponse::RendezvousPlan { + plan, + charged_rendezvous_attempts, + } = service + .handle_request(CoordinatorRequest::RequestRendezvous { + scope: data_plane_scope("project"), + source: endpoint("node-a"), + destination: endpoint("node-b"), + direct_connectivity: true, + failure_reason: String::new(), + }) + .unwrap() + else { + panic!("expected rendezvous plan"); + }; + + assert_eq!(charged_rendezvous_attempts, 1); + assert_eq!(plan.scope.project, ProjectId::from("project")); + assert_eq!(plan.source.node, NodeId::from("node-a")); + assert_eq!(plan.destination.node, NodeId::from("node-b")); + assert!(plan.coordinator_assisted_rendezvous); + assert!(!plan.coordinator_bulk_relay_allowed); + + let unavailable = service + .handle_request(CoordinatorRequest::RequestRendezvous { + scope: data_plane_scope("project"), + source: endpoint("node-a"), + destination: endpoint("node-b"), + direct_connectivity: false, + failure_reason: "nat traversal failed".to_owned(), + }) + .unwrap_err(); + let unavailable = unavailable.to_string(); + assert!(unavailable.contains("nat traversal failed")); + assert!(unavailable.contains("coordinator bulk relay is disabled")); + + let quota = service + .handle_request(CoordinatorRequest::RequestRendezvous { + scope: data_plane_scope("project"), + source: endpoint("node-a"), + destination: endpoint("node-b"), + direct_connectivity: true, + failure_reason: String::new(), + }) + .unwrap_err(); + assert!(quota.to_string().contains("RendezvousAttempt")); + } + + #[test] + fn service_rejects_task_completion_outside_node_scope() { + let mut service = CoordinatorService::new(1); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant-a".to_owned(), + project: "project-a".to_owned(), + node: "node".to_owned(), + public_key: "public-key".to_owned(), + }) + .unwrap(); + service + .handle_request(CoordinatorRequest::StartProcess { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + process: "process".to_owned(), + }) + .unwrap(); + + let error = service + .handle_request(CoordinatorRequest::TaskCompleted { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + process: "process".to_owned(), + node: "node".to_owned(), + task: "compile-linux".to_owned(), + terminal_state: None, + status_code: Some(0), + stdout_bytes: 128, + stderr_bytes: 64, + stdout_tail: "foreign stdout".to_owned(), + stderr_tail: "foreign stderr".to_owned(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/foreign.txt".to_owned()), + artifact_digest: Some(Digest::sha256("foreign-artifact")), + artifact_size_bytes: Some(128), + }) + .unwrap_err(); + + assert!(error.to_string().contains("outside")); + let CoordinatorResponse::TaskEvents { events } = service + .handle_request(CoordinatorRequest::ListTaskEvents { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + actor_user: "user".to_owned(), + process: Some("process".to_owned()), + }) + .unwrap() + else { + panic!("expected task events"); + }; + assert!(events.is_empty()); + } + + #[test] + fn service_rejects_node_capability_report_outside_enrollment_scope() { + let mut service = CoordinatorService::new(1); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant-a".to_owned(), + project: "project-a".to_owned(), + node: "node".to_owned(), + public_key: "public-key".to_owned(), + }) + .unwrap(); + + let error = service + .handle_request(CoordinatorRequest::ReportNodeCapabilities { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + node: "node".to_owned(), + capabilities: linux_capabilities(), + cached_environment_digests: Vec::new(), + dependency_cache_digests: Vec::new(), + source_snapshots: Vec::new(), + artifact_locations: Vec::new(), + direct_connectivity: true, + online: true, + }) + .unwrap_err(); + + assert!(error.to_string().contains("tenant/project scope")); + assert!(service.node_descriptors.is_empty()); + } + + #[test] + fn service_rejects_source_preparation_completion_outside_node_scope() { + let mut service = CoordinatorService::new(1); + service + .handle_request(CoordinatorRequest::AttachNode { + tenant: "tenant-a".to_owned(), + project: "project-a".to_owned(), + node: "node".to_owned(), + public_key: "public-key".to_owned(), + }) + .unwrap(); + + let error = service + .handle_request(CoordinatorRequest::CompleteSourcePreparation { + tenant: "tenant-b".to_owned(), + project: "project-b".to_owned(), + node: "node".to_owned(), + provider: SourceProviderKind::Filesystem, + source_snapshot: Digest::sha256("foreign-source"), + }) + .unwrap_err(); + + assert!(error.to_string().contains("tenant/project scope")); + } + + #[test] + fn service_rejects_unknown_node_heartbeat() { + let mut service = CoordinatorService::new(1); + + let error = service + .handle_request(CoordinatorRequest::NodeHeartbeat { + node: "missing".to_owned(), + }) + .unwrap_err(); + + assert!(error.to_string().contains("not enrolled")); + } + + #[test] + fn service_stream_accepts_multiple_requests_on_one_connection() { + let (listener, addr) = bind_listener("127.0.0.1:0").unwrap(); + let server = std::thread::spawn(move || { + let mut service = CoordinatorService::new(3); + let (stream, _) = listener.accept().unwrap(); + service.handle_stream(stream).unwrap(); + }); + + let mut stream = TcpStream::connect(addr).unwrap(); + let mut reader = BufReader::new(stream.try_clone().unwrap()); + + serde_json::to_writer( + &mut stream, + &CoordinatorRequest::AttachNode { + tenant: "tenant".to_owned(), + project: "project".to_owned(), + node: "node".to_owned(), + public_key: "public-key".to_owned(), + }, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + stream.flush().unwrap(); + + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + assert!(matches!( + serde_json::from_str::(&line).unwrap(), + CoordinatorResponse::NodeAttached { .. } + )); + + serde_json::to_writer( + &mut stream, + &CoordinatorRequest::NodeHeartbeat { + node: "node".to_owned(), + }, + ) + .unwrap(); + stream.write_all(b"\n").unwrap(); + stream.flush().unwrap(); + + line.clear(); + reader.read_line(&mut line).unwrap(); + assert_eq!( + serde_json::from_str::(&line).unwrap(), + CoordinatorResponse::NodeHeartbeat { + node: NodeId::from("node"), + epoch: 3, + } + ); + + stream.shutdown(std::net::Shutdown::Both).unwrap(); + server.join().unwrap(); + } +} diff --git a/crates/disasmer-core/Cargo.toml b/crates/disasmer-core/Cargo.toml new file mode 100644 index 0000000..9474301 --- /dev/null +++ b/crates/disasmer-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "disasmer-core" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +hex.workspace = true +sha2.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tempfile.workspace = true diff --git a/crates/disasmer-core/src/artifact.rs b/crates/disasmer-core/src/artifact.rs new file mode 100644 index 0000000..bc75437 --- /dev/null +++ b/crates/disasmer-core/src/artifact.rs @@ -0,0 +1,939 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + auth::same_tenant_project, Actor, ArtifactId, AuthContext, Digest, LimitError, LimitKind, + NodeId, ProcessId, ProjectId, ResourceLimits, ResourceMeter, Scope, TaskId, TenantId, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum StorageLocation { + RetainedNode(NodeId), + ExplicitStore(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetentionPolicy { + pub best_effort_node_retention: bool, + pub max_download_bytes: u64, +} + +impl Default for RetentionPolicy { + fn default() -> Self { + Self { + best_effort_node_retention: true, + max_download_bytes: 256 * 1024 * 1024, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DownloadPolicy { + pub max_bytes: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DownloadAction { + pub artifact: ArtifactId, + pub source: StorageLocation, + pub scoped_token_subject: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DownloadLink { + pub artifact: ArtifactId, + pub source: StorageLocation, + pub url_path: String, + pub scoped_token_digest: Digest, + pub expires_at_epoch_seconds: u64, + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub actor: Actor, + pub max_bytes: u64, + pub policy_context_digest: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct IssuedDownloadLink { + pub link: DownloadLink, + pub revoked: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactDownloadStream { + pub link: DownloadLink, + pub streamed_bytes: u64, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum DownloadError { + #[error("artifact does not exist")] + NotFound, + #[error("artifact is unavailable from current retention or explicit storage")] + Unavailable, + #[error("artifact download direct connectivity unavailable: {0}")] + DirectConnectivityUnavailable(String), + #[error("artifact download denied: {0}")] + Unauthorized(String), + #[error("artifact size {size} exceeds download limit {limit}")] + LimitExceeded { size: u64, limit: u64 }, + #[error("download link token is invalid for this scoped artifact link")] + InvalidToken, + #[error("download link has expired")] + Expired, + #[error("download link has been revoked")] + Revoked, + #[error("download usage limit failed: {0}")] + Usage(String), +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[error("artifact is unavailable because node-local unsynced bytes were lost")] +pub struct ArtifactUnavailable; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ArtifactMetadata { + pub id: ArtifactId, + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub producer_task: TaskId, + pub producer_node: NodeId, + pub digest: Digest, + pub size: u64, + pub flushed_epoch: u64, + pub retaining_nodes: BTreeSet, + pub explicit_locations: Vec, + pub coordinator_has_large_bytes: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct ArtifactRegistry { + artifacts: BTreeMap, + issued_download_links: BTreeMap, + next_epoch: u64, +} + +impl ArtifactRegistry { + pub fn flush_metadata( + &mut self, + id: ArtifactId, + tenant: TenantId, + project: ProjectId, + process: ProcessId, + producer_task: TaskId, + retaining_node: NodeId, + digest: Digest, + size: u64, + ) -> ArtifactMetadata { + self.next_epoch += 1; + let metadata = ArtifactMetadata { + id: id.clone(), + tenant, + project, + process, + producer_task, + producer_node: retaining_node.clone(), + digest, + size, + flushed_epoch: self.next_epoch, + retaining_nodes: BTreeSet::from([retaining_node]), + explicit_locations: Vec::new(), + coordinator_has_large_bytes: false, + }; + self.artifacts.insert(id, metadata.clone()); + metadata + } + + pub fn sync_to_explicit_store( + &mut self, + artifact: &ArtifactId, + location: impl Into, + ) -> Result<(), ArtifactUnavailable> { + let metadata = self + .artifacts + .get_mut(artifact) + .ok_or(ArtifactUnavailable)?; + metadata.explicit_locations.push(location.into()); + Ok(()) + } + + pub fn garbage_collect_node(&mut self, node: &NodeId) { + for metadata in self.artifacts.values_mut() { + metadata.retaining_nodes.remove(node); + } + } + + pub fn metadata(&self, artifact: &ArtifactId) -> Option<&ArtifactMetadata> { + self.artifacts.get(artifact) + } + + pub fn download_action( + &self, + context: &AuthContext, + artifact: &ArtifactId, + policy: &DownloadPolicy, + ) -> Result { + let metadata = self + .artifacts + .get(artifact) + .ok_or(DownloadError::NotFound)?; + let scope = Scope { + tenant: metadata.tenant.clone(), + project: metadata.project.clone(), + process: Some(metadata.process.clone()), + task: Some(metadata.producer_task.clone()), + node: None, + artifact: Some(metadata.id.clone()), + }; + let authz = same_tenant_project(context, &scope); + if !authz.allowed { + return Err(DownloadError::Unauthorized(authz.reason)); + } + if metadata.size > policy.max_bytes { + return Err(DownloadError::LimitExceeded { + size: metadata.size, + limit: policy.max_bytes, + }); + } + let source = metadata + .retaining_nodes + .iter() + .next() + .cloned() + .map(StorageLocation::RetainedNode) + .or_else(|| { + metadata + .explicit_locations + .first() + .cloned() + .map(StorageLocation::ExplicitStore) + }) + .ok_or(DownloadError::Unavailable)?; + + Ok(DownloadAction { + artifact: artifact.clone(), + source, + scoped_token_subject: format!( + "{}/{}/{}/{}", + metadata.tenant, metadata.project, metadata.process, metadata.id + ), + }) + } + + pub fn downloadable_size( + &self, + context: &AuthContext, + artifact: &ArtifactId, + policy: &DownloadPolicy, + ) -> Result { + self.download_action(context, artifact, policy)?; + let metadata = self + .artifacts + .get(artifact) + .ok_or(DownloadError::NotFound)?; + Ok(metadata.size) + } + + pub fn create_download_link( + &mut self, + context: &AuthContext, + artifact: &ArtifactId, + policy: &DownloadPolicy, + token_nonce: &str, + now_epoch_seconds: u64, + ttl_seconds: u64, + ) -> Result { + let action = self.download_action(context, artifact, policy)?; + let metadata = self + .artifacts + .get(artifact) + .ok_or(DownloadError::NotFound)?; + let expires_at_epoch_seconds = now_epoch_seconds.saturating_add(ttl_seconds); + let policy_context_digest = + download_policy_context_digest(metadata, &action.source, policy); + let scoped_token_digest = Digest::from_parts([ + b"artifact-download-token:v2".as_slice(), + action.scoped_token_subject.as_bytes(), + actor_subject(&context.actor).as_bytes(), + token_nonce.as_bytes(), + metadata.digest.as_str().as_bytes(), + metadata.size.to_string().as_bytes(), + policy_context_digest.as_str().as_bytes(), + expires_at_epoch_seconds.to_string().as_bytes(), + ]); + let link = DownloadLink { + artifact: artifact.clone(), + source: action.source, + url_path: format!( + "/artifacts/{}/{}/{}/{}", + metadata.tenant, metadata.project, metadata.process, metadata.id + ), + scoped_token_digest, + expires_at_epoch_seconds, + tenant: metadata.tenant.clone(), + project: metadata.project.clone(), + process: metadata.process.clone(), + actor: context.actor.clone(), + max_bytes: policy.max_bytes, + policy_context_digest, + }; + self.issued_download_links.insert( + link.scoped_token_digest.clone(), + IssuedDownloadLink { + link: link.clone(), + revoked: false, + }, + ); + Ok(link) + } + + pub fn revoke_download_link( + &mut self, + context: &AuthContext, + artifact: &ArtifactId, + presented_token_digest: &Digest, + ) -> Result { + let issued = self + .issued_download_links + .get(presented_token_digest) + .ok_or(DownloadError::InvalidToken)?; + if issued.link.artifact != *artifact || issued.link.actor != context.actor { + return Err(DownloadError::InvalidToken); + } + self.download_action( + context, + artifact, + &DownloadPolicy { + max_bytes: issued.link.max_bytes, + }, + )?; + + let issued = self + .issued_download_links + .get_mut(presented_token_digest) + .ok_or(DownloadError::InvalidToken)?; + issued.revoked = true; + Ok(issued.link.clone()) + } + + pub fn open_download_stream( + &self, + context: &AuthContext, + artifact: &ArtifactId, + policy: &DownloadPolicy, + presented_token_digest: &Digest, + now_epoch_seconds: u64, + limits: &ResourceLimits, + meter: &mut ResourceMeter, + ) -> Result { + let issued = self + .issued_download_links + .get(presented_token_digest) + .ok_or(DownloadError::InvalidToken)?; + if issued.link.artifact != *artifact + || issued.link.max_bytes != policy.max_bytes + || issued.link.actor != context.actor + { + return Err(DownloadError::InvalidToken); + } + if issued.revoked { + return Err(DownloadError::Revoked); + } + if now_epoch_seconds > issued.link.expires_at_epoch_seconds { + return Err(DownloadError::Expired); + } + let action = self.download_action(context, artifact, policy)?; + if action.source != issued.link.source { + return Err(DownloadError::Unavailable); + } + let metadata = self + .artifacts + .get(artifact) + .ok_or(DownloadError::NotFound)?; + if download_policy_context_digest(metadata, &action.source, policy) + != issued.link.policy_context_digest + { + return Err(DownloadError::InvalidToken); + } + meter + .charge(limits, LimitKind::ArtifactDownloadBytes, 0) + .map_err(download_usage_error)?; + Ok(ArtifactDownloadStream { + link: issued.link.clone(), + streamed_bytes: 0, + }) + } + + pub fn stream_download_chunk( + &self, + stream: &mut ArtifactDownloadStream, + limits: &ResourceLimits, + meter: &mut ResourceMeter, + bytes: u64, + ) -> Result<(), DownloadError> { + let metadata = self + .artifacts + .get(&stream.link.artifact) + .ok_or(DownloadError::NotFound)?; + if !source_is_available(metadata, &stream.link.source) { + return Err(DownloadError::Unavailable); + } + stream.stream_chunk(limits, meter, bytes) + } +} + +impl ArtifactDownloadStream { + pub fn stream_chunk( + &mut self, + limits: &ResourceLimits, + meter: &mut ResourceMeter, + bytes: u64, + ) -> Result<(), DownloadError> { + if self.streamed_bytes.saturating_add(bytes) > self.link.max_bytes { + return Err(DownloadError::LimitExceeded { + size: self.streamed_bytes.saturating_add(bytes), + limit: self.link.max_bytes, + }); + } + meter + .charge(limits, LimitKind::ArtifactDownloadBytes, bytes) + .map_err(download_usage_error)?; + self.streamed_bytes += bytes; + Ok(()) + } +} + +fn download_usage_error(error: LimitError) -> DownloadError { + DownloadError::Usage(error.to_string()) +} + +fn download_policy_context_digest( + metadata: &ArtifactMetadata, + source: &StorageLocation, + policy: &DownloadPolicy, +) -> Digest { + Digest::from_parts([ + b"artifact-download-policy-context:v1".as_slice(), + metadata.tenant.as_str().as_bytes(), + metadata.project.as_str().as_bytes(), + metadata.process.as_str().as_bytes(), + metadata.id.as_str().as_bytes(), + metadata.digest.as_str().as_bytes(), + metadata.size.to_string().as_bytes(), + storage_location_key(source).as_bytes(), + policy.max_bytes.to_string().as_bytes(), + ]) +} + +fn actor_subject(actor: &Actor) -> String { + match actor { + Actor::User(id) => format!("user:{id}"), + Actor::Agent(id) => format!("agent:{id}"), + Actor::Node(id) => format!("node:{id}"), + Actor::Task(id) => format!("task:{id}"), + } +} + +fn storage_location_key(source: &StorageLocation) -> String { + match source { + StorageLocation::RetainedNode(node) => format!("retained-node:{node}"), + StorageLocation::ExplicitStore(location) => format!("explicit-store:{location}"), + } +} + +fn source_is_available(metadata: &ArtifactMetadata, source: &StorageLocation) -> bool { + match source { + StorageLocation::RetainedNode(node) => metadata.retaining_nodes.contains(node), + StorageLocation::ExplicitStore(location) => metadata.explicit_locations.contains(location), + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use crate::{Actor, LimitKind, ResourceLimits, ResourceMeter, UserId}; + + use super::*; + + fn registry_with_artifact() -> ArtifactRegistry { + let mut registry = ArtifactRegistry::default(); + registry.flush_metadata( + ArtifactId::from("artifact"), + TenantId::from("tenant"), + ProjectId::from("project"), + ProcessId::from("process"), + TaskId::from("task"), + NodeId::from("node"), + Digest::sha256("bytes"), + 32, + ); + registry + } + + #[test] + fn flush_publishes_metadata_without_coordinator_bytes() { + let registry = registry_with_artifact(); + let metadata = registry.metadata(&ArtifactId::from("artifact")).unwrap(); + + assert!(!metadata.coordinator_has_large_bytes); + assert_eq!(metadata.id, ArtifactId::from("artifact")); + assert_eq!(metadata.tenant, TenantId::from("tenant")); + assert_eq!(metadata.project, ProjectId::from("project")); + assert_eq!(metadata.process, ProcessId::from("process")); + assert_eq!(metadata.producer_task, TaskId::from("task")); + assert_eq!(metadata.producer_node, NodeId::from("node")); + assert_eq!(metadata.digest, Digest::sha256("bytes")); + assert_eq!(metadata.size, 32); + assert_eq!(metadata.flushed_epoch, 1); + assert!(metadata.retaining_nodes.contains(&NodeId::from("node"))); + assert!(metadata.explicit_locations.is_empty()); + } + + #[test] + fn unsynced_node_loss_surfaces_as_unavailable() { + let mut registry = registry_with_artifact(); + registry.garbage_collect_node(&NodeId::from("node")); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + + let error = registry + .download_action( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + ) + .unwrap_err(); + + assert_eq!(error, DownloadError::Unavailable); + } + + #[test] + fn explicit_user_storage_location_survives_node_retention_loss() { + let mut registry = registry_with_artifact(); + registry + .sync_to_explicit_store(&ArtifactId::from("artifact"), "s3://bucket/app") + .unwrap(); + registry.garbage_collect_node(&NodeId::from("node")); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + + let action = registry + .download_action( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + ) + .unwrap(); + + assert_eq!( + action.source, + StorageLocation::ExplicitStore("s3://bucket/app".to_owned()) + ); + assert!( + !registry + .metadata(&ArtifactId::from("artifact")) + .unwrap() + .coordinator_has_large_bytes + ); + } + + #[test] + fn default_retention_policy_is_best_effort_node_retention() { + let policy = RetentionPolicy::default(); + + assert!(policy.best_effort_node_retention); + assert_eq!(policy.max_download_bytes, 256 * 1024 * 1024); + } + + #[test] + fn cross_tenant_download_is_denied_even_with_known_artifact_id() { + let registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("other"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + + let error = registry + .download_action( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + ) + .unwrap_err(); + + assert!(matches!(error, DownloadError::Unauthorized(_))); + } + + #[test] + fn cross_project_download_is_denied_even_with_known_artifact_id() { + let registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("other-project"), + actor: Actor::User(UserId::from("user")), + }; + + let error = registry + .download_action( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + ) + .unwrap_err(); + + assert!(matches!(error, DownloadError::Unauthorized(_))); + } + + #[test] + fn download_link_is_not_created_when_artifact_is_unavailable_or_too_large() { + let mut registry = registry_with_artifact(); + registry.garbage_collect_node(&NodeId::from("node")); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + + assert_eq!( + registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + "nonce", + 10, + 60, + ) + .unwrap_err(), + DownloadError::Unavailable + ); + + let mut registry = registry_with_artifact(); + assert!(matches!( + registry.create_download_link( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 1 }, + "nonce", + 10, + 60, + ), + Err(DownloadError::LimitExceeded { .. }) + )); + } + + #[test] + fn download_link_is_authenticated_scoped_and_not_guessable() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let link = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + "nonce-a", + 10, + 60, + ) + .unwrap(); + let other = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + "nonce-b", + 10, + 60, + ) + .unwrap(); + + assert_eq!(link.tenant, TenantId::from("tenant")); + assert_eq!(link.project, ProjectId::from("project")); + assert_eq!(link.process, ProcessId::from("process")); + assert_eq!(link.actor, Actor::User(UserId::from("user"))); + assert_eq!(link.max_bytes, 100); + assert!(link.policy_context_digest.is_valid_sha256()); + assert_eq!(link.expires_at_epoch_seconds, 70); + assert!(link + .url_path + .contains("/artifacts/tenant/project/process/artifact")); + assert_ne!(link.scoped_token_digest, other.scoped_token_digest); + assert!(matches!(link.source, StorageLocation::RetainedNode(_))); + } + + #[test] + fn download_link_is_bound_to_actor_and_policy_context() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let other_actor = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("other-user")), + }; + let policy = DownloadPolicy { max_bytes: 100 }; + let link = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "nonce", + 10, + 60, + ) + .unwrap(); + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]), + }; + let mut meter = ResourceMeter::default(); + + assert_eq!( + registry + .open_download_stream( + &other_actor, + &ArtifactId::from("artifact"), + &policy, + &link.scoped_token_digest, + 11, + &limits, + &mut meter, + ) + .unwrap_err(), + DownloadError::InvalidToken + ); + assert_eq!( + registry + .open_download_stream( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 99 }, + &link.scoped_token_digest, + 11, + &limits, + &mut meter, + ) + .unwrap_err(), + DownloadError::InvalidToken + ); + assert_eq!( + registry + .revoke_download_link( + &other_actor, + &ArtifactId::from("artifact"), + &link.scoped_token_digest, + ) + .unwrap_err(), + DownloadError::InvalidToken + ); + } + + #[test] + fn download_link_expires_and_can_be_revoked() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let policy = DownloadPolicy { max_bytes: 100 }; + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]), + }; + let mut meter = ResourceMeter::default(); + let expired = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "expired", + 10, + 5, + ) + .unwrap(); + + let error = registry + .open_download_stream( + &context, + &ArtifactId::from("artifact"), + &policy, + &expired.scoped_token_digest, + 16, + &limits, + &mut meter, + ) + .unwrap_err(); + assert_eq!(error, DownloadError::Expired); + + let active = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "active", + 20, + 60, + ) + .unwrap(); + let revoked = registry + .revoke_download_link( + &context, + &ArtifactId::from("artifact"), + &active.scoped_token_digest, + ) + .unwrap(); + assert_eq!(revoked.scoped_token_digest, active.scoped_token_digest); + + let error = registry + .open_download_stream( + &context, + &ArtifactId::from("artifact"), + &policy, + &active.scoped_token_digest, + 21, + &limits, + &mut meter, + ) + .unwrap_err(); + assert_eq!(error, DownloadError::Revoked); + } + + #[test] + fn download_stream_accounts_usage_before_and_during_streaming() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let policy = DownloadPolicy { max_bytes: 100 }; + let link = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "nonce", + 10, + 60, + ) + .unwrap(); + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]), + }; + let mut meter = ResourceMeter::default(); + let mut stream = registry + .open_download_stream( + &context, + &ArtifactId::from("artifact"), + &policy, + &link.scoped_token_digest, + 11, + &limits, + &mut meter, + ) + .unwrap(); + + stream.stream_chunk(&limits, &mut meter, 16).unwrap(); + stream.stream_chunk(&limits, &mut meter, 16).unwrap(); + assert!(matches!( + stream.stream_chunk(&limits, &mut meter, 1), + Err(DownloadError::Usage(_)) + )); + assert_eq!(meter.used(&LimitKind::ArtifactDownloadBytes), 32); + } + + #[test] + fn download_stream_fails_honestly_when_source_disappears_mid_stream() { + let mut registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let policy = DownloadPolicy { max_bytes: 100 }; + let link = registry + .create_download_link( + &context, + &ArtifactId::from("artifact"), + &policy, + "nonce", + 10, + 60, + ) + .unwrap(); + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]), + }; + let mut meter = ResourceMeter::default(); + let mut stream = registry + .open_download_stream( + &context, + &ArtifactId::from("artifact"), + &policy, + &link.scoped_token_digest, + 11, + &limits, + &mut meter, + ) + .unwrap(); + + registry + .stream_download_chunk(&mut stream, &limits, &mut meter, 16) + .unwrap(); + registry.garbage_collect_node(&NodeId::from("node")); + + assert_eq!( + registry + .stream_download_chunk(&mut stream, &limits, &mut meter, 1) + .unwrap_err(), + DownloadError::Unavailable + ); + assert_eq!(stream.streamed_bytes, 16); + } + + #[test] + fn guessed_download_token_is_rejected() { + let registry = registry_with_artifact(); + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 32)]), + }; + let mut meter = ResourceMeter::default(); + + let error = registry + .open_download_stream( + &context, + &ArtifactId::from("artifact"), + &DownloadPolicy { max_bytes: 100 }, + &Digest::sha256("guessed"), + 11, + &limits, + &mut meter, + ) + .unwrap_err(); + + assert_eq!(error, DownloadError::InvalidToken); + } +} diff --git a/crates/disasmer-core/src/auth.rs b/crates/disasmer-core/src/auth.rs new file mode 100644 index 0000000..4e244d9 --- /dev/null +++ b/crates/disasmer-core/src/auth.rs @@ -0,0 +1,369 @@ +use serde::{Deserialize, Serialize}; + +use crate::{AgentId, ArtifactId, Digest, NodeId, ProcessId, ProjectId, TaskId, TenantId, UserId}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Actor { + User(UserId), + Agent(AgentId), + Node(NodeId), + Task(TaskId), +} + +impl Actor { + pub fn kind(&self) -> IdentityKind { + match self { + Self::User(_) => IdentityKind::User, + Self::Agent(_) => IdentityKind::Agent, + Self::Node(_) => IdentityKind::Node, + Self::Task(_) => IdentityKind::Task, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum IdentityKind { + User, + Agent, + Node, + Project, + Task, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CredentialKind { + BrowserSession, + CliDeviceSession, + PublicKey, + NodeCredential, + TaskCredential, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuthContext { + pub tenant: TenantId, + pub project: ProjectId, + pub actor: Actor, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum Action { + CreateProject, + AttachNode, + CreateNodeEnrollmentGrant, + ExchangeNodeEnrollmentGrant, + LoginBrowser, + LoginCli, + EnrollAgent, + List, + Inspect, + Mutate, + ClaimTask, + DebugAttach, + DebugRead, + DownloadArtifact, + PublishArtifact, + RunNativeCommand, + RunContainer, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BrowserLoginFlow { + pub authorization_url: String, + pub callback_path: String, + pub state: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CliLoginFlow { + pub verification_url: String, + pub user_code: String, + pub device_code: String, + pub expires_in_seconds: u64, + pub yields_long_lived_secret_directly: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PublicKeyIdentity { + pub subject: Actor, + pub public_key: String, + pub fingerprint: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnrollmentGrant { + pub tenant: TenantId, + pub project: ProjectId, + pub grant_id: String, + pub scope: String, + pub expires_at_epoch_seconds: u64, + pub consumed: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeCredential { + pub node: NodeId, + pub tenant: TenantId, + pub project: ProjectId, + pub public_key_fingerprint: Digest, + pub scope: String, + pub capability_policy_digest: Digest, + pub credential_kind: CredentialKind, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum EnrollmentError { + Expired, + AlreadyConsumed, + WrongScope, +} + +impl EnrollmentGrant { + pub fn exchange_for_node_identity( + &mut self, + node: NodeId, + public_key: &str, + requested_scope: &str, + now_epoch_seconds: u64, + ) -> Result { + if self.consumed { + return Err(EnrollmentError::AlreadyConsumed); + } + if now_epoch_seconds > self.expires_at_epoch_seconds { + return Err(EnrollmentError::Expired); + } + if requested_scope != self.scope { + return Err(EnrollmentError::WrongScope); + } + self.consumed = true; + let capability_policy_digest = + node_capability_policy_digest(&self.tenant, &self.project, &self.scope); + Ok(NodeCredential { + node, + tenant: self.tenant.clone(), + project: self.project.clone(), + public_key_fingerprint: Digest::sha256(public_key), + scope: self.scope.clone(), + capability_policy_digest, + credential_kind: CredentialKind::NodeCredential, + }) + } +} + +pub fn node_capability_policy_digest( + tenant: &TenantId, + project: &ProjectId, + scope: &str, +) -> Digest { + Digest::from_parts([ + b"node-capability-policy:v1".as_slice(), + tenant.as_str().as_bytes(), + project.as_str().as_bytes(), + scope.as_bytes(), + ]) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Scope { + pub tenant: TenantId, + pub project: ProjectId, + pub process: Option, + pub task: Option, + pub node: Option, + pub artifact: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Authorization { + pub allowed: bool, + pub reason: String, +} + +impl Authorization { + pub fn allow(reason: impl Into) -> Self { + Self { + allowed: true, + reason: reason.into(), + } + } + + pub fn deny(reason: impl Into) -> Self { + Self { + allowed: false, + reason: reason.into(), + } + } +} + +pub fn same_tenant_project(context: &AuthContext, scope: &Scope) -> Authorization { + if context.tenant != scope.tenant { + return Authorization::deny("tenant mismatch"); + } + if context.project != scope.project { + return Authorization::deny("project mismatch"); + } + Authorization::allow("same tenant and project") +} + +pub fn task_credentials_do_not_contain_user_session( + task: &Actor, + credentials: &[CredentialKind], +) -> Authorization { + if !matches!(task, Actor::Task(_)) { + return Authorization::deny("credential check requires task actor"); + } + if credentials.iter().any(|credential| { + matches!( + credential, + CredentialKind::BrowserSession | CredentialKind::CliDeviceSession + ) + }) { + return Authorization::deny( + "user OAuth/session tokens must not be passed to nodes as task credentials", + ); + } + Authorization::allow("task credentials are scoped runtime credentials") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tenant_project_scope_denies_cross_tenant_access() { + let context = AuthContext { + tenant: TenantId::from("tenant-a"), + project: ProjectId::from("project-a"), + actor: Actor::User(UserId::from("user-a")), + }; + let scope = Scope { + tenant: TenantId::from("tenant-b"), + project: ProjectId::from("project-a"), + process: None, + task: None, + node: None, + artifact: None, + }; + + assert!(!same_tenant_project(&context, &scope).allowed); + } + + #[test] + fn node_enrollment_exchanges_short_lived_grant_once() { + let mut grant = EnrollmentGrant { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + grant_id: "grant".to_owned(), + scope: "node:attach".to_owned(), + expires_at_epoch_seconds: 100, + consumed: false, + }; + + let credential = grant + .exchange_for_node_identity(NodeId::from("node"), "public-key", "node:attach", 99) + .unwrap(); + + assert_eq!(credential.credential_kind, CredentialKind::NodeCredential); + assert_eq!(credential.tenant, TenantId::from("tenant")); + assert_eq!(credential.project, ProjectId::from("project")); + assert_eq!(credential.node, NodeId::from("node")); + assert_eq!(credential.scope, "node:attach"); + assert_eq!( + credential.capability_policy_digest, + node_capability_policy_digest( + &TenantId::from("tenant"), + &ProjectId::from("project"), + "node:attach" + ) + ); + assert_eq!( + grant.exchange_for_node_identity( + NodeId::from("node2"), + "public-key", + "node:attach", + 99 + ), + Err(EnrollmentError::AlreadyConsumed) + ); + } + + #[test] + fn node_capability_policy_digest_is_scoped() { + let base = node_capability_policy_digest( + &TenantId::from("tenant"), + &ProjectId::from("project"), + "node:attach", + ); + let other_project = node_capability_policy_digest( + &TenantId::from("tenant"), + &ProjectId::from("other"), + "node:attach", + ); + let other_scope = node_capability_policy_digest( + &TenantId::from("tenant"), + &ProjectId::from("project"), + "node:limited", + ); + + assert!(base.is_valid_sha256()); + assert_ne!(base, other_project); + assert_ne!(base, other_scope); + } + + #[test] + fn task_credentials_reject_user_session_tokens() { + for credential in [ + CredentialKind::BrowserSession, + CredentialKind::CliDeviceSession, + ] { + let authz = task_credentials_do_not_contain_user_session( + &Actor::Task(TaskId::from("task")), + &[CredentialKind::TaskCredential, credential], + ); + + assert!(!authz.allowed); + assert!(authz.reason.contains("must not be passed")); + } + + let scoped = task_credentials_do_not_contain_user_session( + &Actor::Task(TaskId::from("task")), + &[ + CredentialKind::TaskCredential, + CredentialKind::NodeCredential, + ], + ); + assert!(scoped.allowed); + } + + #[test] + fn identities_remain_distinct_for_authorization() { + assert_eq!(Actor::User(UserId::from("user")).kind(), IdentityKind::User); + assert_eq!( + Actor::Agent(AgentId::from("agent")).kind(), + IdentityKind::Agent + ); + assert_eq!(Actor::Node(NodeId::from("node")).kind(), IdentityKind::Node); + assert_eq!(Actor::Task(TaskId::from("task")).kind(), IdentityKind::Task); + + let scope = Scope { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: Some(ProcessId::from("process")), + task: Some(TaskId::from("task")), + node: Some(NodeId::from("node")), + artifact: Some(ArtifactId::from("artifact")), + }; + assert_eq!(scope.process, Some(ProcessId::from("process"))); + assert_eq!(scope.artifact, Some(ArtifactId::from("artifact"))); + + assert_ne!( + CredentialKind::BrowserSession, + CredentialKind::CliDeviceSession + ); + assert_ne!(CredentialKind::PublicKey, CredentialKind::NodeCredential); + assert_ne!( + CredentialKind::NodeCredential, + CredentialKind::TaskCredential + ); + } +} diff --git a/crates/disasmer-core/src/bundle.rs b/crates/disasmer-core/src/bundle.rs new file mode 100644 index 0000000..d38eceb --- /dev/null +++ b/crates/disasmer-core/src/bundle.rs @@ -0,0 +1,117 @@ +use serde::{Deserialize, Serialize}; + +use crate::{Digest, EnvironmentResource}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SelectedInput { + pub path: String, + pub digest: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleIdentityInputs { + pub wasm_code: Digest, + pub task_abi: Digest, + pub environments: Vec, + pub source_provider_manifest: Digest, + pub selected_inputs: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BundleMetadata { + pub identity: Digest, + pub environments: Vec, + pub selected_inputs: Vec, + pub embeds_full_container_images: bool, +} + +impl BundleIdentityInputs { + pub fn identity(&self) -> Digest { + let mut parts = vec![ + b"bundle:v1".to_vec(), + self.wasm_code.as_str().as_bytes().to_vec(), + self.task_abi.as_str().as_bytes().to_vec(), + self.source_provider_manifest.as_str().as_bytes().to_vec(), + ]; + + let mut environments = self.environments.clone(); + environments.sort_by(|left, right| left.name.cmp(&right.name)); + for environment in environments { + parts.push(environment.name.as_bytes().to_vec()); + parts.push(format!("{:?}", environment.kind).into_bytes()); + parts.push(environment.digest.as_str().as_bytes().to_vec()); + } + + let mut inputs = self.selected_inputs.clone(); + inputs.sort_by(|left, right| left.path.cmp(&right.path)); + for input in inputs { + parts.push(input.path.into_bytes()); + parts.push(input.digest.as_str().as_bytes().to_vec()); + } + + Digest::from_parts(parts) + } + + pub fn inspectable_metadata(&self) -> BundleMetadata { + BundleMetadata { + identity: self.identity(), + environments: self.environments.clone(), + selected_inputs: self.selected_inputs.clone(), + embeds_full_container_images: false, + } + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::{EnvironmentKind, EnvironmentRequirements}; + + use super::*; + + fn env(digest: &str) -> EnvironmentResource { + EnvironmentResource { + name: "linux".to_owned(), + kind: EnvironmentKind::Containerfile, + recipe_path: PathBuf::from("envs/linux/Containerfile"), + context_path: PathBuf::from("envs/linux"), + digest: Digest::sha256(digest), + requirements: EnvironmentRequirements::linux_container(), + } + } + + #[test] + fn bundle_identity_changes_when_environment_recipe_changes() { + let base = BundleIdentityInputs { + wasm_code: Digest::sha256("wasm"), + task_abi: Digest::sha256("abi"), + environments: vec![env("recipe-a")], + source_provider_manifest: Digest::sha256("source"), + selected_inputs: vec![], + }; + let mut changed = base.clone(); + changed.environments = vec![env("recipe-b")]; + + assert_ne!(base.identity(), changed.identity()); + } + + #[test] + fn bundle_metadata_is_inspectable_and_does_not_vendor_images_by_default() { + let inputs = BundleIdentityInputs { + wasm_code: Digest::sha256("wasm"), + task_abi: Digest::sha256("abi"), + environments: vec![env("recipe")], + source_provider_manifest: Digest::sha256("source"), + selected_inputs: vec![SelectedInput { + path: "inputs/config.json".to_owned(), + digest: Digest::sha256("config"), + }], + }; + + let metadata = inputs.inspectable_metadata(); + + assert_eq!(metadata.environments.len(), 1); + assert!(!metadata.embeds_full_container_images); + } +} diff --git a/crates/disasmer-core/src/capability.rs b/crates/disasmer-core/src/capability.rs new file mode 100644 index 0000000..8ffa0c8 --- /dev/null +++ b/crates/disasmer-core/src/capability.rs @@ -0,0 +1,189 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Capability { + Command, + Containers, + RootlessPodman, + SourceFilesystem, + SourceGit, + HostFilesystem, + Network, + Secrets, + InboundPorts, + ArbitrarySyscalls, + VfsArtifacts, + Wasmtime, + WindowsCommandDev, + QuicDirect, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum EnvironmentBackend { + Container, + NixFlake, + WindowsCommandDev, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum Os { + Linux, + Windows, + Macos, + Other(String), +} + +impl Os { + pub fn current() -> Self { + match std::env::consts::OS { + "linux" => Self::Linux, + "windows" => Self::Windows, + "macos" => Self::Macos, + other => Self::Other(other.to_owned()), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeCapabilities { + pub os: Os, + pub arch: String, + pub capabilities: BTreeSet, + pub environment_backends: BTreeSet, + pub source_providers: BTreeSet, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum CapabilityReportError { + #[error("node architecture `{0}` is invalid")] + InvalidArchitecture(String), + #[error("node OS label `{0}` is invalid")] + InvalidOsLabel(String), + #[error("source provider id `{0}` is invalid")] + InvalidSourceProvider(String), +} + +impl NodeCapabilities { + pub fn detect_current() -> Self { + let os = Os::current(); + let mut capabilities = BTreeSet::from([ + Capability::Command, + Capability::SourceFilesystem, + Capability::VfsArtifacts, + Capability::Wasmtime, + ]); + let mut environment_backends = BTreeSet::new(); + + match os { + Os::Linux => { + capabilities.insert(Capability::Containers); + capabilities.insert(Capability::RootlessPodman); + environment_backends.insert(EnvironmentBackend::Container); + } + Os::Windows => { + capabilities.insert(Capability::WindowsCommandDev); + environment_backends.insert(EnvironmentBackend::WindowsCommandDev); + } + Os::Macos | Os::Other(_) => {} + } + + Self { + os, + arch: std::env::consts::ARCH.to_owned(), + capabilities, + environment_backends, + source_providers: BTreeSet::from(["filesystem".to_owned(), "git".to_owned()]), + } + } + + pub fn with_capability(mut self, capability: Capability) -> Self { + self.capabilities.insert(capability); + self + } + + pub fn has_all(&self, required: &BTreeSet) -> bool { + required + .iter() + .all(|capability| self.capabilities.contains(capability)) + } + + pub fn validate_public_report(&self) -> Result<(), CapabilityReportError> { + if !valid_capability_label(&self.arch) { + return Err(CapabilityReportError::InvalidArchitecture( + self.arch.clone(), + )); + } + if let Os::Other(label) = &self.os { + if !valid_capability_label(label) { + return Err(CapabilityReportError::InvalidOsLabel(label.clone())); + } + } + for provider in &self.source_providers { + if !valid_source_provider_id(provider) { + return Err(CapabilityReportError::InvalidSourceProvider( + provider.clone(), + )); + } + } + Ok(()) + } +} + +fn valid_capability_label(label: &str) -> bool { + !label.is_empty() + && label.len() <= 64 + && label.bytes().all( + |byte| matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.'), + ) +} + +fn valid_source_provider_id(provider: &str) -> bool { + !provider.is_empty() + && provider.len() <= 64 + && provider + .bytes() + .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.')) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn capabilities() -> NodeCapabilities { + NodeCapabilities { + os: Os::Linux, + arch: "x86_64".to_owned(), + capabilities: BTreeSet::from([Capability::Command]), + environment_backends: BTreeSet::new(), + source_providers: BTreeSet::from(["filesystem".to_owned(), "git".to_owned()]), + } + } + + #[test] + fn capability_reports_validate_hostile_strings() { + assert!(capabilities().validate_public_report().is_ok()); + + let mut invalid_arch = capabilities(); + invalid_arch.arch = "x86_64\nmalicious".to_owned(); + assert_eq!( + invalid_arch.validate_public_report(), + Err(CapabilityReportError::InvalidArchitecture( + "x86_64\nmalicious".to_owned() + )) + ); + + let mut invalid_provider = capabilities(); + invalid_provider + .source_providers + .insert("../checkout".to_owned()); + assert_eq!( + invalid_provider.validate_public_report(), + Err(CapabilityReportError::InvalidSourceProvider( + "../checkout".to_owned() + )) + ); + } +} diff --git a/crates/disasmer-core/src/checkpoint.rs b/crates/disasmer-core/src/checkpoint.rs new file mode 100644 index 0000000..d1ff175 --- /dev/null +++ b/crates/disasmer-core/src/checkpoint.rs @@ -0,0 +1,282 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{Digest, EnvironmentResource, TaskId, VfsManifest}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CheckpointBoundary { + pub task_entrypoint: String, + pub serialized_args: Digest, + pub environment_digest: Digest, + pub vfs_epoch: u64, + pub task_abi: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskCheckpoint { + pub task: TaskId, + pub boundary: CheckpointBoundary, + pub vfs_manifest: VfsManifest, + pub depends_on_live_stack: bool, + pub depends_on_live_socket: bool, + pub depends_on_ephemeral_artifact_durability: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RestartRequest { + pub task: TaskId, + pub entrypoint: String, + pub serialized_args: Digest, + pub environment: EnvironmentResource, + pub task_abi: Digest, + pub source_edited: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RestartDecision { + RestartTask { + task: TaskId, + from_vfs_epoch: u64, + discard_unflushed_changes: bool, + }, + RestartWholeVirtualProcess { + message: String, + }, +} + +#[derive(Clone, Debug, Default)] +pub struct RestartPolicy; + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum CompatibilityFailure { + #[error("task entrypoint changed")] + Entrypoint, + #[error("serialized task arguments changed")] + Args, + #[error("environment digest changed")] + Environment, + #[error("task ABI changed")] + TaskAbi, + #[error("checkpoint depends on unsupported live stack migration")] + LiveStack, + #[error("checkpoint depends on unsupported live socket checkpointing")] + LiveSocket, + #[error("checkpoint incorrectly treats ephemeral artifacts as durable")] + EphemeralArtifactDurability, +} + +impl RestartPolicy { + pub fn decide(&self, checkpoint: &TaskCheckpoint, request: &RestartRequest) -> RestartDecision { + match compatibility_failure(checkpoint, request) { + None => RestartDecision::RestartTask { + task: checkpoint.task.clone(), + from_vfs_epoch: checkpoint.boundary.vfs_epoch, + discard_unflushed_changes: true, + }, + Some(failure) => RestartDecision::RestartWholeVirtualProcess { + message: format!( + "cannot restart selected task `{}` from checkpoint: {failure}; restart the whole virtual process", + checkpoint.task + ), + }, + } + } +} + +fn compatibility_failure( + checkpoint: &TaskCheckpoint, + request: &RestartRequest, +) -> Option { + if checkpoint.depends_on_live_stack { + return Some(CompatibilityFailure::LiveStack); + } + if checkpoint.depends_on_live_socket { + return Some(CompatibilityFailure::LiveSocket); + } + if checkpoint.depends_on_ephemeral_artifact_durability { + return Some(CompatibilityFailure::EphemeralArtifactDurability); + } + if checkpoint.boundary.task_entrypoint != request.entrypoint { + return Some(CompatibilityFailure::Entrypoint); + } + if checkpoint.boundary.serialized_args != request.serialized_args { + return Some(CompatibilityFailure::Args); + } + if checkpoint.boundary.environment_digest != request.environment.digest { + return Some(CompatibilityFailure::Environment); + } + if checkpoint.boundary.task_abi != request.task_abi { + return Some(CompatibilityFailure::TaskAbi); + } + None +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use crate::{EnvironmentKind, EnvironmentRequirements, NodeId, VfsOverlay, VfsPath}; + + use super::*; + + fn env(digest_input: &str) -> EnvironmentResource { + EnvironmentResource { + name: "linux".to_owned(), + kind: EnvironmentKind::Containerfile, + recipe_path: PathBuf::from("envs/linux/Containerfile"), + context_path: PathBuf::from("envs/linux"), + digest: Digest::sha256(digest_input), + requirements: EnvironmentRequirements::linux_container(), + } + } + + fn checkpoint() -> (TaskCheckpoint, EnvironmentResource) { + let environment = env("env"); + let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node")); + overlay.write( + VfsPath::new("/vfs/artifacts/app").unwrap(), + Digest::sha256("app"), + 3, + ); + let manifest = overlay.flush(); + ( + TaskCheckpoint { + task: TaskId::from("task"), + boundary: CheckpointBoundary { + task_entrypoint: "compile_linux".to_owned(), + serialized_args: Digest::sha256("args"), + environment_digest: environment.digest.clone(), + vfs_epoch: manifest.epoch, + task_abi: Digest::sha256("abi"), + }, + vfs_manifest: manifest, + depends_on_live_stack: false, + depends_on_live_socket: false, + depends_on_ephemeral_artifact_durability: false, + }, + environment, + ) + } + + fn restart_request(environment: EnvironmentResource) -> RestartRequest { + RestartRequest { + task: TaskId::from("task"), + entrypoint: "compile_linux".to_owned(), + serialized_args: Digest::sha256("args"), + environment, + task_abi: Digest::sha256("abi"), + source_edited: true, + } + } + + fn assert_whole_process_restart(decision: RestartDecision, expected_reason: &str) { + match decision { + RestartDecision::RestartWholeVirtualProcess { message } => { + assert!( + message.contains(expected_reason), + "restart message `{message}` did not include `{expected_reason}`" + ); + assert!( + message.contains("restart the whole virtual process"), + "restart message `{message}` did not direct a whole-process restart" + ); + } + RestartDecision::RestartTask { .. } => { + panic!("incompatible checkpoint unexpectedly restarted selected task") + } + } + } + + #[test] + fn compatible_restart_uses_task_boundary_and_discards_unflushed_changes() { + let (checkpoint, environment) = checkpoint(); + let request = restart_request(environment); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_eq!( + decision, + RestartDecision::RestartTask { + task: TaskId::from("task"), + from_vfs_epoch: 1, + discard_unflushed_changes: true + } + ); + } + + #[test] + fn incompatible_environment_requires_whole_process_restart() { + let (checkpoint, _) = checkpoint(); + let request = restart_request(env("changed-env")); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "environment digest changed"); + } + + #[test] + fn incompatible_entrypoint_requires_whole_process_restart() { + let (checkpoint, environment) = checkpoint(); + let mut request = restart_request(environment); + request.entrypoint = "package_linux".to_owned(); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "task entrypoint changed"); + } + + #[test] + fn incompatible_serialized_args_require_whole_process_restart() { + let (checkpoint, environment) = checkpoint(); + let mut request = restart_request(environment); + request.serialized_args = Digest::sha256("changed-args"); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "serialized task arguments changed"); + } + + #[test] + fn incompatible_task_abi_requires_whole_process_restart() { + let (checkpoint, environment) = checkpoint(); + let mut request = restart_request(environment); + request.task_abi = Digest::sha256("changed-abi"); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "task ABI changed"); + } + + #[test] + fn restart_never_claims_live_stack_migration() { + let (mut checkpoint, environment) = checkpoint(); + checkpoint.depends_on_live_stack = true; + let request = restart_request(environment); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "live stack"); + } + + #[test] + fn restart_never_claims_live_socket_checkpointing() { + let (mut checkpoint, environment) = checkpoint(); + checkpoint.depends_on_live_socket = true; + let request = restart_request(environment); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "live socket"); + } + + #[test] + fn restart_never_depends_on_ephemeral_artifact_durability() { + let (mut checkpoint, environment) = checkpoint(); + checkpoint.depends_on_ephemeral_artifact_durability = true; + let request = restart_request(environment); + + let decision = RestartPolicy.decide(&checkpoint, &request); + + assert_whole_process_restart(decision, "ephemeral artifacts"); + } +} diff --git a/crates/disasmer-core/src/debug.rs b/crates/disasmer-core/src/debug.rs new file mode 100644 index 0000000..1c1eb54 --- /dev/null +++ b/crates/disasmer-core/src/debug.rs @@ -0,0 +1,276 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ProcessId, TaskId}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DebugParticipantKind { + WasmTask, + ControlledNativeCommand, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DebugRuntimeState { + Running, + Frozen, + Completed, + Failed(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DebugParticipant { + pub task: TaskId, + pub name: String, + pub kind: DebugParticipantKind, + pub can_freeze: bool, + pub state: DebugRuntimeState, + pub stack_frames: Vec, + pub local_values: Vec<(String, String)>, + pub task_args: Vec<(String, String)>, + pub handles: Vec<(String, String)>, + pub command_status: Option, + pub recent_output: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DebugStopReason { + Breakpoint { task: TaskId, line: u32 }, + PauseRequest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ThreadInspection { + pub task: TaskId, + pub name: String, + pub stack_frames: Vec, + pub local_values: Vec<(String, String)>, + pub task_args: Vec<(String, String)>, + pub handles: Vec<(String, String)>, + pub command_status: Option, + pub recent_output: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DebugEpoch { + pub process: ProcessId, + pub epoch: u64, + pub reason: DebugStopReason, + participants: BTreeMap, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum DebugEpochError { + #[error("participant `{task}` cannot freeze, so all-stop failed")] + CannotFreeze { task: TaskId }, + #[error("participant `{0}` is not part of this debug epoch")] + UnknownParticipant(TaskId), +} + +impl DebugEpoch { + pub fn all_stop( + process: ProcessId, + epoch: u64, + reason: DebugStopReason, + participants: Vec, + ) -> Result { + for participant in &participants { + if matches!( + participant.kind, + DebugParticipantKind::WasmTask | DebugParticipantKind::ControlledNativeCommand + ) && !participant.can_freeze + { + return Err(DebugEpochError::CannotFreeze { + task: participant.task.clone(), + }); + } + } + + let participants = participants + .into_iter() + .map(|mut participant| { + if matches!(participant.state, DebugRuntimeState::Running) { + participant.state = DebugRuntimeState::Frozen; + } + (participant.task.clone(), participant) + }) + .collect(); + + Ok(Self { + process, + epoch, + reason, + participants, + }) + } + + pub fn pause( + process: ProcessId, + epoch: u64, + participants: Vec, + ) -> Result { + Self::all_stop(process, epoch, DebugStopReason::PauseRequest, participants) + } + + pub fn continue_all(&mut self) { + for participant in self.participants.values_mut() { + if participant.state == DebugRuntimeState::Frozen { + participant.state = DebugRuntimeState::Running; + } + } + } + + pub fn inspection(&self, task: &TaskId) -> Result { + let participant = self + .participants + .get(task) + .ok_or_else(|| DebugEpochError::UnknownParticipant(task.clone()))?; + Ok(ThreadInspection { + task: participant.task.clone(), + name: participant.name.clone(), + stack_frames: participant.stack_frames.clone(), + local_values: participant.local_values.clone(), + task_args: participant.task_args.clone(), + handles: participant.handles.clone(), + command_status: participant.command_status.clone(), + recent_output: participant.recent_output.clone(), + }) + } + + pub fn participant_state(&self, task: &TaskId) -> Option<&DebugRuntimeState> { + self.participants + .get(task) + .map(|participant| &participant.state) + } + + pub fn thread_names(&self) -> Vec { + self.participants + .values() + .map(|participant| participant.name.clone()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn participant(task: &str, kind: DebugParticipantKind, can_freeze: bool) -> DebugParticipant { + DebugParticipant { + task: TaskId::from(task), + name: task.to_owned(), + kind, + can_freeze, + state: DebugRuntimeState::Running, + stack_frames: vec![format!("{task}::run")], + local_values: vec![("wasm_local_0".to_owned(), "I32(41)".to_owned())], + task_args: vec![("target".to_owned(), "linux".to_owned())], + handles: vec![("artifact".to_owned(), "artifact-1".to_owned())], + command_status: Some("running".to_owned()), + recent_output: vec!["building".to_owned()], + } + } + + #[test] + fn breakpoint_creates_all_stop_debug_epoch_for_wasm_and_command_tasks() { + let epoch = DebugEpoch::all_stop( + ProcessId::from("process"), + 1, + DebugStopReason::Breakpoint { + task: TaskId::from("compile-linux"), + line: 42, + }, + vec![ + participant("main", DebugParticipantKind::WasmTask, true), + participant( + "compile-linux", + DebugParticipantKind::ControlledNativeCommand, + true, + ), + ], + ) + .unwrap(); + + assert_eq!( + epoch.participant_state(&TaskId::from("main")), + Some(&DebugRuntimeState::Frozen) + ); + assert_eq!( + epoch.participant_state(&TaskId::from("compile-linux")), + Some(&DebugRuntimeState::Frozen) + ); + } + + #[test] + fn debug_epoch_reports_freeze_failure_instead_of_claiming_all_stop() { + let error = DebugEpoch::pause( + ProcessId::from("process"), + 1, + vec![participant( + "compile-linux", + DebugParticipantKind::ControlledNativeCommand, + false, + )], + ) + .unwrap_err(); + + assert!(matches!(error, DebugEpochError::CannotFreeze { .. })); + } + + #[test] + fn continue_resumes_every_frozen_participant() { + let mut epoch = DebugEpoch::pause( + ProcessId::from("process"), + 1, + vec![ + participant("main", DebugParticipantKind::WasmTask, true), + participant("task", DebugParticipantKind::WasmTask, true), + ], + ) + .unwrap(); + + epoch.continue_all(); + + assert_eq!( + epoch.participant_state(&TaskId::from("main")), + Some(&DebugRuntimeState::Running) + ); + assert_eq!( + epoch.participant_state(&TaskId::from("task")), + Some(&DebugRuntimeState::Running) + ); + } + + #[test] + fn inspection_exposes_stack_args_handles_command_status_and_output() { + let epoch = DebugEpoch::pause( + ProcessId::from("process"), + 1, + vec![participant( + "compile-linux", + DebugParticipantKind::ControlledNativeCommand, + true, + )], + ) + .unwrap(); + + let inspection = epoch.inspection(&TaskId::from("compile-linux")).unwrap(); + + assert_eq!(inspection.stack_frames, vec!["compile-linux::run"]); + assert_eq!( + inspection.local_values[0], + ("wasm_local_0".to_owned(), "I32(41)".to_owned()) + ); + assert_eq!( + inspection.task_args[0], + ("target".to_owned(), "linux".to_owned()) + ); + assert_eq!( + inspection.handles[0], + ("artifact".to_owned(), "artifact-1".to_owned()) + ); + assert_eq!(inspection.command_status, Some("running".to_owned())); + assert_eq!(inspection.recent_output, vec!["building"]); + } +} diff --git a/crates/disasmer-core/src/digest.rs b/crates/disasmer-core/src/digest.rs new file mode 100644 index 0000000..f544ffd --- /dev/null +++ b/crates/disasmer-core/src/digest.rs @@ -0,0 +1,56 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest as ShaDigest, Sha256}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct Digest(String); + +impl Digest { + pub fn sha256(bytes: impl AsRef<[u8]>) -> Self { + let mut hasher = Sha256::new(); + hasher.update(bytes.as_ref()); + Self(format!("sha256:{}", hex::encode(hasher.finalize()))) + } + + pub fn from_parts(parts: impl IntoIterator>) -> Self { + let mut hasher = Sha256::new(); + for part in parts { + let part = part.as_ref(); + hasher.update((part.len() as u64).to_be_bytes()); + hasher.update(part); + } + Self(format!("sha256:{}", hex::encode(hasher.finalize()))) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn is_valid_sha256(&self) -> bool { + let Some(hex) = self.0.strip_prefix("sha256:") else { + return false; + }; + hex.len() == 64 + && hex + .bytes() + .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) + } +} + +impl std::fmt::Display for Digest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digest_validates_strict_sha256_syntax() { + assert!(Digest::sha256("bytes").is_valid_sha256()); + assert!(!Digest("sha1:abc".to_owned()).is_valid_sha256()); + assert!(!Digest("sha256:ABCDEF".to_owned()).is_valid_sha256()); + assert!(!Digest("sha256:not-hex".to_owned()).is_valid_sha256()); + } +} diff --git a/crates/disasmer-core/src/environment.rs b/crates/disasmer-core/src/environment.rs new file mode 100644 index 0000000..ea2aee5 --- /dev/null +++ b/crates/disasmer-core/src/environment.rs @@ -0,0 +1,288 @@ +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{Capability, Digest, Os}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum EnvironmentKind { + Containerfile, + Dockerfile, + NixFlake, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentRequirements { + pub os: Option, + pub arch: Option, + pub capabilities: BTreeSet, +} + +impl EnvironmentRequirements { + pub fn linux_container() -> Self { + Self { + os: Some(Os::Linux), + arch: None, + capabilities: BTreeSet::from([Capability::Containers, Capability::RootlessPodman]), + } + } + + pub fn windows_command_dev() -> Self { + Self { + os: Some(Os::Windows), + arch: None, + capabilities: BTreeSet::from([Capability::WindowsCommandDev]), + } + } + + pub fn unconstrained() -> Self { + Self { + os: None, + arch: None, + capabilities: BTreeSet::new(), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentResource { + pub name: String, + pub kind: EnvironmentKind, + pub recipe_path: PathBuf, + pub context_path: PathBuf, + pub digest: Digest, + pub requirements: EnvironmentRequirements, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentReference { + pub name: String, + pub byte_offset: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvironmentDiagnostic { + pub reference: EnvironmentReference, + pub message: String, +} + +#[derive(Debug, Error)] +pub enum EnvironmentError { + #[error("failed to read environment resources under {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, +} + +pub fn discover_environments( + project_root: &Path, +) -> Result, EnvironmentError> { + let envs_dir = project_root.join("envs"); + if !envs_dir.exists() { + return Ok(Vec::new()); + } + + let mut resources = Vec::new(); + let entries = fs::read_dir(&envs_dir).map_err(|source| EnvironmentError::Read { + path: envs_dir.clone(), + source, + })?; + + for entry in entries { + let entry = entry.map_err(|source| EnvironmentError::Read { + path: envs_dir.clone(), + source, + })?; + let path = entry.path(); + if !path.is_dir() { + continue; + } + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + + if let Some(resource) = discover_one(project_root, name, &path)? { + resources.push(resource); + } + } + + resources.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(resources) +} + +pub fn diagnose_environment_references( + source: &str, + environments: &[EnvironmentResource], +) -> Vec { + let known = environments + .iter() + .map(|environment| environment.name.as_str()) + .collect::>(); + + find_env_macro_references(source) + .into_iter() + .filter(|reference| !known.contains(reference.name.as_str())) + .map(|reference| EnvironmentDiagnostic { + message: format!( + "missing Disasmer environment `{}`; expected envs/{}/Containerfile or envs/{}/Dockerfile", + reference.name, reference.name, reference.name + ), + reference, + }) + .collect() +} + +fn discover_one( + project_root: &Path, + name: &str, + env_dir: &Path, +) -> Result, EnvironmentError> { + let candidates = [ + ("Containerfile", EnvironmentKind::Containerfile), + ("Dockerfile", EnvironmentKind::Dockerfile), + ("flake.nix", EnvironmentKind::NixFlake), + ]; + + for (file_name, kind) in candidates { + let recipe_path = env_dir.join(file_name); + if !recipe_path.exists() { + continue; + } + + let recipe_bytes = fs::read(&recipe_path).map_err(|source| EnvironmentError::Read { + path: recipe_path.clone(), + source, + })?; + let relative_recipe = recipe_path + .strip_prefix(project_root) + .unwrap_or(&recipe_path) + .to_string_lossy(); + let digest = Digest::from_parts([ + b"environment:v1".as_slice(), + name.as_bytes(), + format!("{kind:?}").as_bytes(), + relative_recipe.as_bytes(), + recipe_bytes.as_slice(), + ]); + let requirements = match kind { + EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile + if name.eq_ignore_ascii_case("windows") => + { + EnvironmentRequirements::windows_command_dev() + } + EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => { + EnvironmentRequirements::linux_container() + } + EnvironmentKind::NixFlake => EnvironmentRequirements::unconstrained(), + }; + + return Ok(Some(EnvironmentResource { + name: name.to_owned(), + kind, + recipe_path, + context_path: env_dir.to_path_buf(), + digest, + requirements, + })); + } + + Ok(None) +} + +fn find_env_macro_references(source: &str) -> Vec { + let mut references = Vec::new(); + let mut cursor = 0; + + while let Some(index) = source[cursor..].find("env!(") { + let start = cursor + index; + let mut pos = start + "env!(".len(); + while source[pos..].starts_with(char::is_whitespace) { + pos += source[pos..] + .chars() + .next() + .map(char::len_utf8) + .unwrap_or(1); + } + if !source[pos..].starts_with('"') { + cursor = pos; + continue; + } + pos += 1; + let name_start = pos; + while pos < source.len() && !source[pos..].starts_with('"') { + pos += source[pos..] + .chars() + .next() + .map(char::len_utf8) + .unwrap_or(1); + } + if pos < source.len() { + references.push(EnvironmentReference { + name: source[name_start..pos].to_owned(), + byte_offset: start, + }); + } + cursor = pos.saturating_add(1); + } + + references +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn discovers_containerfile_environments_by_logical_name() { + let temp = tempfile::tempdir().unwrap(); + let linux = temp.path().join("envs/linux"); + fs::create_dir_all(&linux).unwrap(); + fs::write(linux.join("Containerfile"), "FROM alpine\n").unwrap(); + + let envs = discover_environments(temp.path()).unwrap(); + + assert_eq!(envs.len(), 1); + assert_eq!(envs[0].name, "linux"); + assert_eq!(envs[0].kind, EnvironmentKind::Containerfile); + assert!(!envs[0].digest.as_str().is_empty()); + } + + #[test] + fn missing_env_macro_reference_reports_clear_diagnostic() { + let source = r#"fn main() { let _ = env!("windows"); }"#; + let diagnostics = diagnose_environment_references(source, &[]); + + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0] + .message + .contains("envs/windows/Containerfile")); + } + + #[test] + fn windows_environment_name_uses_windows_development_requirements() { + let temp = tempfile::tempdir().unwrap(); + let windows = temp.path().join("envs/windows"); + fs::create_dir_all(&windows).unwrap(); + fs::write( + windows.join("Dockerfile"), + "# user-attached windows dev contract\n", + ) + .unwrap(); + + let envs = discover_environments(temp.path()).unwrap(); + + assert_eq!(envs[0].name, "windows"); + assert_eq!(envs[0].requirements.os, Some(Os::Windows)); + assert!(envs[0] + .requirements + .capabilities + .contains(&Capability::WindowsCommandDev)); + } +} diff --git a/crates/disasmer-core/src/execution.rs b/crates/disasmer-core/src/execution.rs new file mode 100644 index 0000000..6cc2b9a --- /dev/null +++ b/crates/disasmer-core/src/execution.rs @@ -0,0 +1,90 @@ +use serde::{Deserialize, Serialize}; + +use crate::{Capability, EnvironmentResource}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum GuestRuntimeKind { + Wasmtime, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum CommandBackendKind { + LinuxRootlessPodman, + WindowsCommandDev, + StubbedWindowsSandbox, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandInvocation { + pub program: String, + pub args: Vec, + pub env: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandPlan { + pub guest_runtime: GuestRuntimeKind, + pub backend: CommandBackendKind, + pub required_capability: Capability, + pub user_attached_development_execution: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NativeCommandPolicy { + pub hosted_control_plane: bool, + pub node_has_command_capability: bool, +} + +impl NativeCommandPolicy { + pub fn authorize(&self) -> Result<(), String> { + if self.hosted_control_plane { + return Err("hosted coordinator control plane cannot run native commands".to_owned()); + } + if !self.node_has_command_capability { + return Err("selected node or task lacks native command capability".to_owned()); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TaskBoundaryValue { + SmallJson(serde_json::Value), + SourceSnapshot(crate::Digest), + Blob(crate::Digest), + Artifact(crate::ArtifactId), + VfsManifest(crate::Digest), +} + +impl TaskBoundaryValue { + pub fn reject_host_only(type_name: &str) -> Result { + Err(format!( + "task boundary value `{type_name}` is host-only; use small serialized data or handles" + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hosted_control_plane_cannot_authorize_native_command() { + let policy = NativeCommandPolicy { + hosted_control_plane: true, + node_has_command_capability: true, + }; + + assert!(policy + .authorize() + .unwrap_err() + .contains("hosted coordinator")); + } + + #[test] + fn raw_pointer_style_task_argument_is_rejected() { + let error = TaskBoundaryValue::reject_host_only("*const u8").unwrap_err(); + + assert!(error.contains("host-only")); + } +} diff --git a/crates/disasmer-core/src/ids.rs b/crates/disasmer-core/src/ids.rs new file mode 100644 index 0000000..29cc5b7 --- /dev/null +++ b/crates/disasmer-core/src/ids.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; + +macro_rules! id_type { + ($name:ident) => { + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Self { + let value = value.into(); + assert!( + !value.trim().is_empty(), + concat!(stringify!($name), " cannot be empty") + ); + Self(value) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl From<&str> for $name { + fn from(value: &str) -> Self { + Self::new(value) + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } + } + }; +} + +id_type!(AgentId); +id_type!(ArtifactId); +id_type!(NodeId); +id_type!(ProcessId); +id_type!(ProjectId); +id_type!(TaskId); +id_type!(TenantId); +id_type!(UserId); diff --git a/crates/disasmer-core/src/lib.rs b/crates/disasmer-core/src/lib.rs new file mode 100644 index 0000000..ece5b8c --- /dev/null +++ b/crates/disasmer-core/src/lib.rs @@ -0,0 +1,75 @@ +pub mod artifact; +pub mod auth; +pub mod bundle; +pub mod capability; +pub mod checkpoint; +pub mod debug; +pub mod digest; +pub mod environment; +pub mod execution; +pub mod ids; +pub mod limits; +pub mod operator_panel; +pub mod policy; +pub mod project; +pub mod scheduler; +pub mod source; +pub mod transport; +pub mod vfs; + +pub use artifact::{ + ArtifactDownloadStream, ArtifactMetadata, ArtifactRegistry, ArtifactUnavailable, + DownloadAction, DownloadError, DownloadLink, DownloadPolicy, RetentionPolicy, StorageLocation, +}; +pub use auth::{ + node_capability_policy_digest, Action, Actor, AuthContext, Authorization, BrowserLoginFlow, + CliLoginFlow, CredentialKind, EnrollmentError, EnrollmentGrant, IdentityKind, NodeCredential, + PublicKeyIdentity, Scope, +}; +pub use bundle::{BundleIdentityInputs, BundleMetadata, SelectedInput}; +pub use capability::{Capability, CapabilityReportError, EnvironmentBackend, NodeCapabilities, Os}; +pub use checkpoint::{ + CheckpointBoundary, CompatibilityFailure, RestartDecision, RestartPolicy, RestartRequest, + TaskCheckpoint, +}; +pub use debug::{ + DebugEpoch, DebugEpochError, DebugParticipant, DebugParticipantKind, DebugRuntimeState, + DebugStopReason, ThreadInspection, +}; +pub use digest::Digest; +pub use environment::{ + diagnose_environment_references, discover_environments, EnvironmentDiagnostic, EnvironmentKind, + EnvironmentReference, EnvironmentRequirements, EnvironmentResource, +}; +pub use execution::{ + CommandBackendKind, CommandInvocation, CommandPlan, GuestRuntimeKind, NativeCommandPolicy, + TaskBoundaryValue, +}; +pub use ids::{AgentId, ArtifactId, NodeId, ProcessId, ProjectId, TaskId, TenantId, UserId}; +pub use limits::{ + LargeArgumentPolicy, LimitError, LimitKind, LogBuffer, LogRecord, ResourceLimits, + ResourceMeter, TaskArgumentBudget, +}; +pub use operator_panel::{ + ControlPlaneAction, PanelError, PanelEvent, PanelEventKind, PanelState, PanelWidget, + PanelWidgetKind, RateLimit, +}; +pub use policy::{ + CapabilityPolicy, Decision, LocalTrustedPolicy, PolicyReason, ResourceRequest, ServicePolicy, +}; +pub use project::{Entrypoint, ProjectModel, ProjectModelError}; +pub use scheduler::{ + DefaultScheduler, NodeDescriptor, Placement, PlacementError, PlacementRequest, Scheduler, +}; +pub use source::{ + SourceManifestError, SourcePreparation, SourceProviderKind, SourceProviderManifest, + SourceProviderModule, SourceTransferMode, SourceTransferPolicy, +}; +pub use transport::{ + BulkTransferDecision, DataPlaneObject, DataPlaneScope, DirectBulkTransferPlan, + NativeQuicTransport, NodeEndpoint, RendezvousRequest, Transport, TransportError, TransportKind, +}; +pub use vfs::{ + ReuseDecision, SyncPolicy, VfsError, VfsManifest, VfsObject, VfsOverlay, VfsPath, + VfsSyncDecision, +}; diff --git a/crates/disasmer-core/src/limits.rs b/crates/disasmer-core/src/limits.rs new file mode 100644 index 0000000..0b95571 --- /dev/null +++ b/crates/disasmer-core/src/limits.rs @@ -0,0 +1,255 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::TaskId; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum LimitKind { + ApiCall, + Spawn, + LogBytes, + MetadataBytes, + DebugReadBytes, + UiEvent, + RendezvousAttempt, + ArtifactDownloadBytes, + HostedFuel, + HostedMemoryBytes, + HostedWallClockMs, + HostedStateBytes, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceLimits { + pub limits: BTreeMap, +} + +impl ResourceLimits { + pub fn community_tier_defaults() -> Self { + Self { + limits: BTreeMap::from([ + (LimitKind::ApiCall, 10_000), + (LimitKind::Spawn, 64), + (LimitKind::LogBytes, 256 * 1024), + (LimitKind::MetadataBytes, 512 * 1024), + (LimitKind::DebugReadBytes, 128 * 1024), + (LimitKind::UiEvent, 1_000), + (LimitKind::RendezvousAttempt, 128), + (LimitKind::ArtifactDownloadBytes, 256 * 1024 * 1024), + (LimitKind::HostedFuel, 10_000_000), + (LimitKind::HostedMemoryBytes, 64 * 1024 * 1024), + (LimitKind::HostedWallClockMs, 30_000), + (LimitKind::HostedStateBytes, 1024 * 1024), + ]), + } + } + + pub fn limit(&self, kind: &LimitKind) -> u64 { + *self.limits.get(kind).unwrap_or(&0) + } +} + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceMeter { + used: BTreeMap, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum LimitError { + #[error( + "resource limit exceeded for {kind:?}: requested {requested}, used {used}, limit {limit}" + )] + Exceeded { + kind: LimitKind, + requested: u64, + used: u64, + limit: u64, + }, + #[error("task argument is too large: {size} bytes exceeds {limit} bytes")] + LargeTaskArgument { size: u64, limit: u64 }, +} + +impl ResourceMeter { + pub fn can_charge( + &self, + limits: &ResourceLimits, + kind: LimitKind, + amount: u64, + ) -> Result<(), LimitError> { + let used = self.used.get(&kind).copied().unwrap_or(0); + let limit = limits.limit(&kind); + if used.saturating_add(amount) > limit { + return Err(LimitError::Exceeded { + kind, + requested: amount, + used, + limit, + }); + } + Ok(()) + } + + pub fn charge( + &mut self, + limits: &ResourceLimits, + kind: LimitKind, + amount: u64, + ) -> Result<(), LimitError> { + self.can_charge(limits, kind.clone(), amount)?; + let used = self.used.get(&kind).copied().unwrap_or(0); + self.used.insert(kind, used + amount); + Ok(()) + } + + pub fn used(&self, kind: &LimitKind) -> u64 { + self.used.get(kind).copied().unwrap_or(0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LogRecord { + pub task: TaskId, + pub bytes: Vec, + pub truncated: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LogBuffer { + max_bytes: usize, + used_bytes: usize, + records: Vec, + backpressured: bool, +} + +impl LogBuffer { + pub fn new(max_bytes: usize) -> Self { + Self { + max_bytes, + used_bytes: 0, + records: Vec::new(), + backpressured: false, + } + } + + pub fn push(&mut self, task: TaskId, bytes: impl AsRef<[u8]>) { + let bytes = bytes.as_ref(); + let remaining = self.max_bytes.saturating_sub(self.used_bytes); + let truncated = bytes.len() > remaining; + let stored = bytes[..bytes.len().min(remaining)].to_vec(); + self.used_bytes += stored.len(); + if truncated { + self.backpressured = true; + } + self.records.push(LogRecord { + task, + bytes: stored, + truncated, + }); + } + + pub fn records(&self) -> &[LogRecord] { + &self.records + } + + pub fn backpressured(&self) -> bool { + self.backpressured + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LargeArgumentPolicy { + Allow, + Warn, + Reject, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskArgumentBudget { + pub max_inline_bytes: u64, + pub policy: LargeArgumentPolicy, +} + +impl TaskArgumentBudget { + pub fn validate(&self, size: u64) -> Result, LimitError> { + if size <= self.max_inline_bytes { + return Ok(None); + } + + match self.policy { + LargeArgumentPolicy::Allow => Ok(None), + LargeArgumentPolicy::Warn => Ok(Some(format!( + "task argument is {size} bytes; prefer SourceSnapshot, Blob, Artifact, or VFS handles" + ))), + LargeArgumentPolicy::Reject => Err(LimitError::LargeTaskArgument { + size, + limit: self.max_inline_bytes, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn resource_meter_rejects_usage_before_work_starts() { + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::Spawn, 1)]), + }; + let mut meter = ResourceMeter::default(); + + meter.charge(&limits, LimitKind::Spawn, 1).unwrap(); + let error = meter.charge(&limits, LimitKind::Spawn, 1).unwrap_err(); + + assert!(matches!(error, LimitError::Exceeded { .. })); + } + + #[test] + fn resource_meter_can_check_limits_without_consuming() { + let limits = ResourceLimits { + limits: BTreeMap::from([(LimitKind::ArtifactDownloadBytes, 4)]), + }; + let mut meter = ResourceMeter::default(); + + meter + .can_charge(&limits, LimitKind::ArtifactDownloadBytes, 4) + .unwrap(); + assert_eq!(meter.used(&LimitKind::ArtifactDownloadBytes), 0); + meter + .charge(&limits, LimitKind::ArtifactDownloadBytes, 3) + .unwrap(); + assert!(matches!( + meter.can_charge(&limits, LimitKind::ArtifactDownloadBytes, 2), + Err(LimitError::Exceeded { .. }) + )); + } + + #[test] + fn log_buffer_caps_backpressures_and_keeps_task_association() { + let mut logs = LogBuffer::new(4); + logs.push(TaskId::from("task-a"), b"abcdef"); + + assert!(logs.backpressured()); + assert_eq!(logs.records()[0].task, TaskId::from("task-a")); + assert_eq!(logs.records()[0].bytes, b"abcd"); + assert!(logs.records()[0].truncated); + } + + #[test] + fn large_task_arguments_are_rejected_or_warned() { + let reject = TaskArgumentBudget { + max_inline_bytes: 4, + policy: LargeArgumentPolicy::Reject, + }; + assert!(reject.validate(5).is_err()); + + let warn = TaskArgumentBudget { + max_inline_bytes: 4, + policy: LargeArgumentPolicy::Warn, + }; + assert!(warn.validate(5).unwrap().unwrap().contains("Artifact")); + } +} diff --git a/crates/disasmer-core/src/operator_panel.rs b/crates/disasmer-core/src/operator_panel.rs new file mode 100644 index 0000000..88b1633 --- /dev/null +++ b/crates/disasmer-core/src/operator_panel.rs @@ -0,0 +1,375 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ArtifactId, DownloadAction, DownloadError, ProcessId, ProjectId, TaskId, TenantId}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PanelWidgetKind { + Text { + value: String, + }, + Progress { + current: u64, + total: u64, + }, + Button { + action: String, + }, + Toggle { + value: bool, + }, + Select { + options: Vec, + selected: String, + }, + ArtifactDownload { + artifact: ArtifactId, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PanelWidget { + pub id: String, + pub label: String, + pub kind: PanelWidgetKind, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ControlPlaneAction { + RestartTask(TaskId), + CancelProcess, + DebugProcess, + DownloadArtifact(ArtifactId), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PanelState { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub widgets: BTreeMap, + pub program_ui_events_enabled: bool, + pub control_plane_actions: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PanelEventKind { + ButtonClicked, + ToggleChanged(bool), + SelectChanged(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PanelEvent { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub widget_id: String, + pub kind: PanelEventKind, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RateLimit { + pub max_events: u64, + pub used_events: u64, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum PanelError { + #[error("custom HTML or JavaScript is not supported in operator panels")] + CustomContentDenied, + #[error( + "operator panel widget `{0}` is not allowed to collect secrets or OAuth-like credentials" + )] + CredentialCollectionDenied(String), + #[error("panel event scope does not match tenant/project/process")] + ScopeMismatch, + #[error("program UI events are disabled while debug process is stopped")] + ProgramEventsDisabled, + #[error("panel event rate limit exceeded")] + RateLimited, + #[error("unknown panel widget `{0}`")] + UnknownWidget(String), + #[error("artifact download action is unavailable: {0}")] + DownloadUnavailable(String), +} + +impl PanelState { + pub fn new(tenant: TenantId, project: ProjectId, process: ProcessId) -> Self { + Self { + tenant, + project, + process, + widgets: BTreeMap::new(), + program_ui_events_enabled: true, + control_plane_actions: Vec::new(), + } + } + + pub fn add_widget(&mut self, widget: PanelWidget) -> Result<(), PanelError> { + validate_widget(&widget)?; + self.widgets.insert(widget.id.clone(), widget); + Ok(()) + } + + pub fn add_download_widget_from_action( + &mut self, + widget_id: impl Into, + label: impl Into, + action: Result, + ) -> Result<(), PanelError> { + let action = action.map_err(|err| PanelError::DownloadUnavailable(err.to_string()))?; + let artifact = action.artifact; + self.add_widget(PanelWidget { + id: widget_id.into(), + label: label.into(), + kind: PanelWidgetKind::ArtifactDownload { + artifact: artifact.clone(), + }, + })?; + self.control_plane_actions + .push(ControlPlaneAction::DownloadArtifact(artifact)); + Ok(()) + } + + pub fn reject_custom_content(_html_or_js: &str) -> Result<(), PanelError> { + Err(PanelError::CustomContentDenied) + } + + pub fn freeze_program_ui_events(&mut self) { + self.program_ui_events_enabled = false; + } + + pub fn set_control_plane_actions(&mut self, actions: Vec) { + self.control_plane_actions = actions; + } + + pub fn accept_event( + &self, + event: &PanelEvent, + limit: &mut RateLimit, + ) -> Result<(), PanelError> { + if !self.program_ui_events_enabled { + return Err(PanelError::ProgramEventsDisabled); + } + if self.tenant != event.tenant + || self.project != event.project + || self.process != event.process + { + return Err(PanelError::ScopeMismatch); + } + if !self.widgets.contains_key(&event.widget_id) { + return Err(PanelError::UnknownWidget(event.widget_id.clone())); + } + if limit.used_events >= limit.max_events { + return Err(PanelError::RateLimited); + } + limit.used_events += 1; + Ok(()) + } + + pub fn control_plane_actions_available(&self) -> &[ControlPlaneAction] { + &self.control_plane_actions + } +} + +fn validate_widget(widget: &PanelWidget) -> Result<(), PanelError> { + let mut checked_text = vec![widget.id.as_str(), widget.label.as_str()]; + match &widget.kind { + PanelWidgetKind::Button { action } => checked_text.push(action), + PanelWidgetKind::Select { options, selected } => { + checked_text.push(selected); + checked_text.extend(options.iter().map(String::as_str)); + } + PanelWidgetKind::Text { .. } + | PanelWidgetKind::Progress { .. } + | PanelWidgetKind::Toggle { .. } + | PanelWidgetKind::ArtifactDownload { .. } => {} + } + + let combined = checked_text.join(" ").to_ascii_lowercase(); + if combined.contains("password") + || combined.contains("token") + || combined.contains("oauth") + || combined.contains("secret") + { + return Err(PanelError::CredentialCollectionDenied(widget.id.clone())); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn panel() -> PanelState { + PanelState::new( + TenantId::from("tenant"), + ProjectId::from("project"), + ProcessId::from("process"), + ) + } + + #[test] + fn panel_uses_typed_widgets_and_rejects_custom_content() { + let mut panel = panel(); + panel + .add_widget(PanelWidget { + id: "progress".to_owned(), + label: "Build".to_owned(), + kind: PanelWidgetKind::Progress { + current: 1, + total: 2, + }, + }) + .unwrap(); + + assert!(PanelState::reject_custom_content("").is_err()); + assert!(panel.widgets.contains_key("progress")); + } + + #[test] + fn panel_rejects_password_or_oauth_collection_widgets() { + let mut panel = panel(); + let error = panel + .add_widget(PanelWidget { + id: "oauth_token".to_owned(), + label: "OAuth Token".to_owned(), + kind: PanelWidgetKind::Text { + value: String::new(), + }, + }) + .unwrap_err(); + + assert!(matches!(error, PanelError::CredentialCollectionDenied(_))); + } + + #[test] + fn panel_rejects_credential_collection_in_interactive_fields() { + let mut panel = panel(); + let button_error = panel + .add_widget(PanelWidget { + id: "continue".to_owned(), + label: "Continue".to_owned(), + kind: PanelWidgetKind::Button { + action: "collect-secret".to_owned(), + }, + }) + .unwrap_err(); + assert!(matches!( + button_error, + PanelError::CredentialCollectionDenied(_) + )); + + let select_error = panel + .add_widget(PanelWidget { + id: "auth-mode".to_owned(), + label: "Auth Mode".to_owned(), + kind: PanelWidgetKind::Select { + options: vec!["password".to_owned(), "public key".to_owned()], + selected: "public key".to_owned(), + }, + }) + .unwrap_err(); + assert!(matches!( + select_error, + PanelError::CredentialCollectionDenied(_) + )); + } + + #[test] + fn panel_events_are_scoped_and_rate_limited() { + let mut panel = panel(); + panel + .add_widget(PanelWidget { + id: "restart".to_owned(), + label: "Restart".to_owned(), + kind: PanelWidgetKind::Button { + action: "restart".to_owned(), + }, + }) + .unwrap(); + let event = PanelEvent { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("process"), + widget_id: "restart".to_owned(), + kind: PanelEventKind::ButtonClicked, + }; + let mut limit = RateLimit { + max_events: 1, + used_events: 0, + }; + + panel.accept_event(&event, &mut limit).unwrap(); + assert_eq!( + panel.accept_event(&event, &mut limit), + Err(PanelError::RateLimited) + ); + } + + #[test] + fn stopped_debug_process_keeps_control_plane_actions_available() { + let mut panel = panel(); + panel.freeze_program_ui_events(); + panel.set_control_plane_actions(vec![ + ControlPlaneAction::RestartTask(TaskId::from("task")), + ControlPlaneAction::DownloadArtifact(ArtifactId::from("artifact")), + ]); + + let event = PanelEvent { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("process"), + widget_id: "missing".to_owned(), + kind: PanelEventKind::ButtonClicked, + }; + let mut limit = RateLimit { + max_events: 1, + used_events: 0, + }; + + assert_eq!( + panel.accept_event(&event, &mut limit), + Err(PanelError::ProgramEventsDisabled) + ); + assert_eq!(panel.control_plane_actions_available().len(), 2); + } + + #[test] + fn download_widget_is_only_created_from_available_action() { + let mut panel = panel(); + let action = Ok(DownloadAction { + artifact: ArtifactId::from("artifact"), + source: crate::StorageLocation::RetainedNode(crate::NodeId::from("node")), + scoped_token_subject: "tenant/project/process/artifact".to_owned(), + }); + + panel + .add_download_widget_from_action("download-artifact", "Download", action) + .unwrap(); + + assert!(matches!( + panel.widgets["download-artifact"].kind, + PanelWidgetKind::ArtifactDownload { .. } + )); + assert!(matches!( + panel.control_plane_actions_available()[0], + ControlPlaneAction::DownloadArtifact(_) + )); + + let before = panel.widgets.len(); + let error = panel + .add_download_widget_from_action( + "missing-download", + "Download", + Err(DownloadError::Unavailable), + ) + .unwrap_err(); + + assert_eq!(panel.widgets.len(), before); + assert!(matches!(error, PanelError::DownloadUnavailable(_))); + } +} diff --git a/crates/disasmer-core/src/policy.rs b/crates/disasmer-core/src/policy.rs new file mode 100644 index 0000000..7c44663 --- /dev/null +++ b/crates/disasmer-core/src/policy.rs @@ -0,0 +1,134 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; + +use crate::{Action, AuthContext, Capability, Scope}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum PolicyReason { + Allowed, + MissingCapability(Capability), + HostedNativeComputeDenied, + HostedContainerDenied, + QuotaExceeded(String), + Unauthorized(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Decision { + pub allowed: bool, + pub reason: PolicyReason, +} + +impl Decision { + pub fn allow() -> Self { + Self { + allowed: true, + reason: PolicyReason::Allowed, + } + } + + pub fn deny(reason: PolicyReason) -> Self { + Self { + allowed: false, + reason, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ResourceRequest { + pub action: Action, + pub required_capabilities: BTreeSet, + pub hosted_control_plane: bool, +} + +pub trait CapabilityPolicy { + fn decide(&self, context: &AuthContext, scope: &Scope, request: &ResourceRequest) -> Decision; +} + +pub trait ServicePolicy: CapabilityPolicy + Send + Sync {} + +impl ServicePolicy for T where T: CapabilityPolicy + Send + Sync {} + +#[derive(Clone, Debug, Default)] +pub struct LocalTrustedPolicy; + +impl CapabilityPolicy for LocalTrustedPolicy { + fn decide(&self, context: &AuthContext, scope: &Scope, request: &ResourceRequest) -> Decision { + let authz = crate::auth::same_tenant_project(context, scope); + if !authz.allowed { + return Decision::deny(PolicyReason::Unauthorized(authz.reason)); + } + if request.hosted_control_plane && request.action == Action::RunNativeCommand { + return Decision::deny(PolicyReason::HostedNativeComputeDenied); + } + if request.hosted_control_plane && request.action == Action::RunContainer { + return Decision::deny(PolicyReason::HostedContainerDenied); + } + Decision::allow() + } +} + +#[cfg(test)] +mod tests { + use crate::{Actor, ProjectId, TenantId, UserId}; + + use super::*; + + #[test] + fn public_policy_interface_denies_hosted_native_compute() { + let policy = LocalTrustedPolicy; + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("user")), + }; + let scope = Scope { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: None, + task: None, + node: None, + artifact: None, + }; + let request = ResourceRequest { + action: Action::RunNativeCommand, + required_capabilities: BTreeSet::new(), + hosted_control_plane: true, + }; + + let decision = policy.decide(&context, &scope, &request); + + assert!(!decision.allowed); + assert_eq!(decision.reason, PolicyReason::HostedNativeComputeDenied); + } + + #[test] + fn local_trusted_policy_allows_owner_controlled_native_capability_request() { + let policy = LocalTrustedPolicy; + let context = AuthContext { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + actor: Actor::User(UserId::from("owner")), + }; + let scope = Scope { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: None, + task: None, + node: None, + artifact: None, + }; + let request = ResourceRequest { + action: Action::RunNativeCommand, + required_capabilities: BTreeSet::from([Capability::Command]), + hosted_control_plane: false, + }; + + let decision = policy.decide(&context, &scope, &request); + + assert!(decision.allowed); + assert_eq!(decision.reason, PolicyReason::Allowed); + } +} diff --git a/crates/disasmer-core/src/project.rs b/crates/disasmer-core/src/project.rs new file mode 100644 index 0000000..98f6e9f --- /dev/null +++ b/crates/disasmer-core/src/project.rs @@ -0,0 +1,125 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{discover_environments, environment::EnvironmentError, EnvironmentResource}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Entrypoint { + pub name: String, + pub function: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProjectModel { + pub root: PathBuf, + pub environments: Vec, + pub entrypoints: BTreeMap, + pub default_entrypoint: String, + pub required_config_file: Option, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum ProjectModelError { + #[error("environment discovery failed: {0}")] + Environment(String), + #[error("unknown Disasmer entrypoint `{name}`; available entrypoints: {available:?}")] + UnknownEntrypoint { + name: String, + available: Vec, + }, +} + +impl ProjectModel { + pub fn discover_without_config(root: &Path) -> Result { + let environments = discover_environments(root).map_err(|err| { + ProjectModelError::Environment(match err { + EnvironmentError::Read { path, source } => { + format!("failed to read {}: {source}", path.display()) + } + }) + })?; + Ok(Self { + root: root.to_path_buf(), + environments, + entrypoints: default_entrypoints(), + default_entrypoint: "build".to_owned(), + required_config_file: None, + }) + } + + pub fn select_entrypoint(&self, name: Option<&str>) -> Result<&Entrypoint, ProjectModelError> { + let name = name.unwrap_or(&self.default_entrypoint); + self.entrypoints + .get(name) + .ok_or_else(|| ProjectModelError::UnknownEntrypoint { + name: name.to_owned(), + available: self.entrypoints.keys().cloned().collect(), + }) + } +} + +fn default_entrypoints() -> BTreeMap { + ["build", "test", "package", "release", "watch"] + .into_iter() + .map(|name| { + ( + name.to_owned(), + Entrypoint { + name: name.to_owned(), + function: format!("{name}_main"), + }, + ) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use std::fs; + + use super::*; + + #[test] + fn project_works_without_hand_written_configuration_file() { + let temp = tempfile::tempdir().unwrap(); + fs::create_dir_all(temp.path().join("envs/linux")).unwrap(); + fs::write( + temp.path().join("envs/linux/Containerfile"), + "FROM alpine\n", + ) + .unwrap(); + + let model = ProjectModel::discover_without_config(temp.path()).unwrap(); + + assert_eq!(model.required_config_file, None); + assert_eq!(model.environments[0].name, "linux"); + assert_eq!(model.select_entrypoint(None).unwrap().name, "build"); + } + + #[test] + fn project_can_define_multiple_default_entrypoints() { + let temp = tempfile::tempdir().unwrap(); + let model = ProjectModel::discover_without_config(temp.path()).unwrap(); + + assert_eq!( + model.select_entrypoint(Some("test")).unwrap().function, + "test_main" + ); + assert_eq!( + model.select_entrypoint(Some("release")).unwrap().function, + "release_main" + ); + } + + #[test] + fn unknown_entrypoint_lists_available_choices() { + let temp = tempfile::tempdir().unwrap(); + let model = ProjectModel::discover_without_config(temp.path()).unwrap(); + let error = model.select_entrypoint(Some("deploy")).unwrap_err(); + + assert!(matches!(error, ProjectModelError::UnknownEntrypoint { .. })); + } +} diff --git a/crates/disasmer-core/src/scheduler.rs b/crates/disasmer-core/src/scheduler.rs new file mode 100644 index 0000000..0cf2e5e --- /dev/null +++ b/crates/disasmer-core/src/scheduler.rs @@ -0,0 +1,429 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ + ArtifactId, Capability, Digest, EnvironmentRequirements, NodeCapabilities, NodeId, ProjectId, + TenantId, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeDescriptor { + pub id: NodeId, + pub tenant: TenantId, + pub project: ProjectId, + pub capabilities: NodeCapabilities, + pub cached_environments: BTreeSet, + pub dependency_caches: BTreeSet, + pub source_snapshots: BTreeSet, + pub artifact_locations: BTreeSet, + pub direct_connectivity: bool, + pub online: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlacementRequest { + pub tenant: TenantId, + pub project: ProjectId, + pub environment: Option, + pub environment_digest: Option, + pub required_capabilities: BTreeSet, + pub dependency_cache: Option, + pub source_snapshot: Option, + pub required_artifacts: BTreeSet, + pub quota_available: bool, + pub policy_allowed: bool, + pub prefer_node: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Placement { + pub node: NodeId, + pub score: i64, + pub reasons: Vec, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[error("no capable node for placement: {message}")] +pub struct PlacementError { + pub message: String, +} + +pub trait Scheduler { + fn place( + &self, + nodes: &[NodeDescriptor], + request: &PlacementRequest, + ) -> Result; +} + +#[derive(Clone, Debug, Default)] +pub struct DefaultScheduler; + +impl Scheduler for DefaultScheduler { + fn place( + &self, + nodes: &[NodeDescriptor], + request: &PlacementRequest, + ) -> Result { + let mut scored = Vec::new(); + let mut rejection_counts = BTreeMap::::new(); + + for node in nodes { + match compatibility(node, request) { + Ok(mut placement) => { + locality_score(node, request, &mut placement); + scored.push(placement); + } + Err(reasons) => { + for reason in reasons { + *rejection_counts.entry(reason).or_default() += 1; + } + } + } + } + + scored + .into_iter() + .max_by_key(|placement| placement.score) + .ok_or_else(|| PlacementError { + message: rejection_counts + .into_iter() + .map(|(reason, count)| format!("{reason} ({count} node(s))")) + .collect::>() + .join("; "), + }) + } +} + +fn compatibility( + node: &NodeDescriptor, + request: &PlacementRequest, +) -> Result> { + let mut reasons = Vec::new(); + if !node.online { + reasons.push("node offline".to_owned()); + } + if node.tenant != request.tenant { + reasons.push("tenant mismatch".to_owned()); + } + if node.project != request.project { + reasons.push("project mismatch".to_owned()); + } + if !request.quota_available { + reasons.push("quota unavailable for placement".to_owned()); + } + if !request.policy_allowed { + reasons.push("policy denied placement".to_owned()); + } + for capability in &request.required_capabilities { + if !node.capabilities.capabilities.contains(capability) { + reasons.push(format!("missing capability {capability:?}")); + } + } + if let Some(environment) = &request.environment { + if let Some(required_os) = &environment.os { + if &node.capabilities.os != required_os { + reasons.push(format!("environment requires os {required_os:?}")); + } + } + if let Some(required_arch) = &environment.arch { + if &node.capabilities.arch != required_arch { + reasons.push(format!("environment requires arch {required_arch}")); + } + } + for capability in &environment.capabilities { + if !node.capabilities.capabilities.contains(capability) { + reasons.push(format!("environment requires capability {capability:?}")); + } + } + } + let source_transfer_required = request + .source_snapshot + .as_ref() + .is_some_and(|digest| !node.source_snapshots.contains(digest)); + if source_transfer_required && !node.direct_connectivity { + reasons.push("source snapshot unavailable and direct connectivity unavailable".to_owned()); + } + let missing_artifacts = request + .required_artifacts + .iter() + .filter(|artifact| !node.artifact_locations.contains(*artifact)) + .count(); + if missing_artifacts > 0 && !node.direct_connectivity { + reasons.push(format!( + "{missing_artifacts} required artifact(s) unavailable and direct connectivity unavailable" + )); + } + + if reasons.is_empty() { + Ok(Placement { + node: node.id.clone(), + score: 0, + reasons: Vec::new(), + }) + } else { + Err(reasons) + } +} + +fn locality_score(node: &NodeDescriptor, request: &PlacementRequest, placement: &mut Placement) { + if request.prefer_node.as_ref() == Some(&node.id) { + placement.score += 100; + placement.reasons.push("preferred node".to_owned()); + } + if request + .environment_digest + .as_ref() + .is_some_and(|digest| node.cached_environments.contains(digest)) + { + placement.score += 50; + placement.reasons.push("warm environment cache".to_owned()); + } + if request + .source_snapshot + .as_ref() + .is_some_and(|digest| node.source_snapshots.contains(digest)) + { + placement.score += 40; + placement + .reasons + .push("source snapshot already local".to_owned()); + } + if request + .dependency_cache + .as_ref() + .is_some_and(|digest| node.dependency_caches.contains(digest)) + { + placement.score += 30; + placement.reasons.push("warm dependency cache".to_owned()); + } + let artifact_hits = request + .required_artifacts + .iter() + .filter(|artifact| node.artifact_locations.contains(*artifact)) + .count() as i64; + if artifact_hits > 0 { + placement.score += 10 * artifact_hits; + placement.reasons.push(format!( + "{artifact_hits} required artifact(s) already local" + )); + } +} + +#[cfg(test)] +mod tests { + use crate::{EnvironmentBackend, Os}; + + use super::*; + + fn node(id: &str, cached_source: bool) -> NodeDescriptor { + let source = Digest::sha256("source"); + NodeDescriptor { + id: NodeId::from(id), + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + capabilities: NodeCapabilities { + os: Os::Linux, + arch: "x86_64".to_owned(), + capabilities: BTreeSet::from([ + Capability::Command, + Capability::Containers, + Capability::RootlessPodman, + ]), + environment_backends: BTreeSet::from([EnvironmentBackend::Container]), + source_providers: BTreeSet::from(["filesystem".to_owned()]), + }, + cached_environments: BTreeSet::from([Digest::sha256("env")]), + dependency_caches: if cached_source { + BTreeSet::from([Digest::sha256("deps")]) + } else { + BTreeSet::new() + }, + source_snapshots: if cached_source { + BTreeSet::from([source]) + } else { + BTreeSet::new() + }, + artifact_locations: BTreeSet::new(), + direct_connectivity: true, + online: true, + } + } + + #[test] + fn scheduler_prefers_warm_source_and_environment() { + let request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: Some(EnvironmentRequirements::linux_container()), + environment_digest: Some(Digest::sha256("env")), + required_capabilities: BTreeSet::from([Capability::Command]), + dependency_cache: Some(Digest::sha256("deps")), + source_snapshot: Some(Digest::sha256("source")), + required_artifacts: BTreeSet::new(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + + let placement = DefaultScheduler + .place(&[node("cold", false), node("warm", true)], &request) + .unwrap(); + + assert_eq!(placement.node, NodeId::from("warm")); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("source"))); + assert!(placement + .reasons + .iter() + .any(|reason| reason.contains("dependency"))); + } + + #[test] + fn scheduler_failure_names_missing_constraint() { + let mut request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: None, + environment_digest: None, + required_capabilities: BTreeSet::from([Capability::WindowsCommandDev]), + dependency_cache: None, + source_snapshot: None, + required_artifacts: BTreeSet::new(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + request.required_capabilities.insert(Capability::Command); + + let error = DefaultScheduler + .place(&[node("linux", false)], &request) + .unwrap_err(); + + assert!(error.message.contains("WindowsCommandDev")); + } + + #[test] + fn scheduler_failure_names_environment_constraint() { + let request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: Some(EnvironmentRequirements::windows_command_dev()), + environment_digest: None, + required_capabilities: BTreeSet::new(), + dependency_cache: None, + source_snapshot: None, + required_artifacts: BTreeSet::new(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + + let error = DefaultScheduler + .place(&[node("linux", false)], &request) + .unwrap_err(); + + assert!(error.message.contains("environment requires os Windows")); + assert!(error + .message + .contains("environment requires capability WindowsCommandDev")); + } + + #[test] + fn scheduler_requires_direct_connectivity_when_transfer_is_needed() { + let mut disconnected = node("disconnected", false); + disconnected.direct_connectivity = false; + let mut local = node("local", true); + local.direct_connectivity = false; + let request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: None, + environment_digest: None, + required_capabilities: BTreeSet::from([Capability::Command]), + dependency_cache: None, + source_snapshot: Some(Digest::sha256("source")), + required_artifacts: BTreeSet::new(), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + + let placement = DefaultScheduler + .place(&[disconnected, local], &request) + .unwrap(); + assert_eq!(placement.node, NodeId::from("local")); + + let mut disconnected = node("disconnected", false); + disconnected.direct_connectivity = false; + let error = DefaultScheduler + .place(&[disconnected], &request) + .unwrap_err(); + + assert!(error + .message + .contains("source snapshot unavailable and direct connectivity unavailable")); + } + + #[test] + fn scheduler_failure_names_required_artifact_transfer_constraint() { + let mut disconnected = node("disconnected", true); + disconnected.direct_connectivity = false; + let request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: None, + environment_digest: None, + required_capabilities: BTreeSet::from([Capability::Command]), + dependency_cache: None, + source_snapshot: None, + required_artifacts: BTreeSet::from([ArtifactId::from("cache")]), + quota_available: true, + policy_allowed: true, + prefer_node: None, + }; + + let error = DefaultScheduler + .place(&[disconnected], &request) + .unwrap_err(); + + assert!(error + .message + .contains("1 required artifact(s) unavailable and direct connectivity unavailable")); + } + + #[test] + fn scheduler_failure_names_quota_and_policy_constraints() { + let mut request = PlacementRequest { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + environment: None, + environment_digest: None, + required_capabilities: BTreeSet::from([Capability::Command]), + dependency_cache: None, + source_snapshot: None, + required_artifacts: BTreeSet::new(), + quota_available: false, + policy_allowed: true, + prefer_node: None, + }; + + let error = DefaultScheduler + .place(&[node("linux", false)], &request) + .unwrap_err(); + + assert!(error.message.contains("quota unavailable for placement")); + + request.quota_available = true; + request.policy_allowed = false; + let error = DefaultScheduler + .place(&[node("linux", false)], &request) + .unwrap_err(); + + assert!(error.message.contains("policy denied placement")); + } +} diff --git a/crates/disasmer-core/src/source.rs b/crates/disasmer-core/src/source.rs new file mode 100644 index 0000000..c68771d --- /dev/null +++ b/crates/disasmer-core/src/source.rs @@ -0,0 +1,324 @@ +use std::collections::BTreeSet; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{Capability, Digest, ProjectId, TenantId}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum SourceProviderKind { + Filesystem, + Git, + Custom(String), +} + +impl SourceProviderKind { + pub fn provider_id(&self) -> &str { + match self { + SourceProviderKind::Filesystem => "filesystem", + SourceProviderKind::Git => "git", + SourceProviderKind::Custom(provider) => provider, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub enum SourceTransferMode { + RequiredContent, + ExplicitSnapshotChunks, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceTransferPolicy { + pub local_source_bytes_remain_node_local: bool, + pub coordinator_receives_source_bytes_by_default: bool, + pub default_full_repo_tarball: bool, + pub allowed_remote_transfer: BTreeSet, +} + +impl SourceTransferPolicy { + pub fn local_first_snapshot_chunks() -> Self { + Self { + local_source_bytes_remain_node_local: true, + coordinator_receives_source_bytes_by_default: false, + default_full_repo_tarball: false, + allowed_remote_transfer: BTreeSet::from([ + SourceTransferMode::RequiredContent, + SourceTransferMode::ExplicitSnapshotChunks, + ]), + } + } +} + +impl Default for SourceTransferPolicy { + fn default() -> Self { + Self::local_first_snapshot_chunks() + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceProviderManifest { + pub kind: SourceProviderKind, + pub digest: Digest, + pub description: String, + #[serde(default)] + pub coordinator_requires_checkout_access: bool, + #[serde(default)] + pub transfer_policy: SourceTransferPolicy, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum SourceManifestError { + #[error("source provider manifest digest is not a valid sha256 digest: {0}")] + InvalidDigest(String), + #[error("custom source provider id `{0}` is invalid")] + InvalidProviderId(String), + #[error("source provider manifest description must be non-empty")] + EmptyDescription, + #[error("source provider manifest description is too long")] + DescriptionTooLong, + #[error("source provider manifest description contains control characters")] + DescriptionControlCharacter, + #[error("source provider manifest would require coordinator checkout access")] + CoordinatorCheckoutAccess, + #[error("source provider manifest would send source bytes to the coordinator by default")] + CoordinatorReceivesSourceBytes, + #[error("source provider manifest would default to a full-repo tarball")] + DefaultFullRepoTarball, + #[error("source provider manifest has no allowed remote transfer mode")] + MissingRemoteTransferMode, +} + +pub trait SourceProviderModule { + fn kind(&self) -> SourceProviderKind; + fn manifest(&self) -> SourceProviderManifest; +} + +impl SourceProviderManifest { + pub fn local_first(kind: SourceProviderKind, description: impl Into) -> Self { + let transfer_policy = SourceTransferPolicy::local_first_snapshot_chunks(); + let digest = Self::digest_for(&kind, false, &transfer_policy); + Self { + kind, + digest, + description: description.into(), + coordinator_requires_checkout_access: false, + transfer_policy, + } + } + + pub fn validate_public_mvp(&self) -> Result<(), SourceManifestError> { + self.validate_shape()?; + if self.coordinator_requires_checkout_access { + return Err(SourceManifestError::CoordinatorCheckoutAccess); + } + if self + .transfer_policy + .coordinator_receives_source_bytes_by_default + { + return Err(SourceManifestError::CoordinatorReceivesSourceBytes); + } + if self.transfer_policy.default_full_repo_tarball { + return Err(SourceManifestError::DefaultFullRepoTarball); + } + if self.transfer_policy.allowed_remote_transfer.is_empty() { + return Err(SourceManifestError::MissingRemoteTransferMode); + } + Ok(()) + } + + fn validate_shape(&self) -> Result<(), SourceManifestError> { + if !self.digest.is_valid_sha256() { + return Err(SourceManifestError::InvalidDigest( + self.digest.as_str().to_owned(), + )); + } + if let SourceProviderKind::Custom(provider) = &self.kind { + if !valid_provider_id(provider) { + return Err(SourceManifestError::InvalidProviderId(provider.clone())); + } + } + if self.description.trim().is_empty() { + return Err(SourceManifestError::EmptyDescription); + } + if self.description.len() > 256 { + return Err(SourceManifestError::DescriptionTooLong); + } + if self.description.chars().any(char::is_control) { + return Err(SourceManifestError::DescriptionControlCharacter); + } + Ok(()) + } + + fn digest_for( + kind: &SourceProviderKind, + coordinator_requires_checkout_access: bool, + transfer_policy: &SourceTransferPolicy, + ) -> Digest { + let mut modes = transfer_policy + .allowed_remote_transfer + .iter() + .map(|mode| format!("{mode:?}")) + .collect::>(); + modes.sort(); + let mut parts = vec![ + b"source-provider-manifest:v2".to_vec(), + kind.provider_id().as_bytes().to_vec(), + coordinator_requires_checkout_access + .to_string() + .into_bytes(), + transfer_policy + .local_source_bytes_remain_node_local + .to_string() + .into_bytes(), + transfer_policy + .coordinator_receives_source_bytes_by_default + .to_string() + .into_bytes(), + transfer_policy + .default_full_repo_tarball + .to_string() + .into_bytes(), + ]; + parts.extend(modes.into_iter().map(String::into_bytes)); + Digest::from_parts(parts) + } +} + +fn valid_provider_id(provider: &str) -> bool { + !provider.is_empty() + && provider.len() <= 64 + && provider + .bytes() + .all(|byte| matches!(byte, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.')) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourcePreparation { + pub tenant: TenantId, + pub project: ProjectId, + pub provider: SourceProviderKind, + pub required_capabilities: BTreeSet, + pub coordinator_requires_checkout_access: bool, +} + +impl SourcePreparation { + pub fn node_task(tenant: TenantId, project: ProjectId, provider: SourceProviderKind) -> Self { + let capability = match provider { + SourceProviderKind::Filesystem => Capability::SourceFilesystem, + SourceProviderKind::Git => Capability::SourceGit, + SourceProviderKind::Custom(_) => Capability::SourceFilesystem, + }; + + Self { + tenant, + project, + provider, + required_capabilities: BTreeSet::from([capability]), + coordinator_requires_checkout_access: false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_preparation_can_be_scheduled_as_node_task() { + let prep = SourcePreparation::node_task( + TenantId::from("tenant"), + ProjectId::from("project"), + SourceProviderKind::Git, + ); + + assert!(!prep.coordinator_requires_checkout_access); + assert!(prep.required_capabilities.contains(&Capability::SourceGit)); + } + + #[test] + fn local_first_source_manifest_rejects_bulk_coordinator_paths() { + let manifest = SourceProviderManifest::local_first( + SourceProviderKind::Git, + "node-side Git snapshot provider", + ); + + assert!(manifest.validate_public_mvp().is_ok()); + assert!(!manifest.coordinator_requires_checkout_access); + assert!( + !manifest + .transfer_policy + .coordinator_receives_source_bytes_by_default + ); + assert!(!manifest.transfer_policy.default_full_repo_tarball); + assert!(manifest + .transfer_policy + .allowed_remote_transfer + .contains(&SourceTransferMode::ExplicitSnapshotChunks)); + } + + #[test] + fn source_manifest_validation_treats_manifest_as_hostile_input() { + let mut manifest = SourceProviderManifest::local_first( + SourceProviderKind::Custom("gitlab-lfs".to_owned()), + "custom provider", + ); + assert!(manifest.validate_public_mvp().is_ok()); + + manifest.kind = SourceProviderKind::Custom("../checkout".to_owned()); + assert_eq!( + manifest.validate_public_mvp(), + Err(SourceManifestError::InvalidProviderId( + "../checkout".to_owned() + )) + ); + + manifest.kind = SourceProviderKind::Git; + manifest.digest = Digest::sha256("valid"); + manifest.coordinator_requires_checkout_access = true; + assert_eq!( + manifest.validate_public_mvp(), + Err(SourceManifestError::CoordinatorCheckoutAccess) + ); + + manifest.coordinator_requires_checkout_access = false; + manifest + .transfer_policy + .coordinator_receives_source_bytes_by_default = true; + assert_eq!( + manifest.validate_public_mvp(), + Err(SourceManifestError::CoordinatorReceivesSourceBytes) + ); + + manifest + .transfer_policy + .coordinator_receives_source_bytes_by_default = false; + manifest.transfer_policy.default_full_repo_tarball = true; + assert_eq!( + manifest.validate_public_mvp(), + Err(SourceManifestError::DefaultFullRepoTarball) + ); + } + + #[test] + fn source_manifest_rejects_malformed_digest_from_json() { + let mut manifest = SourceProviderManifest::local_first( + SourceProviderKind::Filesystem, + "filesystem provider", + ); + let value = serde_json::to_value(&manifest).unwrap(); + let mut object = value.as_object().unwrap().clone(); + object.insert( + "digest".to_owned(), + serde_json::Value::String("sha256:not-a-real-digest".to_owned()), + ); + manifest = serde_json::from_value(serde_json::Value::Object(object)).unwrap(); + + assert_eq!( + manifest.validate_public_mvp(), + Err(SourceManifestError::InvalidDigest( + "sha256:not-a-real-digest".to_owned() + )) + ); + } +} diff --git a/crates/disasmer-core/src/transport.rs b/crates/disasmer-core/src/transport.rs new file mode 100644 index 0000000..3ab9c11 --- /dev/null +++ b/crates/disasmer-core/src/transport.rs @@ -0,0 +1,243 @@ +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ArtifactId, Digest, NodeId, ProcessId, ProjectId, TenantId}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TransportKind { + NativeQuic, +} + +pub trait Transport { + fn kind(&self) -> TransportKind; + fn authenticated_direct_connections(&self) -> bool; +} + +#[derive(Clone, Debug, Default)] +pub struct NativeQuicTransport; + +impl Transport for NativeQuicTransport { + fn kind(&self) -> TransportKind { + TransportKind::NativeQuic + } + + fn authenticated_direct_connections(&self) -> bool { + true + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DataPlaneScope { + pub tenant: TenantId, + pub project: ProjectId, + pub process: ProcessId, + pub object: DataPlaneObject, + pub authorization_subject: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum DataPlaneObject { + Artifact(ArtifactId), + Blob(Digest), + SourceSnapshot(Digest), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct NodeEndpoint { + pub node: NodeId, + pub advertised_addr: String, + pub public_key_fingerprint: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RendezvousRequest { + pub scope: DataPlaneScope, + pub source: NodeEndpoint, + pub destination: NodeEndpoint, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct DirectBulkTransferPlan { + pub transport: TransportKind, + pub scope: DataPlaneScope, + pub source: NodeEndpoint, + pub destination: NodeEndpoint, + pub authorization_digest: Digest, + pub coordinator_assisted_rendezvous: bool, + pub coordinator_bulk_relay_allowed: bool, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum TransportError { + #[error( + "direct node-to-node connectivity is unavailable for scoped data-plane transfer: {reason}; coordinator bulk relay is disabled" + )] + DirectConnectivityUnavailable { reason: String }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum BulkTransferDecision { + DirectAuthenticated { scope: DataPlaneScope }, + FailClear { message: String }, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[error("bulk relay through coordinator is not allowed by default")] +pub struct BulkRelayDenied; + +impl NativeQuicTransport { + pub fn plan_authenticated_direct_bulk_transfer( + &self, + request: RendezvousRequest, + direct_connectivity: bool, + failure_reason: impl Into, + ) -> Result { + if !direct_connectivity { + return Err(TransportError::DirectConnectivityUnavailable { + reason: failure_reason.into(), + }); + } + + let authorization_digest = data_plane_authorization_digest(&request); + Ok(DirectBulkTransferPlan { + transport: self.kind(), + scope: request.scope, + source: request.source, + destination: request.destination, + authorization_digest, + coordinator_assisted_rendezvous: true, + coordinator_bulk_relay_allowed: false, + }) + } +} + +pub fn direct_bulk_transfer_or_error( + scope: DataPlaneScope, + direct_connectivity: bool, +) -> BulkTransferDecision { + if direct_connectivity { + BulkTransferDecision::DirectAuthenticated { scope } + } else { + BulkTransferDecision::FailClear { + message: + "direct node-to-node connectivity is unavailable; coordinator bulk relay is disabled" + .to_owned(), + } + } +} + +fn data_plane_authorization_digest(request: &RendezvousRequest) -> Digest { + let object = match &request.scope.object { + DataPlaneObject::Artifact(artifact) => format!("artifact:{artifact}"), + DataPlaneObject::Blob(digest) => format!("blob:{}", digest.as_str()), + DataPlaneObject::SourceSnapshot(digest) => format!("source:{}", digest.as_str()), + }; + + Digest::from_parts([ + b"dataplane-auth:v1".as_slice(), + request.scope.tenant.as_str().as_bytes(), + request.scope.project.as_str().as_bytes(), + request.scope.process.as_str().as_bytes(), + object.as_bytes(), + request.scope.authorization_subject.as_bytes(), + request.source.node.as_str().as_bytes(), + request.source.public_key_fingerprint.as_str().as_bytes(), + request.destination.node.as_str().as_bytes(), + request + .destination + .public_key_fingerprint + .as_str() + .as_bytes(), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn endpoint(name: &str) -> NodeEndpoint { + NodeEndpoint { + node: NodeId::from(name), + advertised_addr: format!("{name}.mesh.invalid:4433"), + public_key_fingerprint: Digest::sha256(format!("{name}-public-key")), + } + } + + fn scope(project: &str) -> DataPlaneScope { + DataPlaneScope { + tenant: TenantId::from("tenant"), + project: ProjectId::from(project), + process: ProcessId::from("process"), + object: DataPlaneObject::Artifact(ArtifactId::from("artifact")), + authorization_subject: "node-a-to-node-b".to_owned(), + } + } + + #[test] + fn failed_direct_transfer_does_not_silently_relay() { + let decision = direct_bulk_transfer_or_error(scope("project"), false); + + assert!(matches!(decision, BulkTransferDecision::FailClear { .. })); + } + + #[test] + fn native_quic_rendezvous_plan_is_scoped_and_disallows_coordinator_bulk_relay() { + let transport = NativeQuicTransport; + let request = RendezvousRequest { + scope: scope("project"), + source: endpoint("node-a"), + destination: endpoint("node-b"), + }; + + let plan = transport + .plan_authenticated_direct_bulk_transfer(request.clone(), true, "") + .unwrap(); + let changed_scope_plan = transport + .plan_authenticated_direct_bulk_transfer( + RendezvousRequest { + scope: scope("other-project"), + ..request + }, + true, + "", + ) + .unwrap(); + + assert_eq!(plan.transport, TransportKind::NativeQuic); + assert_eq!(plan.scope.tenant, TenantId::from("tenant")); + assert_eq!(plan.scope.project, ProjectId::from("project")); + assert_eq!(plan.scope.process, ProcessId::from("process")); + assert_eq!( + plan.scope.object, + DataPlaneObject::Artifact(ArtifactId::from("artifact")) + ); + assert_eq!(plan.source.node, NodeId::from("node-a")); + assert_eq!(plan.destination.node, NodeId::from("node-b")); + assert!(plan.coordinator_assisted_rendezvous); + assert!(!plan.coordinator_bulk_relay_allowed); + assert_ne!( + plan.authorization_digest, + changed_scope_plan.authorization_digest + ); + } + + #[test] + fn failed_direct_rendezvous_reports_clear_error_instead_of_relaying() { + let error = NativeQuicTransport + .plan_authenticated_direct_bulk_transfer( + RendezvousRequest { + scope: scope("project"), + source: endpoint("node-a"), + destination: endpoint("node-b"), + }, + false, + "nat traversal failed", + ) + .unwrap_err(); + + assert!(error.to_string().contains("nat traversal failed")); + assert!(error + .to_string() + .contains("coordinator bulk relay is disabled")); + } +} diff --git a/crates/disasmer-core/src/vfs.rs b/crates/disasmer-core/src/vfs.rs new file mode 100644 index 0000000..e11d559 --- /dev/null +++ b/crates/disasmer-core/src/vfs.rs @@ -0,0 +1,241 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{Digest, NodeId, TaskId}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct VfsPath(String); + +impl VfsPath { + pub fn new(path: impl Into) -> Result { + let path = path.into(); + if !path.starts_with("/vfs/") { + return Err(VfsError::InvalidPath(path)); + } + Ok(Self(path)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VfsObject { + pub path: VfsPath, + pub digest: Digest, + pub size: u64, + pub producer: TaskId, + pub node: NodeId, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VfsManifest { + pub epoch: u64, + pub producer: TaskId, + pub node: NodeId, + pub objects: BTreeMap, + pub large_bytes_uploaded: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SyncPolicy { + MetadataOnly, + ExplicitNode(NodeId), + ExplicitStore(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum VfsSyncDecision { + NoBytesMoved, + MoveBytesToNode(NodeId), + MoveBytesToStore(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum ReuseDecision { + SameNodeZeroCopy, + NeedsTransfer { from: NodeId, to: NodeId }, + Unavailable, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum VfsError { + #[error("VFS path must start with /vfs/: {0}")] + InvalidPath(String), + #[error("path is not visible in the published VFS manifest: {0}")] + NotVisible(String), +} + +#[derive(Clone, Debug)] +pub struct VfsOverlay { + task: TaskId, + node: NodeId, + epoch: u64, + pending: BTreeMap, + published: BTreeMap, +} + +impl VfsOverlay { + pub fn new(task: TaskId, node: NodeId) -> Self { + Self { + task, + node, + epoch: 0, + pending: BTreeMap::new(), + published: BTreeMap::new(), + } + } + + pub fn write(&mut self, path: VfsPath, digest: Digest, size: u64) -> VfsObject { + let object = VfsObject { + path: path.clone(), + digest, + size, + producer: self.task.clone(), + node: self.node.clone(), + }; + self.pending.insert(path, object.clone()); + object + } + + pub fn flush(&mut self) -> VfsManifest { + self.epoch += 1; + self.published.append(&mut self.pending); + VfsManifest { + epoch: self.epoch, + producer: self.task.clone(), + node: self.node.clone(), + objects: self.published.clone(), + large_bytes_uploaded: false, + } + } + + pub fn sync(&self, policy: SyncPolicy) -> VfsSyncDecision { + match policy { + SyncPolicy::MetadataOnly => VfsSyncDecision::NoBytesMoved, + SyncPolicy::ExplicitNode(node) => VfsSyncDecision::MoveBytesToNode(node), + SyncPolicy::ExplicitStore(store) => VfsSyncDecision::MoveBytesToStore(store), + } + } + + pub fn read_published<'a>( + manifest: &'a VfsManifest, + path: &VfsPath, + ) -> Result<&'a VfsObject, VfsError> { + manifest + .objects + .get(path) + .ok_or_else(|| VfsError::NotVisible(path.as_str().to_owned())) + } + + pub fn reuse_for_consumer( + manifest: &VfsManifest, + path: &VfsPath, + consumer_node: &NodeId, + ) -> ReuseDecision { + let Some(object) = manifest.objects.get(path) else { + return ReuseDecision::Unavailable; + }; + if &object.node == consumer_node { + ReuseDecision::SameNodeZeroCopy + } else { + ReuseDecision::NeedsTransfer { + from: object.node.clone(), + to: consumer_node.clone(), + } + } + } + + pub fn discard_unflushed(&mut self) { + self.pending.clear(); + } + + pub fn pending_len(&self) -> usize { + self.pending.len() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn path() -> VfsPath { + VfsPath::new("/vfs/artifacts/app").unwrap() + } + + #[test] + fn flush_publishes_manifest_without_large_byte_upload() { + let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a")); + overlay.write(path(), Digest::sha256("binary"), 6); + + let manifest = overlay.flush(); + + assert_eq!(manifest.epoch, 1); + assert!(!manifest.large_bytes_uploaded); + assert!(manifest.objects.contains_key(&path())); + } + + #[test] + fn downstream_task_can_read_after_flush_but_not_before() { + let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a")); + overlay.write(path(), Digest::sha256("binary"), 6); + + let empty = VfsManifest { + epoch: 0, + producer: TaskId::from("task"), + node: NodeId::from("node-a"), + objects: BTreeMap::new(), + large_bytes_uploaded: false, + }; + assert!(VfsOverlay::read_published(&empty, &path()).is_err()); + + let manifest = overlay.flush(); + assert!(VfsOverlay::read_published(&manifest, &path()).is_ok()); + } + + #[test] + fn sync_is_explicit_and_policy_driven() { + let overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a")); + + assert_eq!( + overlay.sync(SyncPolicy::MetadataOnly), + VfsSyncDecision::NoBytesMoved + ); + assert_eq!( + overlay.sync(SyncPolicy::ExplicitStore("s3://bucket/app".to_owned())), + VfsSyncDecision::MoveBytesToStore("s3://bucket/app".to_owned()) + ); + } + + #[test] + fn same_node_reuse_avoids_transfer() { + let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a")); + overlay.write(path(), Digest::sha256("binary"), 6); + let manifest = overlay.flush(); + + assert_eq!( + VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-a")), + ReuseDecision::SameNodeZeroCopy + ); + assert_eq!( + VfsOverlay::reuse_for_consumer(&manifest, &path(), &NodeId::from("node-b")), + ReuseDecision::NeedsTransfer { + from: NodeId::from("node-a"), + to: NodeId::from("node-b") + } + ); + } + + #[test] + fn unflushed_task_local_changes_can_be_discarded() { + let mut overlay = VfsOverlay::new(TaskId::from("task"), NodeId::from("node-a")); + overlay.write(path(), Digest::sha256("binary"), 6); + + overlay.discard_unflushed(); + + assert_eq!(overlay.pending_len(), 0); + } +} diff --git a/crates/disasmer-dap/Cargo.toml b/crates/disasmer-dap/Cargo.toml new file mode 100644 index 0000000..e0a23aa --- /dev/null +++ b/crates/disasmer-dap/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "disasmer-dap" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "disasmer-debug-dap" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +disasmer-core = { path = "../disasmer-core" } +disasmer-node = { path = "../disasmer-node" } +serde_json.workspace = true diff --git a/crates/disasmer-dap/src/main.rs b/crates/disasmer-dap/src/main.rs new file mode 100644 index 0000000..3f6ac32 --- /dev/null +++ b/crates/disasmer-dap/src/main.rs @@ -0,0 +1,2572 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::{self, BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{anyhow, Result}; +use disasmer_core::{DebugRuntimeState, Digest, ProcessId, TaskId}; +use disasmer_node::WasmtimeTaskRuntime; +use serde_json::{json, Value}; + +const MAIN_THREAD: i64 = 1; +const LINUX_THREAD: i64 = 2; +const WINDOWS_THREAD: i64 = 3; +const PACKAGE_THREAD: i64 = 4; + +#[derive(Clone)] +struct DapWriter { + seq: i64, +} + +impl DapWriter { + fn new() -> Self { + Self { seq: 1 } + } + + fn response(&mut self, request: &Value, success: bool, body: Value) -> io::Result<()> { + let command = request + .get("command") + .and_then(Value::as_str) + .unwrap_or(""); + let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0); + let seq = self.next_seq(); + self.write(json!({ + "seq": seq, + "type": "response", + "request_seq": request_seq, + "success": success, + "command": command, + "body": body, + })) + } + + fn error_response(&mut self, request: &Value, message: impl Into) -> io::Result<()> { + let command = request + .get("command") + .and_then(Value::as_str) + .unwrap_or(""); + let request_seq = request.get("seq").and_then(Value::as_i64).unwrap_or(0); + let seq = self.next_seq(); + self.write(json!({ + "seq": seq, + "type": "response", + "request_seq": request_seq, + "success": false, + "command": command, + "message": message.into(), + })) + } + + fn event(&mut self, event: &str, body: Value) -> io::Result<()> { + let seq = self.next_seq(); + self.write(json!({ + "seq": seq, + "type": "event", + "event": event, + "body": body, + })) + } + + fn output(&mut self, category: &str, output: impl Into) -> io::Result<()> { + self.event( + "output", + json!({ + "category": category, + "output": output.into(), + }), + ) + } + + fn next_seq(&mut self) -> i64 { + let seq = self.seq; + self.seq += 1; + seq + } + + fn write(&mut self, message: Value) -> io::Result<()> { + let payload = serde_json::to_vec(&message)?; + let mut out = io::stdout().lock(); + write!(out, "Content-Length: {}\r\n\r\n", payload.len())?; + out.write_all(&payload)?; + out.flush() + } +} + +#[derive(Clone)] +struct VirtualThread { + id: i64, + frame_id: i64, + locals_ref: i64, + wasm_locals_ref: i64, + args_ref: i64, + runtime_ref: i64, + output_ref: i64, + target_ref: i64, + vfs_ref: i64, + command_ref: i64, + task: TaskId, + name: String, + line: i64, + state: DebugRuntimeState, + freeze_supported: bool, + recent_output: Vec, + stdout_bytes: u64, + stderr_bytes: u64, + stdout_tail: String, + stderr_tail: String, + stdout_truncated: bool, + stderr_truncated: bool, +} + +#[derive(Clone)] +struct AdapterState { + process: ProcessId, + epoch: u64, + entry: String, + project: String, + source_path: String, + runtime_backend: RuntimeBackend, + operator_endpoint: String, + tenant: String, + project_id: String, + actor_user: String, + session_mode: DapSessionMode, + command_status: String, + last_task_failed: bool, + runtime_artifact_path: Option, + runtime_event_count: usize, + breakpoints: Vec, + threads: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum RuntimeBackend { + Simulated, + LocalServices, + LiveServices, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum DapSessionMode { + Launch, + Attach, +} + +impl RuntimeBackend { + fn description(&self) -> &'static str { + match self { + RuntimeBackend::Simulated => "simulated debug adapter state", + RuntimeBackend::LocalServices => "local services", + RuntimeBackend::LiveServices => "live services", + } + } +} + +impl Default for AdapterState { + fn default() -> Self { + let entry = "build".to_owned(); + let project = ".".to_owned(); + Self { + process: process_id(&project, &entry), + epoch: 0, + threads: launch_threads(&entry), + entry, + project, + source_path: "src/build.rs".to_owned(), + runtime_backend: RuntimeBackend::Simulated, + operator_endpoint: "disasmer.michelpaulissen.com:9443".to_owned(), + tenant: "tenant".to_owned(), + project_id: "project".to_owned(), + actor_user: "dap".to_owned(), + session_mode: DapSessionMode::Launch, + command_status: "waiting for launch".to_owned(), + last_task_failed: false, + runtime_artifact_path: None, + runtime_event_count: 0, + breakpoints: Vec::new(), + } + } +} + +impl AdapterState { + fn configure_launch( + &mut self, + project: String, + entry: String, + runtime_backend: RuntimeBackend, + operator_endpoint: String, + tenant: String, + project_id: String, + actor_user: String, + source_path: Option, + ) { + self.process = process_id(&project, &entry); + self.entry = entry; + self.source_path = source_path.unwrap_or_else(|| infer_source_path(&project)); + self.project = project; + self.runtime_backend = runtime_backend; + self.operator_endpoint = normalize_operator_endpoint(&operator_endpoint); + self.tenant = tenant; + self.project_id = project_id; + self.actor_user = actor_user; + self.session_mode = DapSessionMode::Launch; + self.command_status = "waiting for configurationDone".to_owned(); + self.last_task_failed = false; + self.runtime_artifact_path = None; + self.runtime_event_count = 0; + self.epoch = 0; + self.breakpoints.clear(); + self.threads = launch_threads(&self.entry); + } + + fn apply_runtime_record(&mut self, record: RuntimeLaunchRecord) { + if !record.placed_task_launched { + self.command_status = format!( + "coordinator-side virtual process started through {}; no capability task launched yet", + self.runtime_backend.description() + ); + self.last_task_failed = false; + self.runtime_artifact_path = None; + self.runtime_event_count = record.event_count; + if let Some(thread) = self.threads.get_mut(&MAIN_THREAD) { + thread.recent_output.push(format!( + "coordinator-side process started through {}", + self.runtime_backend.description() + )); + thread + .recent_output + .push("a Command-capability virtual task will need a capable node when the process reaches it".to_owned()); + } + let _ = write_view_state(self, &record); + return; + } + + let task_failed = record.status_code != Some(0); + self.command_status = format!( + "{} through {} with status {:?}", + if task_failed { "failed" } else { "completed" }, + self.runtime_backend.description(), + record.status_code + ); + self.last_task_failed = task_failed; + self.runtime_artifact_path = record.artifact_path.clone(); + self.runtime_event_count = record.event_count; + if let Some(thread) = self.threads.get_mut(&LINUX_THREAD) { + thread.stdout_bytes = record.stdout_bytes; + thread.stderr_bytes = record.stderr_bytes; + thread.stdout_tail = record.stdout_tail.clone(); + thread.stderr_tail = record.stderr_tail.clone(); + thread.stdout_truncated = record.stdout_truncated; + thread.stderr_truncated = record.stderr_truncated; + thread.recent_output.push(format!( + "attached node {} task with {} stdout bytes and {} stderr bytes", + if task_failed { "failed" } else { "completed" }, + record.stdout_bytes, + record.stderr_bytes + )); + thread.recent_output.push(format!( + "coordinator recorded {} task event(s)", + record.event_count + )); + if task_failed { + thread.state = DebugRuntimeState::Failed(format!( + "local services task exited with status {:?}", + record.status_code + )); + } + } + let _ = write_view_state(self, &record); + } +} + +#[derive(Clone, Debug)] +struct RuntimeLaunchRecord { + coordinator: String, + node: String, + node_report: Value, + task_events: Value, + placed_task_launched: bool, + status_code: Option, + stdout_bytes: u64, + stderr_bytes: u64, + stdout_tail: String, + stderr_tail: String, + stdout_truncated: bool, + stderr_truncated: bool, + artifact_path: Option, + event_count: usize, +} + +fn launch_threads(entry: &str) -> BTreeMap { + [ + thread(MAIN_THREAD, "main", &format!("{entry} virtual process"), 12), + thread(LINUX_THREAD, "compile-linux", "compile linux", 42), + thread(WINDOWS_THREAD, "compile-windows", "compile windows", 52), + thread(PACKAGE_THREAD, "package", "package artifacts", 64), + ] + .into_iter() + .map(|thread| (thread.id, thread)) + .collect() +} + +fn process_id(project: &str, entry: &str) -> ProcessId { + let digest = Digest::from_parts([ + b"dap-process:v1".as_slice(), + project.as_bytes(), + entry.as_bytes(), + ]); + let suffix = digest + .as_str() + .trim_start_matches("sha256:") + .chars() + .take(12) + .collect::(); + ProcessId::new(format!("vp-{suffix}")) +} + +fn thread(id: i64, task: &str, name: &str, line: i64) -> VirtualThread { + VirtualThread { + id, + frame_id: 1000 + id, + locals_ref: 5000 + id, + wasm_locals_ref: 9000 + id, + args_ref: 2000 + id, + runtime_ref: 3000 + id, + output_ref: 4000 + id, + target_ref: 6000 + id, + vfs_ref: 7000 + id, + command_ref: 8000 + id, + task: TaskId::from(task), + name: name.to_owned(), + line, + state: DebugRuntimeState::Running, + freeze_supported: true, + recent_output: vec![format!("{name}: waiting")], + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + } +} + +fn main() -> Result<()> { + let mut writer = DapWriter::new(); + let mut reader = BufReader::new(io::stdin()); + let mut state = AdapterState::default(); + + while let Some(request) = read_message(&mut reader)? { + let command = request + .get("command") + .and_then(Value::as_str) + .unwrap_or_default(); + match command { + "initialize" => writer.response(&request, true, initialize_capabilities())?, + "launch" | "attach" => { + let args = request + .get("arguments") + .cloned() + .unwrap_or_else(|| json!({})); + state.entry = args + .get("entry") + .and_then(Value::as_str) + .unwrap_or("build") + .to_owned(); + let project = args + .get("project") + .and_then(Value::as_str) + .unwrap_or(".") + .to_owned(); + let runtime_backend = match args.get("runtimeBackend").and_then(Value::as_str) { + Some("local-services") => RuntimeBackend::LocalServices, + Some("live-services") => RuntimeBackend::LiveServices, + _ => RuntimeBackend::Simulated, + }; + let operator_endpoint = args + .get("operatorEndpoint") + .and_then(Value::as_str) + .unwrap_or("disasmer.michelpaulissen.com:9443") + .to_owned(); + let tenant = args + .get("tenant") + .and_then(Value::as_str) + .unwrap_or("tenant") + .to_owned(); + let project_id = args + .get("projectId") + .or_else(|| args.get("project_id")) + .and_then(Value::as_str) + .unwrap_or("project") + .to_owned(); + let actor_user = args + .get("actorUser") + .or_else(|| args.get("actor_user")) + .and_then(Value::as_str) + .unwrap_or("dap") + .to_owned(); + let source_path = args + .get("sourcePath") + .or_else(|| args.get("source_path")) + .and_then(Value::as_str) + .map(str::to_owned); + state.configure_launch( + project, + state.entry.clone(), + runtime_backend, + operator_endpoint, + tenant, + project_id, + actor_user, + source_path, + ); + if command == "attach" { + state.session_mode = DapSessionMode::Attach; + state.command_status = + format!("attached to existing virtual process {}", state.process); + } + writer.response(&request, true, json!({}))?; + writer.event("initialized", json!({}))?; + } + "setBreakpoints" => { + state.breakpoints = request + .get("arguments") + .and_then(|value| value.get("breakpoints")) + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| item.get("line").and_then(Value::as_i64)) + .collect::>() + }) + .unwrap_or_default(); + let response = state + .breakpoints + .iter() + .enumerate() + .map(|(index, line)| { + json!({ + "id": index + 1, + "verified": true, + "line": line, + "message": "Mapped to Disasmer virtual source location", + }) + }) + .collect::>(); + writer.response(&request, true, json!({ "breakpoints": response }))?; + } + "setExceptionBreakpoints" => { + writer.response(&request, true, json!({ "breakpoints": [] }))?; + } + "configurationDone" => { + if state.session_mode == DapSessionMode::Attach { + state.command_status = + format!("attached to existing virtual process {}", state.process); + } else { + match state.runtime_backend { + RuntimeBackend::LocalServices => match run_local_services_runtime(&state) { + Ok(record) => { + state.apply_runtime_record(record); + } + Err(err) => { + writer.error_response( + &request, + format!("local services runtime launch failed: {err:#}"), + )?; + continue; + } + }, + RuntimeBackend::LiveServices => match run_live_services_runtime(&state) { + Ok(record) => { + state.apply_runtime_record(record); + } + Err(err) => { + writer.error_response( + &request, + format!("live services runtime launch failed: {err:#}"), + )?; + continue; + } + }, + RuntimeBackend::Simulated => { + state.command_status = + "running under simulated debug adapter state".to_owned(); + } + } + } + writer.response(&request, true, json!({}))?; + let session_message = if state.session_mode == DapSessionMode::Attach { + format!( + "Attached to Disasmer virtual process {} with {:?} runtime\n", + state.process, state.runtime_backend + ) + } else { + format!( + "Disasmer bundle refreshed for entry `{}`; starting virtual process {} with {:?} runtime\n", + state.entry, state.process, state.runtime_backend + ) + }; + writer.output("console", session_message)?; + if !state.breakpoints.is_empty() { + let stopped_thread = stopped_thread_for_breakpoint(&state); + match freeze_all(&mut state, stopped_thread, None) { + Ok(()) => { + writer.event( + "stopped", + json!({ + "reason": "breakpoint", + "description": "Disasmer Debug Epoch all-stop", + "threadId": stopped_thread, + "allThreadsStopped": true, + }), + )?; + } + Err(failure) => { + let message = failure.message(); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + } + } + } else { + writer.event("terminated", json!({}))?; + } + } + "threads" => { + let threads = state + .threads + .values() + .map(|thread| { + json!({ + "id": thread.id, + "name": format!("{}/{}", state.process, thread.name), + }) + }) + .collect::>(); + writer.response(&request, true, json!({ "threads": threads }))?; + } + "stackTrace" => { + let thread = request_thread(&request, &state); + let source_path = stack_source_path(&state); + writer.response( + &request, + true, + json!({ + "stackFrames": [{ + "id": thread.frame_id, + "name": format!("{}::run", thread.name), + "line": thread.line, + "column": 1, + "source": { + "name": source_name(&source_path), + "path": source_path, + "presentationHint": "normal" + } + }], + "totalFrames": 1 + }), + )?; + } + "source" => match source_response(&state, &request) { + Ok(body) => writer.response(&request, true, body)?, + Err(err) => writer.error_response(&request, err.to_string())?, + }, + "scopes" => { + let frame_id = request + .get("arguments") + .and_then(|value| value.get("frameId")) + .and_then(Value::as_i64) + .unwrap_or(1000 + LINUX_THREAD); + let thread = state + .threads + .values() + .find(|thread| thread.frame_id == frame_id) + .cloned() + .unwrap_or_else(|| state.threads[&LINUX_THREAD].clone()); + writer.response( + &request, + true, + json!({ + "scopes": [ + { + "name": "Source Locals", + "variablesReference": thread.locals_ref, + "expensive": false, + "presentationHint": "locals" + }, + { + "name": "Wasm Frame Locals", + "variablesReference": thread.wasm_locals_ref, + "expensive": false, + "presentationHint": "locals" + }, + { + "name": "Task Args and Handles", + "variablesReference": thread.args_ref, + "expensive": false, + "presentationHint": "arguments" + }, + { + "name": "Disasmer Runtime", + "variablesReference": thread.runtime_ref, + "expensive": false + }, + { + "name": "Recent Output", + "variablesReference": thread.output_ref, + "expensive": false + } + ] + }), + )?; + } + "variables" => { + let reference = request + .get("arguments") + .and_then(|value| value.get("variablesReference")) + .and_then(Value::as_i64) + .unwrap_or(0); + writer.response(&request, true, variables_response(&state, reference))?; + } + "evaluate" => { + let expression = request + .get("arguments") + .and_then(|value| value.get("expression")) + .and_then(Value::as_str) + .unwrap_or_default(); + let result = match expression { + "virtual_process_id" => state.process.to_string(), + "debug_epoch" => state.epoch.to_string(), + "command_status" => state.command_status.clone(), + _ => "not available".to_owned(), + }; + writer.response( + &request, + true, + json!({ "result": result, "variablesReference": 0 }), + )?; + } + "pause" => { + match freeze_all( + &mut state, + LINUX_THREAD, + simulated_freeze_failure_thread(&request), + ) { + Ok(()) => { + writer.response(&request, true, json!({}))?; + writer.event( + "stopped", + json!({ + "reason": "pause", + "description": "Disasmer Debug Epoch pause", + "threadId": LINUX_THREAD, + "allThreadsStopped": true, + }), + )?; + } + Err(failure) => { + let message = failure.message(); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + } + } + } + "continue" => { + let thread_id = request_thread_id(&request).unwrap_or(LINUX_THREAD); + let next_breakpoint = next_breakpoint_after(&state, thread_id); + for thread in state.threads.values_mut() { + if thread.state == DebugRuntimeState::Frozen { + thread.state = DebugRuntimeState::Running; + } + } + writer.response(&request, true, json!({ "allThreadsContinued": true }))?; + writer.event( + "continued", + json!({ + "threadId": thread_id, + "allThreadsContinued": true, + }), + )?; + if let Some((stopped_thread, line)) = next_breakpoint { + if state.runtime_backend == RuntimeBackend::LiveServices + && stopped_thread == LINUX_THREAD + && state.runtime_event_count == 0 + { + match launch_live_placed_task(&state) { + Ok(record) => state.apply_runtime_record(record), + Err(err) => { + let message = format!( + "Command-capability virtual task placement failed after coordinator-side process start: {err:#}" + ); + state.command_status = message.clone(); + state.last_task_failed = true; + if let Some(thread) = state.threads.get_mut(&LINUX_THREAD) { + thread.state = DebugRuntimeState::Failed(message.clone()); + thread.recent_output.push(message.clone()); + } + writer.output("stderr", format!("{message}\n"))?; + writer.event( + "stopped", + json!({ + "reason": "exception", + "description": "Disasmer capability task unavailable", + "threadId": LINUX_THREAD, + "allThreadsStopped": true, + }), + )?; + continue; + } + } + } + match freeze_all_at_line(&mut state, stopped_thread, line, None) { + Ok(()) => { + writer.event( + "stopped", + json!({ + "reason": "breakpoint", + "description": "Disasmer Debug Epoch all-stop", + "threadId": stopped_thread, + "allThreadsStopped": true, + }), + )?; + } + Err(failure) => { + let message = failure.message(); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + } + } + } + } + "next" | "stepIn" | "stepOut" => { + let thread_id = request_thread_id(&request).unwrap_or(LINUX_THREAD); + let description = match command { + "next" => "step over", + "stepIn" => "step in", + "stepOut" => "step out", + _ => "step", + }; + step_thread(&mut state, thread_id, description); + writer.response(&request, true, json!({}))?; + writer.event( + "stopped", + json!({ + "reason": "step", + "description": format!("Disasmer Debug Epoch {description}"), + "threadId": thread_id, + "allThreadsStopped": true, + }), + )?; + } + "restart" | "restartFrame" => { + let thread_id = request_thread_id(&request).unwrap_or(LINUX_THREAD); + if restart_requires_whole_process(&request) { + let message = format!( + "Incompatible source edit requires whole virtual-process restart for {}", + state.process + ); + writer.output("stderr", format!("{message}\n"))?; + writer.error_response(&request, message)?; + continue; + } + let restarting_failed_task = state + .threads + .get(&thread_id) + .is_some_and(|thread| matches!(thread.state, DebugRuntimeState::Failed(_))); + if let Some(thread) = state.threads.get_mut(&thread_id) { + thread.state = DebugRuntimeState::Running; + thread + .recent_output + .push("task restarted from VFS checkpoint".to_owned()); + } + if restarting_failed_task { + state.last_task_failed = false; + } + state.command_status = format!( + "{} task restarted from compatible VFS checkpoint", + if restarting_failed_task { + "failed" + } else { + "selected" + } + ); + writer.response(&request, true, json!({}))?; + writer.output( + "console", + format!( + "Restarted {} task from compatible VFS checkpoint on thread {thread_id}\n", + if restarting_failed_task { + "failed" + } else { + "selected" + } + ), + )?; + } + "disconnect" | "terminate" => { + writer.response(&request, true, json!({}))?; + writer.event("terminated", json!({}))?; + break; + } + _ => writer.error_response(&request, format!("unsupported DAP command: {command}"))?, + } + } + + Ok(()) +} + +fn request_thread<'a>(request: &Value, state: &'a AdapterState) -> &'a VirtualThread { + let thread_id = request_thread_id(request).unwrap_or(LINUX_THREAD); + state + .threads + .get(&thread_id) + .unwrap_or_else(|| &state.threads[&LINUX_THREAD]) +} + +fn request_thread_id(request: &Value) -> Option { + request + .get("arguments") + .and_then(|value| value.get("threadId")) + .and_then(Value::as_i64) +} + +fn restart_requires_whole_process(request: &Value) -> bool { + let Some(arguments) = request.get("arguments") else { + return false; + }; + + arguments + .get("requiresWholeProcessRestart") + .and_then(Value::as_bool) + .unwrap_or(false) + || [ + "compatibility", + "sourceCompatibility", + "sourceEditCompatibility", + "taskCompatibility", + ] + .iter() + .filter_map(|field| arguments.get(field).and_then(Value::as_str)) + .any(is_incompatible_restart) + || arguments + .get("sourceEdit") + .and_then(|value| value.get("compatibility")) + .and_then(Value::as_str) + .is_some_and(is_incompatible_restart) +} + +fn is_incompatible_restart(value: &str) -> bool { + value.eq_ignore_ascii_case("incompatible") + || value.eq_ignore_ascii_case("whole-process") + || value.eq_ignore_ascii_case("whole_process") +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct FreezeFailure { + thread_id: i64, + task: TaskId, +} + +impl FreezeFailure { + fn message(&self) -> String { + format!( + "debug all-stop failed: participant `{}` on thread {} could not freeze", + self.task, self.thread_id + ) + } +} + +fn simulated_freeze_failure_thread(request: &Value) -> Option { + request + .get("arguments") + .and_then(|arguments| arguments.get("simulateFreezeFailure")) + .and_then(Value::as_bool) + .unwrap_or(false) + .then_some(PACKAGE_THREAD) +} + +fn freeze_all( + state: &mut AdapterState, + stopped_thread: i64, + forced_failure_thread: Option, +) -> Result<(), FreezeFailure> { + let line = state + .breakpoints + .iter() + .copied() + .find(|line| thread_for_source_line(state, *line) == stopped_thread) + .or_else(|| state.breakpoints.first().copied()) + .unwrap_or_else(|| state.threads[&stopped_thread].line); + freeze_all_at_line(state, stopped_thread, line, forced_failure_thread) +} + +fn freeze_all_at_line( + state: &mut AdapterState, + stopped_thread: i64, + line: i64, + forced_failure_thread: Option, +) -> Result<(), FreezeFailure> { + if let Some(failure) = state.threads.values().find(|thread| { + thread.state == DebugRuntimeState::Running + && (!thread.freeze_supported || forced_failure_thread == Some(thread.id)) + }) { + return Err(FreezeFailure { + thread_id: failure.id, + task: failure.task.clone(), + }); + } + + state.epoch += 1; + for thread in state.threads.values_mut() { + if thread.state == DebugRuntimeState::Running { + thread.state = DebugRuntimeState::Frozen; + } + } + if let Some(thread) = state.threads.get_mut(&stopped_thread) { + thread.line = line; + thread + .recent_output + .push(format!("debug epoch {} all-stop", state.epoch)); + } + Ok(()) +} + +fn stopped_thread_for_breakpoint(state: &AdapterState) -> i64 { + state + .breakpoints + .first() + .map(|line| thread_for_source_line(state, *line)) + .unwrap_or(LINUX_THREAD) +} + +fn next_breakpoint_after(state: &AdapterState, thread_id: i64) -> Option<(i64, i64)> { + let current_line = state.threads.get(&thread_id)?.line; + state + .breakpoints + .iter() + .copied() + .filter(|line| *line > current_line) + .min() + .map(|line| (thread_for_source_line(state, line), line)) +} + +fn thread_for_source_line(state: &AdapterState, line: i64) -> i64 { + if let Some(function_name) = source_function_name_at_line(state, line) { + let lower = function_name.to_ascii_lowercase(); + if lower.contains("linux") { + return LINUX_THREAD; + } + if lower.contains("windows") { + return WINDOWS_THREAD; + } + if lower.contains("package") { + return PACKAGE_THREAD; + } + return MAIN_THREAD; + } + + state + .threads + .values() + .find(|thread| thread.line == line) + .map(|thread| thread.id) + .unwrap_or(MAIN_THREAD) +} + +fn source_function_name_at_line(state: &AdapterState, line: i64) -> Option { + let source = + fs::read_to_string(resolve_source_path(&state.project, &state.source_path)).ok()?; + let lines = source.lines().collect::>(); + let mut index = usize::try_from(line).ok()?.saturating_sub(1); + if index >= lines.len() { + index = lines.len().saturating_sub(1); + } + lines[..=index] + .iter() + .rev() + .find_map(|line| parse_rust_function_name(line)) +} + +fn parse_rust_function_name(line: &str) -> Option { + let start = line.find("fn ")? + 3; + let rest = &line[start..]; + let name = rest + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::(); + (!name.is_empty()).then_some(name) +} + +fn step_thread(state: &mut AdapterState, thread_id: i64, description: &str) { + state.epoch += 1; + let resolved_thread = if state.threads.contains_key(&thread_id) { + thread_id + } else { + LINUX_THREAD + }; + if let Some(thread) = state.threads.get_mut(&resolved_thread) { + thread.state = DebugRuntimeState::Frozen; + thread.line += 1; + thread + .recent_output + .push(format!("debug epoch {} {description}", state.epoch)); + } +} + +fn variables_response(state: &AdapterState, reference: i64) -> Value { + let Some(thread) = state.threads.values().find(|thread| { + thread.locals_ref == reference + || thread.wasm_locals_ref == reference + || thread.args_ref == reference + || thread.runtime_ref == reference + || thread.output_ref == reference + || thread.target_ref == reference + || thread.vfs_ref == reference + || thread.command_ref == reference + }) else { + return json!({ "variables": [] }); + }; + + if thread.locals_ref == reference { + return json!({ "variables": source_locals_variables(state, thread) }); + } + + if thread.wasm_locals_ref == reference { + return json!({ "variables": wasm_frame_local_variables(state, thread) }); + } + + if thread.args_ref == reference { + return json!({ + "variables": [ + { + "name": "target", + "value": format!("Target {{ task = \"{}\", env = \"{}\" }}", thread.task, task_environment(thread)), + "variablesReference": thread.target_ref + }, + { + "name": "task_arguments", + "value": task_arguments_value(thread), + "variablesReference": 0 + }, + { + "name": "return_value", + "value": return_value_for_thread(state, thread), + "variablesReference": 0 + }, + { + "name": "artifact", + "value": format!("Artifact {{ id = \"{}\" }}", artifact_handle_value(state, thread)), + "variablesReference": 0 + }, + { + "name": "source_snapshot", + "value": format!("SourceSnapshot {{ digest = \"source://local-checkout/{}\" }}", project_snapshot_suffix(&state.project)), + "variablesReference": 0 + }, + { + "name": "blob", + "value": format!("Blob {{ digest = \"{}\" }}", blob_digest(state, thread)), + "variablesReference": 0 + }, + { + "name": "vfs_mounts", + "value": "{ /vfs/artifacts, /vfs/sources, /vfs/blobs }", + "variablesReference": thread.vfs_ref + }, + { + "name": "unavailable-local-diagnostic", + "value": "source-level locals that cannot be inspected keep task args and handles visible", + "variablesReference": 0 + } + ] + }); + } + + if thread.target_ref == reference { + return json!({ + "variables": [ + { + "name": "task", + "value": thread.task.to_string(), + "variablesReference": 0 + }, + { + "name": "environment", + "value": task_environment(thread), + "variablesReference": 0 + }, + { + "name": "kind", + "value": if thread.id == MAIN_THREAD { "coordinator-side wasm entrypoint" } else { "capability task" }, + "variablesReference": 0 + }, + { + "name": "required_capability", + "value": if thread.id == MAIN_THREAD { "none" } else { "Command" }, + "variablesReference": 0 + } + ] + }); + } + + if thread.vfs_ref == reference { + return json!({ + "variables": [ + { + "name": "/vfs/artifacts", + "value": artifact_handle_value(state, thread), + "variablesReference": 0 + }, + { + "name": "/vfs/sources", + "value": format!("source://local-checkout/{}", project_snapshot_suffix(&state.project)), + "variablesReference": 0 + }, + { + "name": "/vfs/blobs", + "value": blob_digest(state, thread), + "variablesReference": 0 + }, + { + "name": "durability", + "value": "best-effort until explicitly exported or stored", + "variablesReference": 0 + } + ] + }); + } + + if thread.runtime_ref == reference { + return json!({ + "variables": [ + { + "name": "virtual_process_id", + "value": state.process.to_string(), + "variablesReference": 0 + }, + { + "name": "virtual_thread", + "value": thread.name, + "variablesReference": 0 + }, + { + "name": "debug_epoch", + "value": state.epoch, + "variablesReference": 0 + }, + { + "name": "state", + "value": format!("{:?}", thread.state), + "variablesReference": 0 + }, + { + "name": "runtime_backend", + "value": format!("{:?}", state.runtime_backend), + "variablesReference": 0 + }, + { + "name": "coordinator_task_events", + "value": state.runtime_event_count, + "variablesReference": 0 + }, + { + "name": "command_status", + "value": state.command_status, + "variablesReference": 0 + }, + { + "name": "command_spec", + "value": command_spec_summary(state, thread), + "variablesReference": thread.command_ref + }, + { + "name": "stdout_tail", + "value": output_tail_display(&thread.stdout_tail, thread.stdout_bytes, thread.stdout_truncated, "stdout"), + "variablesReference": 0 + }, + { + "name": "stderr_tail", + "value": output_tail_display(&thread.stderr_tail, thread.stderr_bytes, thread.stderr_truncated, "stderr"), + "variablesReference": 0 + } + ] + }); + } + + if thread.command_ref == reference { + return json!({ + "variables": [ + { + "name": "program", + "value": command_program(thread), + "variablesReference": 0 + }, + { + "name": "args", + "value": command_args_summary(state, thread), + "variablesReference": 0 + }, + { + "name": "required_capability", + "value": if thread.id == MAIN_THREAD { "none" } else { "Command" }, + "variablesReference": 0 + }, + { + "name": "status", + "value": state.command_status, + "variablesReference": 0 + } + ] + }); + } + + let mut variables = vec![ + json!({ + "name": "stdout_tail", + "value": output_tail_display(&thread.stdout_tail, thread.stdout_bytes, thread.stdout_truncated, "stdout"), + "variablesReference": 0 + }), + json!({ + "name": "stderr_tail", + "value": output_tail_display(&thread.stderr_tail, thread.stderr_bytes, thread.stderr_truncated, "stderr"), + "variablesReference": 0 + }), + ]; + variables.extend( + thread + .recent_output + .iter() + .enumerate() + .map(|(index, output)| { + json!({ + "name": format!("line_{index}"), + "value": output, + "variablesReference": 0 + }) + }), + ); + + json!({ "variables": variables }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SourceLocal { + name: String, + line: i64, + value: Option, +} + +fn source_locals_variables(state: &AdapterState, thread: &VirtualThread) -> Vec { + let (locals, diagnostic) = source_locals_for_thread(state, thread); + let mut variables = locals + .into_iter() + .map(|local| { + let has_value = local.value.is_some(); + let value = local.value.unwrap_or_else(|| { + format!( + "cannot be inspected: source-level local from line {} is known, but DWARF/runtime value snapshot is unavailable", + local.line + ) + }); + json!({ + "name": local.name, + "value": value, + "type": if has_value { "disasmer-source-local" } else { "unavailable-local" }, + "variablesReference": 0 + }) + }) + .collect::>(); + variables.push(json!({ + "name": "unavailable-local-diagnostic", + "value": diagnostic, + "type": "unavailable-local", + "variablesReference": 0 + })); + variables +} + +const TASK_ADD_ONE_WAT: &str = r#" +(module + (func (export "task_add_one") (param i32) (result i32) + local.get 0 + i32.const 1 + i32.add)) +"#; + +fn wasm_frame_local_variables(state: &AdapterState, thread: &VirtualThread) -> Vec { + let function_name = source_function_name_at_line(state, thread.line); + if function_name.as_deref() != Some("task_add_one") { + return vec![json!({ + "name": "wasm-local-diagnostic", + "value": "no Wasm frame-local snapshot is available at this source location", + "type": "unavailable-local", + "variablesReference": 0 + })]; + } + + let probe = WasmtimeTaskRuntime::new().and_then(|runtime| { + runtime.freeze_resume_i32_export_probe(TASK_ADD_ONE_WAT, "task_add_one", 41) + }); + match probe { + Ok(probe) => { + let mut variables = probe + .local_values + .into_iter() + .map(|(name, value)| { + json!({ + "name": name, + "value": value, + "type": "wasm-frame-local", + "variablesReference": 0 + }) + }) + .collect::>(); + variables.push(json!({ + "name": "wasm-local-diagnostic", + "value": "captured from Wasmtime guest-debug frame state in the product node runtime", + "type": "diagnostic", + "variablesReference": 0 + })); + variables + } + Err(err) => vec![json!({ + "name": "wasm-local-diagnostic", + "value": format!("cannot inspect Wasm frame locals: {err:#}"), + "type": "unavailable-local", + "variablesReference": 0 + })], + } +} + +fn source_locals_for_thread( + state: &AdapterState, + thread: &VirtualThread, +) -> (Vec, String) { + let source_path = resolve_source_path(&state.project, &state.source_path); + let Ok(source) = fs::read_to_string(&source_path) else { + return ( + Vec::new(), + format!( + "cannot be inspected: source file `{}` is unavailable; task args and handles remain visible", + source_path.display() + ), + ); + }; + let lines = source.lines().collect::>(); + if lines.is_empty() { + return ( + Vec::new(), + "cannot be inspected: source file is empty; task args and handles remain visible" + .to_owned(), + ); + } + let current = usize::try_from(thread.line) + .ok() + .and_then(|line| line.checked_sub(1)) + .unwrap_or(0) + .min(lines.len() - 1); + let function_start = lines[..=current] + .iter() + .rposition(|line| parse_rust_function_name(line).is_some()) + .unwrap_or(0); + let mut locals = Vec::new(); + let mut runtime_values = BTreeMap::new(); + let mut index = function_start; + while index <= current { + let Some(name) = parse_let_binding_name(lines[index]) else { + index += 1; + continue; + }; + + let line = (index + 1) as i64; + let mut statement = lines[index].to_owned(); + while !statement.contains(';') && index < current { + index += 1; + statement.push('\n'); + statement.push_str(lines[index]); + } + + let runtime_value = infer_disasmer_source_local_value(state, &statement, &runtime_values); + if let Some(value) = runtime_value.clone() { + runtime_values.insert(name.clone(), value); + } + locals.push(SourceLocal { + name, + line, + value: runtime_value.map(|value| value.display(state)), + }); + index += 1; + } + let diagnostic = if locals.is_empty() { + "cannot be inspected: no source-level `let` locals are in scope for this frame; task args and handles remain visible" + .to_owned() + } else if locals.iter().any(|local| local.value.is_some()) { + "selected source-level Disasmer API locals are populated from virtual-thread runtime state; non-Disasmer locals remain best-effort until DWARF value snapshots are available" + .to_owned() + } else { + "cannot be inspected: selected source-level local names are best-effort until DWARF/runtime value snapshots are available" + .to_owned() + }; + (locals, diagnostic) +} + +fn parse_let_binding_name(line: &str) -> Option { + let rest = line.trim_start().strip_prefix("let ")?; + let rest = rest + .trim_start() + .strip_prefix("mut ") + .unwrap_or(rest) + .trim_start(); + let name = rest + .chars() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::(); + (!name.is_empty()).then_some(name) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum SourceLocalRuntimeValue { + TaskHandle { + task: TaskId, + thread_id: i64, + env: String, + state: String, + }, + ThreadId(i64), + Artifact(String), +} + +impl SourceLocalRuntimeValue { + fn display(&self, _state: &AdapterState) -> String { + match self { + SourceLocalRuntimeValue::TaskHandle { + task, + thread_id, + env, + state, + } => format!( + "TaskHandle {{ task = \"{task}\", virtual_thread_id = {thread_id}, env = \"{env}\", state = {state} }}" + ), + SourceLocalRuntimeValue::ThreadId(thread_id) => thread_id.to_string(), + SourceLocalRuntimeValue::Artifact(artifact) => { + format!("Artifact {{ id = \"{artifact}\" }}") + } + } + } +} + +fn infer_disasmer_source_local_value( + state: &AdapterState, + statement: &str, + runtime_values: &BTreeMap, +) -> Option { + if let Some(task_function) = extract_call_argument(statement, "disasmer::spawn::task(") { + let task_name = extract_string_argument(statement, ".name("); + let spawned_thread = + thread_for_spawn_statement(state, &task_function, task_name.as_deref())?; + return Some(SourceLocalRuntimeValue::TaskHandle { + task: spawned_thread.task.clone(), + thread_id: spawned_thread.id, + env: extract_env_name(statement) + .unwrap_or_else(|| task_environment(spawned_thread).to_owned()), + state: format!("{:?}", spawned_thread.state), + }); + } + + if let Some(receiver) = receiver_before_method(statement, ".virtual_thread_id()") { + if let Some(SourceLocalRuntimeValue::TaskHandle { thread_id, .. }) = + runtime_values.get(&receiver) + { + return Some(SourceLocalRuntimeValue::ThreadId(*thread_id)); + } + } + + if let Some(receiver) = receiver_before_method(statement, ".join()") { + if let Some(SourceLocalRuntimeValue::TaskHandle { thread_id, .. }) = + runtime_values.get(&receiver) + { + if let Some(thread) = state.threads.get(thread_id) { + return Some(SourceLocalRuntimeValue::Artifact(artifact_handle_value( + state, thread, + ))); + } + } + } + + None +} + +fn extract_call_argument(statement: &str, marker: &str) -> Option { + let start = statement.find(marker)? + marker.len(); + let rest = &statement[start..]; + let end = rest.find(')')?; + let value = rest[..end].trim(); + (!value.is_empty()).then_some(value.to_owned()) +} + +fn extract_string_argument(statement: &str, marker: &str) -> Option { + let start = statement.find(marker)? + marker.len(); + let rest = &statement[start..]; + let quoted = rest.strip_prefix('"')?; + let end = quoted.find('"')?; + Some(quoted[..end].to_owned()) +} + +fn extract_env_name(statement: &str) -> Option { + if statement.contains("windows_env") || statement.contains("env!(\"windows\")") { + return Some("windows".to_owned()); + } + if statement.contains("linux_env") || statement.contains("env!(\"linux\")") { + return Some("linux".to_owned()); + } + None +} + +fn receiver_before_method(statement: &str, method: &str) -> Option { + let before = statement.split(method).next()?.trim_end(); + let receiver = before + .chars() + .rev() + .take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_') + .collect::() + .chars() + .rev() + .collect::(); + (!receiver.is_empty()).then_some(receiver) +} + +fn thread_for_spawn_statement<'a>( + state: &'a AdapterState, + task_function: &str, + task_name: Option<&str>, +) -> Option<&'a VirtualThread> { + if let Some(task_name) = task_name { + if let Some(thread) = state + .threads + .values() + .find(|thread| thread.name == task_name) + { + return Some(thread); + } + } + let normalized = task_function.replace('_', "-"); + state + .threads + .values() + .find(|thread| thread.task.as_str() == normalized) +} + +fn task_environment(thread: &VirtualThread) -> &'static str { + match thread.id { + WINDOWS_THREAD => "windows", + MAIN_THREAD => "coordinator", + _ => "linux", + } +} + +fn task_arguments_value(thread: &VirtualThread) -> String { + if thread.task.as_str() == "task_add_one" { + "[input: i32]".to_owned() + } else { + "[]".to_owned() + } +} + +fn return_value_for_thread(state: &AdapterState, thread: &VirtualThread) -> String { + match thread.id { + LINUX_THREAD | PACKAGE_THREAD => { + format!( + "Artifact {{ id = \"{}\" }}", + artifact_handle_value(state, thread) + ) + } + MAIN_THREAD => "not returned yet: coordinator-side workflow still running".to_owned(), + WINDOWS_THREAD => { + "not returned yet: Windows capability task waits for a capable node".to_owned() + } + _ => "not returned yet".to_owned(), + } +} + +fn artifact_handle_value(state: &AdapterState, thread: &VirtualThread) -> String { + if thread.id == LINUX_THREAD { + return state.runtime_artifact_path.clone().unwrap_or_else(|| { + format!("artifact://{}/{}/app.tar.zst", state.process, thread.task) + }); + } + if thread.id == PACKAGE_THREAD { + return "artifact://package/release.tar.zst".to_owned(); + } + format!("artifact://{}/{}/app.tar.zst", state.process, thread.task) +} + +fn blob_digest(state: &AdapterState, thread: &VirtualThread) -> String { + Digest::from_parts([ + b"blob:v1".as_slice(), + state.project.as_bytes(), + thread.task.as_str().as_bytes(), + ]) + .as_str() + .to_owned() +} + +fn command_spec_summary(state: &AdapterState, thread: &VirtualThread) -> String { + format!( + "{} {} ({})", + command_program(thread), + command_args_summary(state, thread), + state.command_status + ) +} + +fn command_program(thread: &VirtualThread) -> &'static str { + if thread.id == MAIN_THREAD { + "coordinator-side wasm" + } else { + "cargo" + } +} + +fn command_args_summary(state: &AdapterState, thread: &VirtualThread) -> String { + if thread.id == MAIN_THREAD { + return format!("entry={}", state.entry); + } + format!("test --quiet --manifest-path {}/Cargo.toml", state.project) +} + +fn output_tail_display(tail: &str, bytes: u64, truncated: bool, stream: &str) -> String { + if tail.is_empty() { + return format!("no {stream} captured ({bytes} bytes)"); + } + if truncated { + format!("{tail}\n... truncated") + } else { + tail.to_owned() + } +} + +fn run_local_services_runtime(state: &AdapterState) -> Result { + let repo = std::env::current_dir()?; + let mut coordinator = Command::new("cargo") + .args([ + "run", + "-q", + "-p", + "disasmer-coordinator", + "--bin", + "disasmer-coordinator", + "--", + "--listen", + "127.0.0.1:0", + ]) + .current_dir(&repo) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let result = run_with_coordinator(state, &repo, &mut coordinator); + let _ = coordinator.kill(); + let _ = coordinator.wait(); + result +} + +fn run_live_services_runtime(state: &AdapterState) -> Result { + let coordinator = normalize_operator_endpoint(&state.operator_endpoint); + let started = coordinator_request( + &coordinator, + json!({ + "type": "start_process", + "tenant": state.tenant, + "project": state.project_id, + "process": state.process.as_str(), + }), + )?; + let epoch = started + .get("epoch") + .and_then(Value::as_u64) + .ok_or_else(|| anyhow!("coordinator did not report a process epoch"))?; + + Ok(RuntimeLaunchRecord { + coordinator, + node: "coordinator-side-process".to_owned(), + node_report: json!({ + "coordinator_process": started, + "coordinator_epoch": epoch, + "capability_task": "not_launched", + }), + task_events: json!({ "events": [] }), + placed_task_launched: false, + status_code: None, + stdout_bytes: 0, + stderr_bytes: 0, + stdout_tail: String::new(), + stderr_tail: String::new(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + event_count: 0, + }) +} + +fn launch_live_placed_task(state: &AdapterState) -> Result { + let coordinator = normalize_operator_endpoint(&state.operator_endpoint); + let launch = coordinator_request( + &coordinator, + json!({ + "type": "launch_task", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + "task": "compile-linux", + "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::new(&state.project) + .join("Cargo.toml") + .to_string_lossy() + .into_owned() + ], + "artifact_path": "/vfs/artifacts/dap-output.txt", + }), + ) + .map_err(|err| { + anyhow!( + "coordinator-side process could not place virtual task with Command capability: {err:#}. Attach a capable node, for example `disasmer-node --worker --coordinator {} --tenant {} --project-id {} --node `.", + coordinator, + state.tenant, + state.project_id, + ) + })?; + let node = launch + .get("placement") + .and_then(|placement| placement.get("node")) + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("task launch did not report a worker node placement"))? + .to_owned(); + let events = wait_for_placed_task_event(&coordinator, state, "compile-linux", &node)?; + let event_count = events + .get("events") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + let event = events + .get("events") + .and_then(Value::as_array) + .and_then(|items| { + items.iter().find(|event| { + event.get("task").and_then(Value::as_str) == Some("compile-linux") + && event.get("node").and_then(Value::as_str) == Some(node.as_str()) + }) + }) + .ok_or_else(|| anyhow!("worker node {node} did not record compile-linux completion"))?; + let status_code = event + .get("status_code") + .and_then(Value::as_i64) + .map(|value| value as i32); + let stdout_bytes = event + .get("stdout_bytes") + .and_then(Value::as_u64) + .unwrap_or(0); + let stderr_bytes = event + .get("stderr_bytes") + .and_then(Value::as_u64) + .unwrap_or(0); + let artifact_path = event + .get("artifact_path") + .and_then(Value::as_str) + .map(str::to_owned); + let stdout_tail = event + .get("stdout_tail") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let stderr_tail = event + .get("stderr_tail") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(); + let stdout_truncated = event + .get("stdout_truncated") + .and_then(Value::as_bool) + .unwrap_or(false); + let stderr_truncated = event + .get("stderr_truncated") + .and_then(Value::as_bool) + .unwrap_or(false); + + Ok(RuntimeLaunchRecord { + coordinator, + node, + node_report: json!({ + "task_launch": launch, + }), + task_events: events, + placed_task_launched: true, + status_code, + stdout_bytes, + stderr_bytes, + stdout_tail, + stderr_tail, + stdout_truncated, + stderr_truncated, + artifact_path, + event_count, + }) +} + +fn run_with_coordinator( + state: &AdapterState, + repo: &std::path::Path, + coordinator: &mut Child, +) -> Result { + let stdout = coordinator + .stdout + .take() + .ok_or_else(|| anyhow!("coordinator stdout was not captured"))?; + let mut ready_line = String::new(); + BufReader::new(stdout).read_line(&mut ready_line)?; + let ready: Value = serde_json::from_str(&ready_line)?; + let listen = ready + .get("listen") + .and_then(Value::as_str) + .ok_or_else(|| anyhow!("coordinator did not report a listen address"))?; + + run_node_against_coordinator( + state, + repo, + "cargo", + &[ + "run", + "-q", + "-p", + "disasmer-node", + "--bin", + "disasmer-node", + "--", + ], + listen, + "dap-node", + ) +} + +fn run_node_against_coordinator( + state: &AdapterState, + cwd: &Path, + command: &str, + prefix_args: &[&str], + coordinator: &str, + node: &str, +) -> Result { + let mut args = prefix_args + .iter() + .map(|arg| (*arg).to_owned()) + .collect::>(); + args.extend([ + "--coordinator".to_owned(), + coordinator.to_owned(), + "--tenant".to_owned(), + state.tenant.clone(), + "--project-id".to_owned(), + state.project_id.clone(), + "--node".to_owned(), + node.to_owned(), + "--process".to_owned(), + state.process.as_str().to_owned(), + "--task".to_owned(), + "compile-linux".to_owned(), + "--project".to_owned(), + state.project.clone(), + "--artifact".to_owned(), + "/vfs/artifacts/dap-output.txt".to_owned(), + ]); + + let node_output = Command::new(command) + .args(&args) + .current_dir(cwd) + .output()?; + if !node_output.status.success() { + return Err(anyhow!( + "node runtime failed: {}", + String::from_utf8_lossy(&node_output.stderr) + )); + } + + let stdout = String::from_utf8_lossy(&node_output.stdout); + let report_line = stdout + .lines() + .last() + .ok_or_else(|| anyhow!("node runtime did not emit JSON"))?; + let report: Value = serde_json::from_str(report_line)?; + let events = coordinator_request( + coordinator, + json!({ + "type": "list_task_events", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + }), + )?; + let event_count = events + .get("events") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0); + + Ok(RuntimeLaunchRecord { + coordinator: coordinator.to_owned(), + node: node.to_owned(), + node_report: report.clone(), + task_events: events, + placed_task_launched: true, + status_code: report + .get("status_code") + .and_then(Value::as_i64) + .map(|value| value as i32), + stdout_bytes: report + .get("stdout_bytes") + .and_then(Value::as_u64) + .unwrap_or(0), + stderr_bytes: report + .get("stderr_bytes") + .and_then(Value::as_u64) + .unwrap_or(0), + stdout_tail: report + .get("stdout_tail") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + stderr_tail: report + .get("stderr_tail") + .and_then(Value::as_str) + .unwrap_or_default() + .to_owned(), + stdout_truncated: report + .get("stdout_truncated") + .and_then(Value::as_bool) + .unwrap_or(false), + stderr_truncated: report + .get("stderr_truncated") + .and_then(Value::as_bool) + .unwrap_or(false), + artifact_path: report + .get("staged_artifact") + .and_then(|artifact| artifact.get("path")) + .and_then(Value::as_str) + .map(str::to_owned), + event_count, + }) +} + +fn coordinator_request(addr: &str, request: Value) -> Result { + let mut stream = TcpStream::connect(addr)?; + serde_json::to_writer(&mut stream, &request)?; + stream.write_all(b"\n")?; + stream.flush()?; + + let mut line = String::new(); + BufReader::new(stream).read_line(&mut line)?; + let response: Value = serde_json::from_str(&line)?; + if response.get("type").and_then(Value::as_str) == Some("error") { + return Err(anyhow!( + "{}", + response + .get("message") + .and_then(Value::as_str) + .unwrap_or("coordinator returned an error") + )); + } + Ok(response) +} + +fn wait_for_placed_task_event( + coordinator: &str, + state: &AdapterState, + task: &str, + node: &str, +) -> Result { + let deadline = Instant::now() + Duration::from_secs(120); + loop { + let events = coordinator_request( + coordinator, + json!({ + "type": "list_task_events", + "tenant": state.tenant, + "project": state.project_id, + "actor_user": state.actor_user, + "process": state.process.as_str(), + }), + )?; + let completed = events + .get("events") + .and_then(Value::as_array) + .is_some_and(|items| { + items.iter().any(|event| { + event.get("task").and_then(Value::as_str) == Some(task) + && event.get("node").and_then(Value::as_str) == Some(node) + }) + }); + if completed { + return Ok(events); + } + if Instant::now() >= deadline { + return Err(anyhow!( + "node {node} accepted virtual task `{task}` but did not report completion; make sure the explicit `disasmer-node --worker` process is still running" + )); + } + std::thread::sleep(Duration::from_millis(250)); + } +} + +fn project_snapshot_suffix(project: &str) -> String { + Digest::from_parts([b"source-snapshot:v1".as_slice(), project.as_bytes()]) + .as_str() + .trim_start_matches("sha256:") + .chars() + .take(12) + .collect() +} + +fn infer_source_path(project: &str) -> String { + if Path::new(project).join("src/build.rs").is_file() { + "src/build.rs".to_owned() + } else { + "src/main.rs".to_owned() + } +} + +fn source_name(source_path: &str) -> &str { + Path::new(source_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(source_path) +} + +fn stack_source_path(state: &AdapterState) -> String { + let resolved = resolve_source_path(&state.project, &state.source_path); + let absolute = if resolved.is_absolute() { + resolved + } else { + std::env::current_dir() + .unwrap_or_else(|_| Path::new(".").to_path_buf()) + .join(resolved) + }; + absolute + .canonicalize() + .unwrap_or(absolute) + .to_string_lossy() + .into_owned() +} + +fn source_response(state: &AdapterState, request: &Value) -> Result { + let source_path = request + .get("arguments") + .and_then(|arguments| arguments.get("source")) + .and_then(|source| source.get("path")) + .and_then(Value::as_str) + .unwrap_or(&state.source_path); + let content = fs::read_to_string(resolve_source_path(&state.project, source_path))?; + Ok(json!({ + "content": content, + "mimeType": "text/x-rustsrc", + })) +} + +fn resolve_source_path(project: &str, source_path: &str) -> std::path::PathBuf { + let path = Path::new(source_path); + if path.is_absolute() { + path.to_path_buf() + } else { + Path::new(project).join(path) + } +} + +fn normalize_operator_endpoint(endpoint: &str) -> String { + let trimmed = endpoint.trim(); + let without_scheme = trimmed + .strip_prefix("https://") + .or_else(|| trimmed.strip_prefix("http://")) + .unwrap_or(trimmed); + without_scheme + .split('/') + .next() + .unwrap_or(without_scheme) + .to_owned() +} + +fn write_view_state(state: &AdapterState, record: &RuntimeLaunchRecord) -> Result<()> { + let dir = Path::new(&state.project).join(".disasmer"); + fs::create_dir_all(&dir)?; + let node_status = if record.placed_task_launched { + if record.status_code == Some(0) { + "completed" + } else { + "failed" + } + } else { + "not-required-yet" + }; + let process_status = if record.placed_task_launched { + if record.status_code == Some(0) { + "completed" + } else { + "failed" + } + } else { + "running" + }; + let log_message = if record.placed_task_launched { + "node task output was staged as a VFS artifact" + } else { + "coordinator-side virtual process started; capability task not launched" + }; + let artifact_status = if record.placed_task_launched { + "best-effort retained on the attached node" + } else { + "not produced until a capability task is placed" + }; + let view_state = json!({ + "nodes": [{ + "id": record.node, + "status": node_status, + "capabilities": format!("connected to {}", record.coordinator), + }], + "processes": [{ + "id": state.process.as_str(), + "status": process_status, + "entry": state.entry, + }], + "logs": [{ + "task": "compile-linux", + "message": log_message, + "bytes": format!("stdout={} stderr={}", record.stdout_bytes, record.stderr_bytes), + }], + "artifacts": [{ + "path": record.artifact_path.clone().unwrap_or_else(|| "/vfs/artifacts/dap-output.txt".to_owned()), + "status": artifact_status, + "size": "reported by node", + }], + "inspector": [ + { + "label": "Runtime backend", + "value": state.runtime_backend.description(), + }, + { + "label": "Coordinator", + "value": record.coordinator, + }, + { + "label": "Node report", + "value": record.node_report.to_string(), + }, + { + "label": "Task events", + "value": record.task_events.to_string(), + } + ] + }); + fs::write( + dir.join("views.json"), + serde_json::to_string_pretty(&view_state)?, + )?; + Ok(()) +} + +fn initialize_capabilities() -> Value { + json!({ + "supportsConfigurationDoneRequest": true, + "supportsTerminateRequest": true, + "supportsRestartRequest": true, + "supportsRestartFrame": true, + "supportsEvaluateForHovers": true, + "supportsStepBack": false, + }) +} + +fn read_message(reader: &mut R) -> Result> { + let mut content_length = None; + + loop { + let mut line = String::new(); + let bytes = reader.read_line(&mut line)?; + if bytes == 0 { + return Ok(None); + } + + let line = line.trim_end_matches(['\r', '\n']); + if line.is_empty() { + break; + } + + if let Some(value) = line.strip_prefix("Content-Length:") { + content_length = Some(value.trim().parse::()?); + } + } + + let length = content_length.ok_or_else(|| anyhow!("DAP message missing Content-Length"))?; + let mut payload = vec![0; length]; + reader.read_exact(&mut payload)?; + Ok(Some(serde_json::from_slice(&payload)?)) +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use serde_json::json; + + use super::*; + + #[test] + fn reads_standard_dap_content_length_frame_from_generic_client() { + let payload = json!({ + "seq": 7, + "type": "request", + "command": "threads" + }) + .to_string(); + let frame = format!("Content-Length: {}\r\n\r\n{payload}", payload.len()); + let mut reader = Cursor::new(frame.into_bytes()); + + let message = read_message(&mut reader) + .unwrap() + .expect("DAP frame should contain a request"); + + assert_eq!(message["seq"], 7); + assert_eq!(message["type"], "request"); + assert_eq!(message["command"], "threads"); + } + + #[test] + fn initialize_capabilities_use_standard_dap_flags() { + let capabilities = initialize_capabilities(); + let capabilities = capabilities + .as_object() + .expect("initialize capabilities should be an object"); + + assert_eq!( + capabilities.get("supportsConfigurationDoneRequest"), + Some(&json!(true)) + ); + assert_eq!(capabilities.get("supportsRestartFrame"), Some(&json!(true))); + assert_eq!(capabilities.get("supportsStepBack"), Some(&json!(false))); + assert!(capabilities + .keys() + .all(|key| key.starts_with("supports") && !key.contains("disasmer"))); + } + + #[test] + fn compatible_restart_frame_does_not_require_whole_process_restart() { + let request = json!({ + "command": "restartFrame", + "arguments": { + "frameId": 1, + "sourceEdit": { + "compatibility": "compatible" + } + } + }); + + assert!(!restart_requires_whole_process(&request)); + } + + #[test] + fn incompatible_source_edit_requires_whole_process_restart() { + for arguments in [ + json!({ "sourceCompatibility": "incompatible" }), + json!({ "sourceEdit": { "compatibility": "whole-process" } }), + json!({ "requiresWholeProcessRestart": true }), + ] { + let request = json!({ + "command": "restartFrame", + "arguments": arguments, + }); + + assert!(restart_requires_whole_process(&request)); + } + } + + #[test] + fn nonzero_local_runtime_record_marks_linux_task_failed() { + let mut state = AdapterState::default(); + state.runtime_backend = RuntimeBackend::LocalServices; + + state.apply_runtime_record(RuntimeLaunchRecord { + coordinator: "127.0.0.1:7999".to_owned(), + node: "node".to_owned(), + node_report: json!({}), + task_events: json!({ "events": [] }), + placed_task_launched: true, + status_code: Some(1), + stdout_bytes: 0, + stderr_bytes: 12, + stdout_tail: String::new(), + stderr_tail: "failed".to_owned(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: None, + event_count: 1, + }); + + let linux = state + .threads + .get(&LINUX_THREAD) + .expect("linux task should exist"); + + assert!(state.last_task_failed); + assert!(state + .command_status + .contains("failed through local services")); + assert!(matches!(linux.state, DebugRuntimeState::Failed(_))); + assert!(linux + .recent_output + .iter() + .any(|line| line.contains("attached node failed task"))); + } + + #[test] + fn freeze_all_reports_failure_without_claiming_all_stop() { + let mut state = AdapterState::default(); + state + .threads + .get_mut(&PACKAGE_THREAD) + .expect("package thread should exist") + .freeze_supported = false; + + let error = freeze_all(&mut state, LINUX_THREAD, None).unwrap_err(); + + assert_eq!(error.thread_id, PACKAGE_THREAD); + assert_eq!(error.task, TaskId::from("package")); + assert!(error.message().contains("could not freeze")); + assert_eq!(state.epoch, 0); + assert!(state + .threads + .values() + .all(|thread| thread.state == DebugRuntimeState::Running)); + } + + #[test] + fn freeze_all_success_freezes_every_running_participant() { + let mut state = AdapterState::default(); + + freeze_all(&mut state, LINUX_THREAD, None).unwrap(); + + assert_eq!(state.epoch, 1); + assert!(state + .threads + .values() + .all(|thread| thread.state == DebugRuntimeState::Frozen)); + } + + #[test] + fn breakpoint_line_selects_main_or_spawned_virtual_thread() { + let mut state = AdapterState::default(); + + state.breakpoints = vec![12]; + assert_eq!(stopped_thread_for_breakpoint(&state), MAIN_THREAD); + + state.breakpoints = vec![42]; + assert_eq!(stopped_thread_for_breakpoint(&state), LINUX_THREAD); + } + + #[test] + fn launch_threads_include_windows_task_in_same_virtual_process() { + let state = AdapterState::default(); + let windows = state + .threads + .get(&WINDOWS_THREAD) + .expect("windows task should be debugger-visible"); + + assert_eq!(state.threads.len(), 4); + assert_eq!(windows.id, WINDOWS_THREAD); + assert_eq!(windows.task, TaskId::from("compile-windows")); + assert_eq!(windows.name, "compile windows"); + assert_eq!(windows.line, 52); + assert_eq!(process_id(&state.project, &state.entry), state.process); + } + + #[test] + fn windows_thread_runtime_variables_share_virtual_process() { + let state = AdapterState::default(); + let windows = state + .threads + .get(&WINDOWS_THREAD) + .expect("windows task should be debugger-visible"); + + let variables = variables_response(&state, windows.runtime_ref); + let variables = variables["variables"] + .as_array() + .expect("variables response should be an array"); + + assert!(variables.iter().any(|variable| { + variable["name"] == "virtual_process_id" + && variable["value"] == state.process.to_string() + })); + assert!(variables.iter().any(|variable| { + variable["name"] == "virtual_thread" && variable["value"] == "compile windows" + })); + } + + #[test] + fn variables_expose_mvp_runtime_handles_and_output_tails() { + let mut state = AdapterState::default(); + state.runtime_backend = RuntimeBackend::LocalServices; + state.apply_runtime_record(RuntimeLaunchRecord { + coordinator: "127.0.0.1:7999".to_owned(), + node: "node".to_owned(), + node_report: json!({}), + task_events: json!({ "events": [] }), + placed_task_launched: true, + status_code: Some(0), + stdout_bytes: 11, + stderr_bytes: 7, + stdout_tail: "hello tail\n".to_owned(), + stderr_tail: "warn\n".to_owned(), + stdout_truncated: false, + stderr_truncated: false, + artifact_path: Some("/vfs/artifacts/dap-output.txt".to_owned()), + event_count: 1, + }); + let linux = state + .threads + .get(&LINUX_THREAD) + .expect("linux task should exist"); + + let args = variables_response(&state, linux.args_ref); + let args = args["variables"] + .as_array() + .expect("variables response should be an array"); + assert!(args.iter().any(|variable| { + variable["name"] == "return_value" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("Artifact")) + })); + assert!(args.iter().any(|variable| variable["name"] == "blob")); + let target_ref = args + .iter() + .find(|variable| variable["name"] == "target") + .and_then(|variable| variable["variablesReference"].as_i64()) + .expect("target should have child variables"); + let vfs_ref = args + .iter() + .find(|variable| variable["name"] == "vfs_mounts") + .and_then(|variable| variable["variablesReference"].as_i64()) + .expect("vfs mounts should have child variables"); + + let target = variables_response(&state, target_ref); + assert!(target["variables"] + .as_array() + .unwrap() + .iter() + .any(|variable| variable["name"] == "environment" && variable["value"] == "linux")); + + let vfs = variables_response(&state, vfs_ref); + assert!(vfs["variables"] + .as_array() + .unwrap() + .iter() + .any(|variable| variable["name"] == "/vfs/artifacts")); + + let runtime = variables_response(&state, linux.runtime_ref); + let runtime = runtime["variables"].as_array().unwrap(); + let command_ref = runtime + .iter() + .find(|variable| variable["name"] == "command_spec") + .and_then(|variable| variable["variablesReference"].as_i64()) + .expect("command spec should have child variables"); + assert!(runtime.iter().any( + |variable| variable["name"] == "stdout_tail" && variable["value"] == "hello tail\n" + )); + assert!(runtime + .iter() + .any(|variable| variable["name"] == "stderr_tail" && variable["value"] == "warn\n")); + + let command = variables_response(&state, command_ref); + assert!(command["variables"] + .as_array() + .unwrap() + .iter() + .any(|variable| variable["name"] == "required_capability" + && variable["value"] == "Command")); + } + + #[test] + fn source_locals_infer_disasmer_api_values_from_runtime_state() { + let mut state = AdapterState::default(); + state.project = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/launch-build-demo") + .to_string_lossy() + .into_owned(); + state.source_path = "src/build.rs".to_owned(); + state.threads.get_mut(&MAIN_THREAD).unwrap().line = 60; + let thread = state.threads[&MAIN_THREAD].clone(); + + let locals = variables_response(&state, thread.locals_ref); + let locals = locals["variables"].as_array().unwrap(); + + assert!(locals.iter().any(|variable| { + variable["name"] == "linux" + && variable["value"].as_str().is_some_and(|value| { + value.contains("TaskHandle") + && value.contains("compile-linux") + && value.contains("virtual_thread_id = 2") + }) + })); + assert!(locals + .iter() + .any(|variable| { variable["name"] == "linux_thread" && variable["value"] == "2" })); + assert!(locals.iter().any(|variable| { + variable["name"] == "linux_artifact" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("Artifact")) + })); + assert!(locals.iter().any(|variable| { + variable["name"] == "unavailable-local-diagnostic" + && variable["type"] == "unavailable-local" + })); + } + + #[test] + fn wasm_frame_locals_expose_wasmtime_runtime_values() { + let mut state = AdapterState::default(); + state.project = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../examples/launch-build-demo") + .to_string_lossy() + .into_owned(); + state.source_path = "src/build.rs".to_owned(); + state.threads.get_mut(&MAIN_THREAD).unwrap().line = 31; + let thread = state.threads[&MAIN_THREAD].clone(); + + let locals = variables_response(&state, thread.wasm_locals_ref); + let locals = locals["variables"].as_array().unwrap(); + + assert!(locals.iter().any(|variable| { + variable["name"] == "wasm_local_0" + && variable["type"] == "wasm-frame-local" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("41")) + })); + assert!(locals.iter().any(|variable| { + variable["name"] == "wasm-local-diagnostic" + && variable["value"] + .as_str() + .is_some_and(|value| value.contains("product node runtime")) + })); + } +} diff --git a/crates/disasmer-macros/Cargo.toml b/crates/disasmer-macros/Cargo.toml new file mode 100644 index 0000000..ec210f3 --- /dev/null +++ b/crates/disasmer-macros/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "disasmer-macros" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true diff --git a/crates/disasmer-macros/src/lib.rs b/crates/disasmer-macros/src/lib.rs new file mode 100644 index 0000000..33289cf --- /dev/null +++ b/crates/disasmer-macros/src/lib.rs @@ -0,0 +1,77 @@ +use proc_macro::TokenStream; +use quote::{format_ident, quote}; +use syn::{parse::Parser, parse_macro_input, Expr, ItemFn, Lit, Meta, Token}; + +#[proc_macro_attribute] +pub fn main(attr: TokenStream, item: TokenStream) -> TokenStream { + let function = parse_macro_input!(item as ItemFn); + let function_name = function.sig.ident.to_string(); + let entrypoint_name = descriptor_name( + attr, + function_name + .strip_suffix("_main") + .unwrap_or(&function_name), + ); + let descriptor = format_ident!( + "__DISASMER_ENTRYPOINT_{}", + function_name.to_ascii_uppercase() + ); + + quote! { + #function + + #[doc(hidden)] + pub const #descriptor: ::disasmer::EntrypointDescriptor = ::disasmer::EntrypointDescriptor { + name: #entrypoint_name, + function: #function_name, + }; + } + .into() +} + +#[proc_macro_attribute] +pub fn task(attr: TokenStream, item: TokenStream) -> TokenStream { + let function = parse_macro_input!(item as ItemFn); + let function_name = function.sig.ident.to_string(); + let task_name = descriptor_name(attr, &function_name); + let descriptor = format_ident!("__DISASMER_TASK_{}", function_name.to_ascii_uppercase()); + + quote! { + #function + + #[doc(hidden)] + pub const #descriptor: ::disasmer::TaskDescriptor = ::disasmer::TaskDescriptor { + name: #task_name, + function: #function_name, + remotely_startable: true, + }; + } + .into() +} + +fn descriptor_name(attr: TokenStream, default: &str) -> String { + if attr.is_empty() { + return default.to_owned(); + } + + let parser = syn::punctuated::Punctuated::::parse_terminated; + let Ok(args) = parser.parse(attr) else { + return default.to_owned(); + }; + + for meta in args { + let Meta::NameValue(name_value) = meta else { + continue; + }; + if !name_value.path.is_ident("name") { + continue; + } + if let Expr::Lit(expr) = name_value.value { + if let Lit::Str(name) = expr.lit { + return name.value(); + } + } + } + + default.to_owned() +} diff --git a/crates/disasmer-node/Cargo.toml b/crates/disasmer-node/Cargo.toml new file mode 100644 index 0000000..c30e411 --- /dev/null +++ b/crates/disasmer-node/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "disasmer-node" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[dependencies] +disasmer-core = { path = "../disasmer-core" } +quinn.workspace = true +rcgen.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +wasmtime.workspace = true diff --git a/crates/disasmer-node/src/bin/disasmer-podman-smoke.rs b/crates/disasmer-node/src/bin/disasmer-podman-smoke.rs new file mode 100644 index 0000000..4a2676d --- /dev/null +++ b/crates/disasmer-node/src/bin/disasmer-podman-smoke.rs @@ -0,0 +1,84 @@ +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use disasmer_core::{ + CommandInvocation, Digest, EnvironmentKind, EnvironmentRequirements, EnvironmentResource, + NodeId, ProcessId, TaskId, VfsOverlay, VfsPath, +}; +use disasmer_node::{LinuxRootlessPodmanBackend, LocalSourceCheckout, StdProcessRunner}; +use serde_json::json; + +fn main() -> Result<(), Box> { + let workspace = create_workspace()?; + let env_dir = workspace.join("envs/linux"); + fs::create_dir_all(&env_dir)?; + fs::write( + env_dir.join("Containerfile"), + "FROM docker.io/library/alpine:3.20\nWORKDIR /workspace\n", + )?; + fs::write(workspace.join("input.txt"), "node-local source\n")?; + + let env = EnvironmentResource { + name: "linux".to_owned(), + kind: EnvironmentKind::Containerfile, + recipe_path: env_dir.join("Containerfile"), + context_path: env_dir, + digest: Digest::sha256("phase2-podman-smoke-linux-env"), + requirements: EnvironmentRequirements::linux_container(), + }; + let invocation = CommandInvocation { + program: "sh".to_owned(), + args: vec![ + "-c".to_owned(), + "printf 'podman-ok:' && cat input.txt".to_owned(), + ], + env: Some(env), + }; + let checkout = LocalSourceCheckout { + host_path: workspace.clone(), + snapshot: Digest::sha256("phase2-podman-smoke-checkout"), + }; + let task = TaskId::from("podman-smoke"); + let mut overlay = VfsOverlay::new(task.clone(), NodeId::from("node-podman-smoke")); + let mut runner = StdProcessRunner; + let output = LinuxRootlessPodmanBackend.execute_local_checkout_task( + ProcessId::from("vp-podman-smoke"), + task, + &invocation, + checkout, + Some(VfsPath::new("/vfs/artifacts/podman-smoke.txt")?), + &mut runner, + &mut overlay, + )?; + let manifest = overlay.flush(); + + println!( + "{}", + serde_json::to_string(&json!({ + "podman_status": "completed", + "status_code": output.status_code, + "stdout": output.stdout, + "stderr": output.stderr, + "staged_artifact": output.staged_artifact, + "large_bytes_uploaded": manifest.large_bytes_uploaded, + "uses_full_repo_tarball": false, + "coordinator_routed_file_reads": false, + }))? + ); + let _ = fs::remove_dir_all(workspace); + Ok(()) +} + +fn create_workspace() -> Result { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + let workspace = std::env::temp_dir().join(format!( + "disasmer-podman-smoke-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(&workspace)?; + Ok(workspace) +} diff --git a/crates/disasmer-node/src/bin/disasmer-quic-smoke.rs b/crates/disasmer-node/src/bin/disasmer-quic-smoke.rs new file mode 100644 index 0000000..1a9f4b7 --- /dev/null +++ b/crates/disasmer-node/src/bin/disasmer-quic-smoke.rs @@ -0,0 +1,138 @@ +use std::{net::SocketAddr, sync::Arc}; + +use disasmer_core::{ + ArtifactId, DataPlaneObject, DataPlaneScope, Digest, NativeQuicTransport, NodeEndpoint, NodeId, + ProcessId, ProjectId, RendezvousRequest, TenantId, Transport, +}; +use quinn::rustls::{ + pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer}, + RootCertStore, +}; +use quinn::{ClientConfig, Endpoint, ServerConfig}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +struct QuicTransferRequest { + scope: DataPlaneScope, + authorization_digest: Digest, + requested_bytes: u64, +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let payload = b"artifact-bytes-over-rust-native-quic".to_vec(); + let (cert, key) = self_signed_localhost_cert()?; + let server_endpoint = Endpoint::server( + ServerConfig::with_single_cert(vec![cert.clone()], key)?, + "127.0.0.1:0".parse()?, + )?; + let server_addr = server_endpoint.local_addr()?; + + let scope = DataPlaneScope { + tenant: TenantId::from("tenant"), + project: ProjectId::from("project"), + process: ProcessId::from("vp-quic"), + object: DataPlaneObject::Artifact(ArtifactId::from("quic-artifact")), + authorization_subject: "node-a-to-node-b".to_owned(), + }; + let transport = NativeQuicTransport; + let plan = transport.plan_authenticated_direct_bulk_transfer( + RendezvousRequest { + scope: scope.clone(), + source: endpoint("node-a", server_addr), + destination: endpoint("node-b", "127.0.0.1:0".parse()?), + }, + true, + "", + )?; + + let expected_scope = plan.scope.clone(); + let expected_digest = plan.authorization_digest.clone(); + let expected_payload = payload.clone(); + let server = tokio::spawn(async move { + let incoming = server_endpoint + .accept() + .await + .ok_or("server endpoint closed before accepting a QUIC connection")?; + let connection = incoming.await?; + let (mut send, mut recv) = connection.accept_bi().await?; + let request_bytes = recv.read_to_end(64 * 1024).await?; + let request: QuicTransferRequest = serde_json::from_slice(&request_bytes)?; + if request.scope != expected_scope { + return Err("QUIC request scope did not match the authorized data-plane scope".into()); + } + if request.authorization_digest != expected_digest { + return Err( + "QUIC request authorization digest did not match the rendezvous plan".into(), + ); + } + send.write_all(&expected_payload).await?; + send.finish()?; + server_endpoint.wait_idle().await; + Ok::>(request_bytes.len()) + }); + + let mut roots = RootCertStore::empty(); + roots.add(cert)?; + let client_config = ClientConfig::with_root_certificates(Arc::new(roots))?; + let mut client_endpoint = Endpoint::client("127.0.0.1:0".parse()?)?; + client_endpoint.set_default_client_config(client_config); + + let connection = client_endpoint.connect(server_addr, "localhost")?.await?; + let (mut send, mut recv) = connection.open_bi().await?; + let request = QuicTransferRequest { + scope: plan.scope.clone(), + authorization_digest: plan.authorization_digest.clone(), + requested_bytes: payload.len() as u64, + }; + let request_bytes = serde_json::to_vec(&request)?; + send.write_all(&request_bytes).await?; + send.finish()?; + let received = recv.read_to_end(64 * 1024).await?; + connection.close(0u32.into(), b"done"); + client_endpoint.wait_idle().await; + let server_received_request_bytes = server.await??; + + if received != payload { + return Err("QUIC artifact payload did not round trip".into()); + } + + println!( + "{}", + json!({ + "kind": "disasmer_quic_smoke", + "transport": format!("{:?}", transport.kind()), + "rust_native_quic": true, + "authenticated_direct_connection": transport.authenticated_direct_connections(), + "coordinator_assisted_rendezvous": plan.coordinator_assisted_rendezvous, + "coordinator_bulk_relay_allowed": plan.coordinator_bulk_relay_allowed, + "source_node": plan.source.node, + "destination_node": plan.destination.node, + "scope": plan.scope, + "request_bytes": request_bytes.len(), + "server_received_request_bytes": server_received_request_bytes, + "payload_bytes": received.len(), + "authorization_digest": plan.authorization_digest, + }) + ); + + Ok(()) +} + +fn self_signed_localhost_cert() -> Result< + (CertificateDer<'static>, PrivateKeyDer<'static>), + Box, +> { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_owned()])?; + let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(cert.signing_key.serialize_der())); + Ok((cert.cert.into(), key)) +} + +fn endpoint(name: &str, addr: SocketAddr) -> NodeEndpoint { + NodeEndpoint { + node: NodeId::from(name), + advertised_addr: addr.to_string(), + public_key_fingerprint: Digest::sha256(format!("{name}-public-key")), + } +} diff --git a/crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs b/crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs new file mode 100644 index 0000000..7e96f52 --- /dev/null +++ b/crates/disasmer-node/src/bin/disasmer-wasmtime-smoke.rs @@ -0,0 +1,133 @@ +use std::fs; +use std::path::PathBuf; + +use disasmer_core::{CommandInvocation, NodeId, TaskId, VfsPath}; +use disasmer_node::{LocalCommandExecutor, VirtualThreadCommand, WasmtimeTaskRuntime}; +use serde_json::json; + +fn main() -> Result<(), Box> { + let args: Vec = std::env::args().collect(); + if args.len() == 2 && args[1] == "--host-command" { + return run_host_command_smoke(); + } + if args.len() == 6 && args[1] == "--debug-freeze-resume" { + return run_debug_freeze_resume_smoke(&args[2], &args[3], &args[4], &args[5]); + } + if args.len() != 5 { + return Err( + "usage: disasmer-wasmtime-smoke | --host-command | --debug-freeze-resume ".into(), + ); + } + + let module = PathBuf::from(&args[1]); + let export = &args[2]; + let arg: i32 = args[3].parse()?; + let expected: i32 = args[4].parse()?; + + let wasm = fs::read(&module)?; + let runtime = WasmtimeTaskRuntime::new()?; + let result = runtime.run_i32_export(&wasm, export, arg)?; + if result != expected { + return Err(format!("expected {expected}, got {result} from export `{export}`").into()); + } + + println!( + "{}", + serde_json::to_string(&json!({ + "type": "wasmtime_task_smoke", + "module": module, + "export": export, + "arg": arg, + "result": result, + }))? + ); + Ok(()) +} + +fn run_debug_freeze_resume_smoke( + module: &str, + export: &str, + arg: &str, + expected: &str, +) -> Result<(), Box> { + let module = PathBuf::from(module); + let arg: i32 = arg.parse()?; + let expected: i32 = expected.parse()?; + let wasm = fs::read(&module)?; + let runtime = WasmtimeTaskRuntime::new()?; + let probe = runtime.freeze_resume_i32_export_probe(&wasm, export, arg)?; + if probe.result != expected { + return Err(format!( + "expected {expected}, got {} from export `{export}`", + probe.result + ) + .into()); + } + + println!( + "{}", + serde_json::to_string(&json!({ + "type": "wasmtime_debug_freeze_resume_smoke", + "module": module, + "export": export, + "task": probe.task, + "frozen_state": probe.frozen_state, + "resumed_state": probe.resumed_state, + "stack_frames": probe.stack_frames, + "local_values": probe.local_values, + "wasm_function": probe.wasm_function, + "wasm_pc": probe.wasm_pc, + "arg": arg, + "result": probe.result, + "node_runtime_reached_wasm_task": true, + "node_runtime_captured_wasm_locals": true, + }))? + ); + Ok(()) +} + +fn run_host_command_smoke() -> Result<(), Box> { + let runtime = WasmtimeTaskRuntime::new()?; + let result = runtime.run_i32_export_with_command_import( + r#" + (module + (import "disasmer" "cmd_run" (func $cmd_run (result i32))) + (func (export "compile-linux") (result i32) + call $cmd_run)) + "#, + "compile-linux", + LocalCommandExecutor { + node: NodeId::from("node-wasmtime"), + hosted_control_plane: false, + has_command_capability: true, + }, + VirtualThreadCommand { + virtual_thread: TaskId::from("compile-linux"), + invocation: CommandInvocation { + program: "sh".to_owned(), + args: vec!["-c".to_owned(), "printf linux-build-artifact".to_owned()], + env: None, + }, + stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/linux/app.tar.zst")?), + }, + )?; + + println!( + "{}", + serde_json::to_string(&json!({ + "type": "wasmtime_host_command_smoke", + "export": "compile-linux", + "export_result": result.export_result, + "virtual_thread": result.command_output.virtual_thread, + "stdout": result.command_output.stdout, + "staged_artifact": result.command_output.staged_artifact, + "large_bytes_uploaded": result.manifest.large_bytes_uploaded, + "manifest_objects": result.manifest.objects.len(), + "node_host_import": "disasmer.cmd_run", + "flagship_linux_build_task": true, + "node_executed_host_command": true, + "hosted_control_plane_ran_command": false, + }))? + ); + Ok(()) +} diff --git a/crates/disasmer-node/src/lib.rs b/crates/disasmer-node/src/lib.rs new file mode 100644 index 0000000..a369de2 --- /dev/null +++ b/crates/disasmer-node/src/lib.rs @@ -0,0 +1,1424 @@ +use std::path::PathBuf; + +use disasmer_core::{ + Capability, CommandBackendKind, CommandInvocation, CommandPlan, DebugEpoch, DebugParticipant, + DebugParticipantKind, DebugRuntimeState, Digest, EnvironmentKind, GuestRuntimeKind, LogBuffer, + NativeCommandPolicy, NodeId, ProcessId, TaskId, VfsManifest, VfsObject, VfsOverlay, VfsPath, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; +use wasmtime::{ + Caller, Config, DebugEvent, DebugHandler, Engine, Instance, Linker, Module, OptLevel, Store, + StoreContextMut, +}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct MaterializedEnvironment { + pub name: String, + pub backend: CommandBackendKind, + pub local_reference: String, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum BackendError { + #[error("native command denied: {0}")] + Denied(String), + #[error("environment is required for this backend")] + MissingEnvironment, + #[error("command failed to execute: {0}")] + Command(String), + #[error("artifact staging failed: {0}")] + Artifact(String), + #[error("unsupported environment kind for backend")] + UnsupportedEnvironment, + #[error("node cannot freeze task `{task}` for the current debug epoch")] + DebugFreezeUnsupported { task: TaskId }, +} + +pub trait CommandBackend { + fn kind(&self) -> CommandBackendKind; + fn plan(&self, invocation: &CommandInvocation) -> Result; +} + +#[derive(Clone, Debug, Default)] +pub struct LinuxRootlessPodmanBackend; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PodmanCommand { + pub program: String, + pub args: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ProcessOutput { + pub status_code: Option, + pub stdout: Vec, + pub stderr: Vec, +} + +pub trait ProcessRunner { + fn run(&mut self, command: &PodmanCommand) -> Result; +} + +#[derive(Clone, Debug, Default)] +pub struct StdProcessRunner; + +impl ProcessRunner for StdProcessRunner { + fn run(&mut self, command: &PodmanCommand) -> Result { + let output = std::process::Command::new(&command.program) + .args(&command.args) + .output() + .map_err(|err| BackendError::Command(format!("{err:#}")))?; + + Ok(ProcessOutput { + status_code: output.status.code(), + stdout: output.stdout, + stderr: output.stderr, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PodmanEnvironmentMaterialization { + pub environment: String, + pub image_tag: String, + pub build: PodmanCommand, + pub rootless_user_podman: bool, + pub embeds_full_image_in_bundle: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LocalSourceCheckout { + pub host_path: PathBuf, + pub snapshot: Digest, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum SourceAccessMode { + LocalCheckoutBindMount { + host_path: PathBuf, + container_path: String, + read_only: bool, + snapshot: Digest, + }, + NodePreparedSnapshot { + node_path: PathBuf, + container_path: String, + snapshot: Digest, + }, +} + +impl SourceAccessMode { + pub fn uses_full_repo_tarball(&self) -> bool { + false + } + + pub fn coordinator_routed_file_reads(&self) -> bool { + false + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum LinuxTaskState { + Running, + Frozen, + Cancelled, + Completed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinuxTaskLifecycle { + pub process: ProcessId, + pub virtual_thread: TaskId, + pub log_stream: String, + pub cancellation_token: String, + pub debug_participant: String, + pub freeze_supported: bool, + pub state: LinuxTaskState, +} + +impl LinuxTaskLifecycle { + pub fn new(process: ProcessId, virtual_thread: TaskId) -> Self { + Self { + log_stream: format!("process/{process}/task/{virtual_thread}/logs"), + cancellation_token: format!("cancel:{process}:{virtual_thread}"), + debug_participant: format!("debug:{process}:{virtual_thread}"), + process, + virtual_thread, + freeze_supported: true, + state: LinuxTaskState::Running, + } + } + + pub fn freeze_for_debug_epoch(&mut self) -> Result<(), BackendError> { + if !self.freeze_supported { + return Err(BackendError::DebugFreezeUnsupported { + task: self.virtual_thread.clone(), + }); + } + self.state = LinuxTaskState::Frozen; + Ok(()) + } + + pub fn resume_after_debug_epoch(&mut self) { + if self.state == LinuxTaskState::Frozen { + self.state = LinuxTaskState::Running; + } + } + + pub fn cancel(&mut self) { + self.state = LinuxTaskState::Cancelled; + } + + pub fn complete(&mut self) { + self.state = LinuxTaskState::Completed; + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinuxCommandRunPlan { + pub process: ProcessId, + pub virtual_thread: TaskId, + pub image_tag: String, + pub run: PodmanCommand, + pub source_access: SourceAccessMode, + pub stage_stdout_as: Option, + pub uses_full_repo_tarball: bool, + pub coordinator_routed_file_reads: bool, + pub lifecycle: LinuxTaskLifecycle, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct LinuxCommandTaskOutput { + pub virtual_thread: TaskId, + pub status_code: Option, + pub stdout: String, + pub stderr: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub log_backpressured: bool, + pub staged_artifact: Option, + pub lifecycle: LinuxTaskLifecycle, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapturedCommandLogs { + pub stdout: String, + pub stderr: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub backpressured: bool, +} + +pub const DEFAULT_COMMAND_LOG_LIMIT_BYTES: usize = 256 * 1024; + +impl LinuxRootlessPodmanBackend { + pub fn materialize_environment( + &self, + env: &disasmer_core::EnvironmentResource, + ) -> Result { + match env.kind { + EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => {} + EnvironmentKind::NixFlake => return Err(BackendError::UnsupportedEnvironment), + } + + let image_tag = self.image_tag(env); + Ok(PodmanEnvironmentMaterialization { + environment: env.name.clone(), + image_tag: image_tag.clone(), + build: PodmanCommand { + program: "podman".to_owned(), + args: vec![ + "build".to_owned(), + "--pull=never".to_owned(), + "--tag".to_owned(), + image_tag, + "--file".to_owned(), + env.recipe_path.to_string_lossy().into_owned(), + env.context_path.to_string_lossy().into_owned(), + ], + }, + rootless_user_podman: true, + embeds_full_image_in_bundle: false, + }) + } + + pub fn plan_local_checkout_run( + &self, + process: ProcessId, + virtual_thread: TaskId, + invocation: &CommandInvocation, + checkout: LocalSourceCheckout, + stage_stdout_as: Option, + ) -> Result { + let env = invocation + .env + .as_ref() + .ok_or(BackendError::MissingEnvironment)?; + let materialization = self.materialize_environment(env)?; + let source_access = SourceAccessMode::LocalCheckoutBindMount { + host_path: checkout.host_path.clone(), + container_path: "/workspace".to_owned(), + read_only: false, + snapshot: checkout.snapshot, + }; + let lifecycle = LinuxTaskLifecycle::new(process.clone(), virtual_thread.clone()); + let mut args = vec![ + "run".to_owned(), + "--rm".to_owned(), + "--network".to_owned(), + "none".to_owned(), + "--volume".to_owned(), + format!("{}:/workspace:Z", checkout.host_path.to_string_lossy()), + "--workdir".to_owned(), + "/workspace".to_owned(), + materialization.image_tag.clone(), + invocation.program.clone(), + ]; + args.extend(invocation.args.iter().cloned()); + + Ok(LinuxCommandRunPlan { + process, + virtual_thread, + image_tag: materialization.image_tag, + run: PodmanCommand { + program: "podman".to_owned(), + args, + }, + source_access, + stage_stdout_as, + uses_full_repo_tarball: false, + coordinator_routed_file_reads: false, + lifecycle, + }) + } + + pub fn execute_environment_materialization( + &self, + env: &disasmer_core::EnvironmentResource, + runner: &mut impl ProcessRunner, + ) -> Result { + let materialization = self.materialize_environment(env)?; + let output = runner.run(&materialization.build)?; + if output.status_code != Some(0) { + return Err(BackendError::Command(format!( + "podman build for environment `{}` failed with status {:?}: {}", + materialization.environment, + output.status_code, + String::from_utf8_lossy(&output.stderr) + ))); + } + + Ok(MaterializedEnvironment { + name: materialization.environment, + backend: CommandBackendKind::LinuxRootlessPodman, + local_reference: materialization.image_tag, + }) + } + + pub fn execute_run_plan( + &self, + plan: LinuxCommandRunPlan, + runner: &mut impl ProcessRunner, + overlay: &mut VfsOverlay, + ) -> Result { + self.execute_run_plan_with_log_limit(plan, runner, overlay, DEFAULT_COMMAND_LOG_LIMIT_BYTES) + } + + pub fn execute_run_plan_with_log_limit( + &self, + mut plan: LinuxCommandRunPlan, + runner: &mut impl ProcessRunner, + overlay: &mut VfsOverlay, + max_log_bytes: usize, + ) -> Result { + let output = runner.run(&plan.run)?; + let logs = capture_command_logs( + &plan.virtual_thread, + &output.stdout, + &output.stderr, + max_log_bytes, + ); + let staged_artifact = if output.status_code == Some(0) { + if let Some(path) = plan.stage_stdout_as.take() { + Some(overlay.write( + path, + Digest::sha256(&output.stdout), + output.stdout.len() as u64, + )) + } else { + None + } + } else { + None + }; + + if output.status_code == Some(0) { + plan.lifecycle.complete(); + } + + Ok(LinuxCommandTaskOutput { + virtual_thread: plan.virtual_thread, + status_code: output.status_code, + stdout: logs.stdout, + stderr: logs.stderr, + stdout_truncated: logs.stdout_truncated, + stderr_truncated: logs.stderr_truncated, + log_backpressured: logs.backpressured, + staged_artifact, + lifecycle: plan.lifecycle, + }) + } + + pub fn execute_local_checkout_task( + &self, + process: ProcessId, + virtual_thread: TaskId, + invocation: &CommandInvocation, + checkout: LocalSourceCheckout, + stage_stdout_as: Option, + runner: &mut impl ProcessRunner, + overlay: &mut VfsOverlay, + ) -> Result { + let env = invocation + .env + .as_ref() + .ok_or(BackendError::MissingEnvironment)?; + self.execute_environment_materialization(env, runner)?; + let plan = self.plan_local_checkout_run( + process, + virtual_thread, + invocation, + checkout, + stage_stdout_as, + )?; + self.execute_run_plan(plan, runner, overlay) + } + + fn image_tag(&self, env: &disasmer_core::EnvironmentResource) -> String { + let name = env + .name + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + ch + } else { + '-' + } + }) + .collect::(); + let digest = env + .digest + .as_str() + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' { + ch + } else { + '-' + } + }) + .take(24) + .collect::(); + format!("disasmer-env/{name}:{digest}") + } +} + +impl CommandBackend for LinuxRootlessPodmanBackend { + fn kind(&self) -> CommandBackendKind { + CommandBackendKind::LinuxRootlessPodman + } + + fn plan(&self, invocation: &CommandInvocation) -> Result { + let env = invocation + .env + .as_ref() + .ok_or(BackendError::MissingEnvironment)?; + match env.kind { + EnvironmentKind::Containerfile | EnvironmentKind::Dockerfile => Ok(CommandPlan { + guest_runtime: GuestRuntimeKind::Wasmtime, + backend: CommandBackendKind::LinuxRootlessPodman, + required_capability: Capability::RootlessPodman, + user_attached_development_execution: false, + }), + EnvironmentKind::NixFlake => Err(BackendError::UnsupportedEnvironment), + } + } +} + +#[derive(Clone, Debug, Default)] +pub struct WindowsCommandDevBackend; + +impl CommandBackend for WindowsCommandDevBackend { + fn kind(&self) -> CommandBackendKind { + CommandBackendKind::WindowsCommandDev + } + + fn plan(&self, _invocation: &CommandInvocation) -> Result { + Ok(CommandPlan { + guest_runtime: GuestRuntimeKind::Wasmtime, + backend: CommandBackendKind::WindowsCommandDev, + required_capability: Capability::WindowsCommandDev, + user_attached_development_execution: true, + }) + } +} + +#[derive(Clone, Debug, Default)] +pub struct WindowsSandboxStubBackend; + +impl CommandBackend for WindowsSandboxStubBackend { + fn kind(&self) -> CommandBackendKind { + CommandBackendKind::StubbedWindowsSandbox + } + + fn plan(&self, _invocation: &CommandInvocation) -> Result { + Err(BackendError::Denied( + "Windows sandbox backend is an explicit stub for MVP; use windows-command-dev only for user-attached development execution" + .to_owned(), + )) + } +} + +pub fn authorize_node_command( + hosted_control_plane: bool, + node_has_command_capability: bool, +) -> Result<(), BackendError> { + NativeCommandPolicy { + hosted_control_plane, + node_has_command_capability, + } + .authorize() + .map_err(BackendError::Denied) +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VirtualThreadCommand { + pub virtual_thread: TaskId, + pub invocation: CommandInvocation, + pub stage_stdout_as: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandOutput { + pub virtual_thread: TaskId, + pub status_code: Option, + pub stdout: String, + pub stderr: String, + pub stdout_truncated: bool, + pub stderr_truncated: bool, + pub log_backpressured: bool, + pub staged_artifact: Option, +} + +#[derive(Clone, Debug)] +pub struct LocalCommandExecutor { + pub node: NodeId, + pub hosted_control_plane: bool, + pub has_command_capability: bool, +} + +impl LocalCommandExecutor { + pub fn run( + &self, + command: VirtualThreadCommand, + overlay: &mut VfsOverlay, + ) -> Result { + self.run_with_log_limit(command, overlay, DEFAULT_COMMAND_LOG_LIMIT_BYTES) + } + + pub fn run_with_log_limit( + &self, + command: VirtualThreadCommand, + overlay: &mut VfsOverlay, + max_log_bytes: usize, + ) -> Result { + authorize_node_command(self.hosted_control_plane, self.has_command_capability)?; + + let output = std::process::Command::new(&command.invocation.program) + .args(&command.invocation.args) + .output() + .map_err(|err| BackendError::Command(format!("{err:#}")))?; + + let logs = capture_command_logs( + &command.virtual_thread, + &output.stdout, + &output.stderr, + max_log_bytes, + ); + let staged_artifact = if let Some(path) = command.stage_stdout_as { + Some(overlay.write( + path, + Digest::sha256(&output.stdout), + output.stdout.len() as u64, + )) + } else { + None + }; + + Ok(CommandOutput { + virtual_thread: command.virtual_thread, + status_code: output.status.code(), + stdout: logs.stdout, + stderr: logs.stderr, + stdout_truncated: logs.stdout_truncated, + stderr_truncated: logs.stderr_truncated, + log_backpressured: logs.backpressured, + staged_artifact, + }) + } +} + +fn capture_command_logs( + task: &TaskId, + stdout: &[u8], + stderr: &[u8], + max_log_bytes: usize, +) -> CapturedCommandLogs { + let mut logs = LogBuffer::new(max_log_bytes); + logs.push(task.clone(), stdout); + logs.push(task.clone(), stderr); + let records = logs.records(); + let stdout_record = &records[0]; + let stderr_record = &records[1]; + debug_assert_eq!(&stdout_record.task, task); + debug_assert_eq!(&stderr_record.task, task); + CapturedCommandLogs { + stdout: String::from_utf8_lossy(&stdout_record.bytes).into_owned(), + stderr: String::from_utf8_lossy(&stderr_record.bytes).into_owned(), + stdout_truncated: stdout_record.truncated, + stderr_truncated: stderr_record.truncated, + backpressured: logs.backpressured(), + } +} + +#[derive(Clone)] +pub struct WasmtimeTaskRuntime { + engine: Engine, +} + +#[derive(Clone, Debug, Error, PartialEq, Eq)] +pub enum WasmTaskError { + #[error("wasmtime task failed: {0}")] + Runtime(String), + #[error("wasmtime host command failed: {0}")] + HostCommand(String), +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WasmtimeDebugProbe { + pub task: TaskId, + pub frozen_state: DebugRuntimeState, + pub resumed_state: DebugRuntimeState, + pub result: i32, + pub stack_frames: Vec, + pub local_values: Vec<(String, String)>, + pub wasm_function: Option, + pub wasm_pc: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct WasmtimeFrameSnapshot { + stack_frames: Vec, + local_values: Vec<(String, String)>, + wasm_function: Option, + wasm_pc: Option, +} + +#[derive(Clone, Debug, Default)] +struct WasmtimeDebugState { + snapshot: Option, +} + +#[derive(Clone, Debug)] +struct WasmtimeLocalSnapshotHandler { + export: String, +} + +impl DebugHandler for WasmtimeLocalSnapshotHandler { + type Data = WasmtimeDebugState; + + async fn handle(&self, mut store: StoreContextMut<'_, Self::Data>, event: DebugEvent<'_>) { + if !matches!(event, DebugEvent::Breakpoint) || store.data().snapshot.is_some() { + return; + } + + let mut snapshot = WasmtimeFrameSnapshot { + stack_frames: vec![format!("{}::wasm_export", self.export)], + ..WasmtimeFrameSnapshot::default() + }; + for frame in store.debug_exit_frames().collect::>() { + if let Ok(Some((function, pc))) = frame.wasm_function_index_and_pc(&mut store) { + snapshot.wasm_function = Some(format!("{function:?}")); + snapshot.wasm_pc = Some(pc); + } + + if let Ok(count) = frame.num_locals(&mut store) { + for index in 0..count.min(16) { + let value = match frame.local(&mut store, index) { + Ok(value) => format!("{value:?}"), + Err(err) => format!(""), + }; + snapshot + .local_values + .push((format!("wasm_local_{index}"), value)); + } + } + break; + } + + if let Some(function) = &snapshot.wasm_function { + snapshot.stack_frames = vec![format!("{} / {function}", self.export)]; + } + store.data_mut().snapshot = Some(snapshot); + if let Some(mut edit) = store.edit_breakpoints() { + let _ = edit.single_step(false); + } + } +} + +impl WasmtimeTaskRuntime { + pub fn new() -> Result { + let mut config = Config::new(); + config.wasm_backtrace_details(wasmtime::WasmBacktraceDetails::Enable); + let engine = Engine::new(&config).map_err(wasmtime_error)?; + Ok(Self { engine }) + } + + fn debug_engine() -> Result { + let mut config = Config::new(); + config.debug_info(true); + config.guest_debug(true); + config.generate_address_map(true); + config.cranelift_opt_level(OptLevel::None); + Engine::new(&config).map_err(wasmtime_error) + } + + pub fn run_i32_export( + &self, + wasm_or_wat: impl AsRef<[u8]>, + export: &str, + arg: i32, + ) -> Result { + let module = Module::new(&self.engine, wasm_or_wat.as_ref()).map_err(wasmtime_error)?; + let mut store = Store::new(&self.engine, ()); + let instance = Instance::new(&mut store, &module, &[]).map_err(wasmtime_error)?; + let func = instance + .get_typed_func::(&mut store, export) + .map_err(wasmtime_error)?; + func.call(&mut store, arg).map_err(wasmtime_error) + } + + pub fn freeze_resume_i32_export_probe( + &self, + wasm_or_wat: impl AsRef<[u8]>, + export: &str, + arg: i32, + ) -> Result { + let task = TaskId::from(export); + let snapshot = Self::debug_i32_export_snapshot(wasm_or_wat.as_ref(), export, arg)?; + let mut epoch = DebugEpoch::pause( + ProcessId::from("wasmtime-debug-probe"), + 1, + vec![DebugParticipant { + task: task.clone(), + name: export.to_owned(), + kind: DebugParticipantKind::WasmTask, + can_freeze: true, + state: DebugRuntimeState::Running, + stack_frames: snapshot.stack_frames.clone(), + local_values: snapshot.local_values.clone(), + task_args: vec![("arg".to_owned(), arg.to_string())], + handles: Vec::new(), + command_status: None, + recent_output: Vec::new(), + }], + ) + .map_err(|err| WasmTaskError::Runtime(err.to_string()))?; + let frozen_state = epoch + .participant_state(&task) + .cloned() + .ok_or_else(|| WasmTaskError::Runtime("Wasm debug participant missing".to_owned()))?; + let inspection = epoch + .inspection(&task) + .map_err(|err| WasmTaskError::Runtime(err.to_string()))?; + epoch.continue_all(); + let resumed_state = epoch + .participant_state(&task) + .cloned() + .ok_or_else(|| WasmTaskError::Runtime("Wasm debug participant missing".to_owned()))?; + let result = self.run_i32_export(wasm_or_wat, export, arg)?; + + Ok(WasmtimeDebugProbe { + task, + frozen_state, + resumed_state, + result, + stack_frames: inspection.stack_frames, + local_values: inspection.local_values, + wasm_function: snapshot.wasm_function, + wasm_pc: snapshot.wasm_pc, + }) + } + + fn debug_i32_export_snapshot( + wasm_or_wat: &[u8], + export: &str, + arg: i32, + ) -> Result { + let engine = Self::debug_engine()?; + let module = Module::new(&engine, wasm_or_wat).map_err(wasmtime_error)?; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|err| WasmTaskError::Runtime(format!("create debug runtime: {err:#}")))?; + let state = runtime.block_on(async { + let mut store = Store::new(&engine, WasmtimeDebugState::default()); + store.set_debug_handler(WasmtimeLocalSnapshotHandler { + export: export.to_owned(), + }); + let instance = Instance::new_async(&mut store, &module, &[]) + .await + .map_err(wasmtime_error)?; + if let Some(mut edit) = store.edit_breakpoints() { + edit.single_step(true).map_err(wasmtime_error)?; + } + let func = instance + .get_typed_func::(&mut store, export) + .map_err(wasmtime_error)?; + let _ = func + .call_async(&mut store, arg) + .await + .map_err(wasmtime_error)?; + Ok::<_, WasmTaskError>(store.into_data()) + })?; + + state.snapshot.ok_or_else(|| { + WasmTaskError::Runtime( + "Wasmtime guest debug did not produce a frame-local snapshot".to_owned(), + ) + }) + } + + pub fn run_i32_export_with_command_import( + &self, + wasm_or_wat: impl AsRef<[u8]>, + export: &str, + executor: LocalCommandExecutor, + command: VirtualThreadCommand, + ) -> Result { + let module = Module::new(&self.engine, wasm_or_wat.as_ref()).map_err(wasmtime_error)?; + let mut linker = Linker::new(&self.engine); + linker + .func_wrap( + "disasmer", + "cmd_run", + |mut caller: Caller<'_, WasmtimeHostCommandState>| -> i32 { + let state = caller.data_mut(); + match state + .executor + .run(state.command.clone(), &mut state.overlay) + { + Ok(output) => { + let status_code = output.status_code.unwrap_or(-1); + state.output = Some(output); + status_code + } + Err(err) => { + state.host_error = Some(err.to_string()); + -1 + } + } + }, + ) + .map_err(wasmtime_error)?; + let task = command.virtual_thread.clone(); + let node = executor.node.clone(); + let state = WasmtimeHostCommandState { + executor, + overlay: VfsOverlay::new(task, node), + command, + output: None, + host_error: None, + }; + let mut store = Store::new(&self.engine, state); + let instance = linker + .instantiate(&mut store, &module) + .map_err(wasmtime_error)?; + let func = instance + .get_typed_func::<(), i32>(&mut store, export) + .map_err(wasmtime_error)?; + let export_result = func.call(&mut store, ()).map_err(wasmtime_error)?; + let mut state = store.into_data(); + if let Some(error) = state.host_error.take() { + return Err(WasmTaskError::HostCommand(error)); + } + let output = state.output.take().ok_or_else(|| { + WasmTaskError::HostCommand( + "wasm task completed without invoking disasmer.cmd_run".to_owned(), + ) + })?; + let manifest = state.overlay.flush(); + Ok(WasmtimeHostCommandResult { + export_result, + command_output: output, + manifest, + }) + } +} + +fn wasmtime_error(err: wasmtime::Error) -> WasmTaskError { + WasmTaskError::Runtime(format!("{err:?}")) +} + +#[derive(Clone, Debug)] +struct WasmtimeHostCommandState { + executor: LocalCommandExecutor, + overlay: VfsOverlay, + command: VirtualThreadCommand, + output: Option, + host_error: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WasmtimeHostCommandResult { + pub export_result: i32, + pub command_output: CommandOutput, + pub manifest: VfsManifest, +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::path::PathBuf; + + use disasmer_core::{ + Digest, EnvironmentRequirements, EnvironmentResource, ProjectId, TenantId, + }; + + use super::*; + + #[derive(Default)] + struct RecordingRunner { + commands: Vec, + outputs: VecDeque, + } + + impl RecordingRunner { + fn with_outputs(outputs: impl IntoIterator) -> Self { + Self { + commands: Vec::new(), + outputs: outputs.into_iter().collect(), + } + } + } + + impl ProcessRunner for RecordingRunner { + fn run(&mut self, command: &PodmanCommand) -> Result { + self.commands.push(command.clone()); + self.outputs.pop_front().ok_or_else(|| { + BackendError::Command("recording runner has no output queued".to_owned()) + }) + } + } + + fn success_output(stdout: impl Into>) -> ProcessOutput { + ProcessOutput { + status_code: Some(0), + stdout: stdout.into(), + stderr: Vec::new(), + } + } + + fn container_env() -> EnvironmentResource { + EnvironmentResource { + name: "linux".to_owned(), + kind: EnvironmentKind::Containerfile, + recipe_path: PathBuf::from("envs/linux/Containerfile"), + context_path: PathBuf::from("envs/linux"), + digest: Digest::sha256("recipe"), + requirements: EnvironmentRequirements::linux_container(), + } + } + + #[test] + fn linux_backend_plans_rootless_podman_under_wasmtime_virtual_task() { + let invocation = CommandInvocation { + program: "cargo".to_owned(), + args: vec!["build".to_owned()], + env: Some(container_env()), + }; + let plan = LinuxRootlessPodmanBackend.plan(&invocation).unwrap(); + + assert_eq!(plan.guest_runtime, GuestRuntimeKind::Wasmtime); + assert_eq!(plan.backend, CommandBackendKind::LinuxRootlessPodman); + assert_eq!(plan.required_capability, Capability::RootlessPodman); + } + + #[test] + fn linux_backend_materializes_containerfile_with_rootless_podman_without_vendored_image() { + let env = container_env(); + let materialization = LinuxRootlessPodmanBackend + .materialize_environment(&env) + .unwrap(); + + assert_eq!(materialization.environment, "linux"); + assert!(materialization.image_tag.starts_with("disasmer-env/linux:")); + assert_eq!(materialization.image_tag.matches(':').count(), 1); + assert_eq!(materialization.build.program, "podman"); + assert!(materialization.rootless_user_podman); + assert!(!materialization.embeds_full_image_in_bundle); + assert!(materialization.build.args.contains(&"build".to_owned())); + assert!(materialization + .build + .args + .contains(&"--pull=never".to_owned())); + assert!(materialization + .build + .args + .contains(&"envs/linux/Containerfile".to_owned())); + assert!(materialization + .build + .args + .contains(&"envs/linux".to_owned())); + } + + #[test] + fn linux_run_plan_keeps_local_checkout_local_and_avoids_coordinator_file_reads() { + let invocation = CommandInvocation { + program: "cargo".to_owned(), + args: vec!["build".to_owned(), "--release".to_owned()], + env: Some(container_env()), + }; + let plan = LinuxRootlessPodmanBackend + .plan_local_checkout_run( + ProcessId::from("vp"), + TaskId::from("compile-linux"), + &invocation, + LocalSourceCheckout { + host_path: PathBuf::from("/work/example"), + snapshot: Digest::sha256("checkout"), + }, + Some(VfsPath::new("/vfs/artifacts/app").unwrap()), + ) + .unwrap(); + + assert_eq!(plan.run.program, "podman"); + assert!(plan.run.args.contains(&"run".to_owned())); + assert!(plan.run.args.contains(&"--network".to_owned())); + assert!(plan.run.args.contains(&"none".to_owned())); + assert!(plan + .run + .args + .contains(&"/work/example:/workspace:Z".to_owned())); + assert!(plan.run.args.contains(&"cargo".to_owned())); + assert!(plan.run.args.contains(&"--release".to_owned())); + assert!(!plan.uses_full_repo_tarball); + assert!(!plan.coordinator_routed_file_reads); + assert!(!plan.source_access.uses_full_repo_tarball()); + assert!(!plan.source_access.coordinator_routed_file_reads()); + assert_eq!( + plan.stage_stdout_as, + Some(VfsPath::new("/vfs/artifacts/app").unwrap()) + ); + assert_eq!(plan.lifecycle.process, ProcessId::from("vp")); + assert_eq!(plan.lifecycle.virtual_thread, TaskId::from("compile-linux")); + assert_eq!(plan.lifecycle.state, LinuxTaskState::Running); + + match &plan.source_access { + SourceAccessMode::LocalCheckoutBindMount { + host_path, + container_path, + read_only, + .. + } => { + assert_eq!(host_path, &PathBuf::from("/work/example")); + assert_eq!(container_path, "/workspace"); + assert!(!read_only); + } + SourceAccessMode::NodePreparedSnapshot { .. } => { + panic!("local Linux build should use the node-local checkout") + } + } + } + + #[test] + fn linux_backend_executes_podman_build_then_run_and_stages_artifact() { + let invocation = CommandInvocation { + program: "cargo".to_owned(), + args: vec!["build".to_owned()], + env: Some(container_env()), + }; + let mut runner = + RecordingRunner::with_outputs([success_output([]), success_output(b"artifact-bytes")]); + let mut overlay = VfsOverlay::new(TaskId::from("compile-linux"), NodeId::from("node")); + + let output = LinuxRootlessPodmanBackend + .execute_local_checkout_task( + ProcessId::from("vp"), + TaskId::from("compile-linux"), + &invocation, + LocalSourceCheckout { + host_path: PathBuf::from("/work/demo"), + snapshot: Digest::sha256("checkout"), + }, + Some(VfsPath::new("/vfs/artifacts/app.tar.zst").unwrap()), + &mut runner, + &mut overlay, + ) + .unwrap(); + let manifest = overlay.flush(); + + assert_eq!(runner.commands.len(), 2); + assert_eq!(runner.commands[0].program, "podman"); + assert!(runner.commands[0].args.contains(&"build".to_owned())); + assert_eq!(runner.commands[1].program, "podman"); + assert!(runner.commands[1].args.contains(&"run".to_owned())); + assert!(runner.commands[1] + .args + .contains(&"/work/demo:/workspace:Z".to_owned())); + assert_eq!(output.status_code, Some(0)); + assert_eq!(output.stdout, "artifact-bytes"); + assert_eq!(output.lifecycle.state, LinuxTaskState::Completed); + assert!(output.staged_artifact.is_some()); + assert!(manifest + .objects + .contains_key(&VfsPath::new("/vfs/artifacts/app.tar.zst").unwrap())); + assert!(!manifest.large_bytes_uploaded); + } + + #[test] + fn linux_backend_caps_logs_without_truncating_staged_artifact_bytes() { + let invocation = CommandInvocation { + program: "cargo".to_owned(), + args: vec!["build".to_owned()], + env: Some(container_env()), + }; + let mut runner = + RecordingRunner::with_outputs([success_output(b"abcdef"), success_output(b"unused")]); + let mut overlay = VfsOverlay::new(TaskId::from("compile-linux"), NodeId::from("node")); + let plan = LinuxRootlessPodmanBackend + .plan_local_checkout_run( + ProcessId::from("vp"), + TaskId::from("compile-linux"), + &invocation, + LocalSourceCheckout { + host_path: PathBuf::from("/work/demo"), + snapshot: Digest::sha256("checkout"), + }, + Some(VfsPath::new("/vfs/artifacts/app.txt").unwrap()), + ) + .unwrap(); + + let output = LinuxRootlessPodmanBackend + .execute_run_plan_with_log_limit(plan, &mut runner, &mut overlay, 4) + .unwrap(); + + assert_eq!(output.virtual_thread, TaskId::from("compile-linux")); + assert_eq!(output.stdout, "abcd"); + assert!(output.stdout_truncated); + assert!(output.log_backpressured); + assert_eq!(output.staged_artifact.as_ref().unwrap().size, 6); + } + + #[test] + fn failed_podman_materialization_returns_clear_error_before_command_run() { + let invocation = CommandInvocation { + program: "cargo".to_owned(), + args: vec!["build".to_owned()], + env: Some(container_env()), + }; + let mut runner = RecordingRunner::with_outputs([ProcessOutput { + status_code: Some(125), + stdout: Vec::new(), + stderr: b"image build failed".to_vec(), + }]); + let mut overlay = VfsOverlay::new(TaskId::from("compile-linux"), NodeId::from("node")); + + let error = LinuxRootlessPodmanBackend + .execute_local_checkout_task( + ProcessId::from("vp"), + TaskId::from("compile-linux"), + &invocation, + LocalSourceCheckout { + host_path: PathBuf::from("/work/demo"), + snapshot: Digest::sha256("checkout"), + }, + Some(VfsPath::new("/vfs/artifacts/app.tar.zst").unwrap()), + &mut runner, + &mut overlay, + ) + .unwrap_err(); + + assert!(error.to_string().contains("podman build")); + assert!(error.to_string().contains("image build failed")); + assert_eq!(runner.commands.len(), 1); + } + + #[test] + fn linux_task_lifecycle_supports_cancel_and_all_stop_freeze_resume() { + let mut lifecycle = + LinuxTaskLifecycle::new(ProcessId::from("vp"), TaskId::from("compile-linux")); + + lifecycle.freeze_for_debug_epoch().unwrap(); + assert_eq!(lifecycle.state, LinuxTaskState::Frozen); + + lifecycle.resume_after_debug_epoch(); + assert_eq!(lifecycle.state, LinuxTaskState::Running); + + lifecycle.cancel(); + assert_eq!(lifecycle.state, LinuxTaskState::Cancelled); + + let mut unsupported = + LinuxTaskLifecycle::new(ProcessId::from("vp"), TaskId::from("native-command")); + unsupported.freeze_supported = false; + let error = unsupported.freeze_for_debug_epoch().unwrap_err(); + assert!(matches!(error, BackendError::DebugFreezeUnsupported { .. })); + } + + #[test] + fn windows_backend_is_labeled_user_attached_dev_execution() { + let invocation = CommandInvocation { + program: "cmd".to_owned(), + args: vec!["/C".to_owned(), "build.bat".to_owned()], + env: None, + }; + let plan = WindowsCommandDevBackend.plan(&invocation).unwrap(); + + assert!(plan.user_attached_development_execution); + assert_eq!(plan.required_capability, Capability::WindowsCommandDev); + } + + #[test] + fn hosted_control_plane_native_command_is_denied() { + let error = authorize_node_command(true, true).unwrap_err(); + + assert!(matches!(error, BackendError::Denied(_))); + } + + #[test] + fn native_command_is_denied_without_command_capability() { + let error = authorize_node_command(false, false).unwrap_err(); + + assert!(matches!(error, BackendError::Denied(_))); + assert!(error + .to_string() + .contains("lacks native command capability")); + } + + #[test] + fn wasmtime_runtime_runs_named_task_export() { + let runtime = WasmtimeTaskRuntime::new().unwrap(); + let result = runtime + .run_i32_export( + r#" + (module + (func (export "task_add_one") (param i32) (result i32) + local.get 0 + i32.const 1 + i32.add)) + "#, + "task_add_one", + 41, + ) + .unwrap(); + + assert_eq!(result, 42); + } + + #[test] + fn wasmtime_runtime_freezes_and_resumes_wasm_debug_participant() { + let runtime = WasmtimeTaskRuntime::new().unwrap(); + let probe = runtime + .freeze_resume_i32_export_probe( + r#" + (module + (func (export "task_add_one") (param i32) (result i32) + local.get 0 + i32.const 1 + i32.add)) + "#, + "task_add_one", + 41, + ) + .unwrap(); + + assert_eq!(probe.task, TaskId::from("task_add_one")); + assert_eq!(probe.frozen_state, DebugRuntimeState::Frozen); + assert_eq!(probe.resumed_state, DebugRuntimeState::Running); + assert_eq!(probe.result, 42); + assert!(probe + .stack_frames + .iter() + .any(|frame| frame.contains("task_add_one"))); + assert!(probe + .local_values + .iter() + .any(|(name, value)| { name == "wasm_local_0" && value.contains("41") })); + assert!(probe.wasm_pc.is_some()); + } + + #[cfg(unix)] + #[test] + fn wasmtime_task_invokes_native_command_through_node_host_import() { + let runtime = WasmtimeTaskRuntime::new().unwrap(); + let executor = LocalCommandExecutor { + node: NodeId::from("node"), + hosted_control_plane: false, + has_command_capability: true, + }; + let result = runtime + .run_i32_export_with_command_import( + r#" + (module + (import "disasmer" "cmd_run" (func $cmd_run (result i32))) + (func (export "task_calls_command") (result i32) + call $cmd_run)) + "#, + "task_calls_command", + executor, + VirtualThreadCommand { + virtual_thread: TaskId::from("compile-linux"), + invocation: CommandInvocation { + program: "sh".to_owned(), + args: vec!["-c".to_owned(), "printf wasmtime-host-command".to_owned()], + env: None, + }, + stage_stdout_as: Some( + VfsPath::new("/vfs/artifacts/wasmtime-host-command.txt").unwrap(), + ), + }, + ) + .unwrap(); + + assert_eq!(result.export_result, 0); + assert_eq!( + result.command_output.virtual_thread, + TaskId::from("compile-linux") + ); + assert_eq!(result.command_output.stdout, "wasmtime-host-command"); + assert_eq!( + result.command_output.staged_artifact.as_ref().unwrap().path, + VfsPath::new("/vfs/artifacts/wasmtime-host-command.txt").unwrap() + ); + assert!(!result.manifest.large_bytes_uploaded); + assert!(result + .manifest + .objects + .contains_key(&VfsPath::new("/vfs/artifacts/wasmtime-host-command.txt").unwrap())); + } + + #[cfg(unix)] + #[test] + fn wasmtime_host_command_import_respects_node_command_capability() { + let runtime = WasmtimeTaskRuntime::new().unwrap(); + let executor = LocalCommandExecutor { + node: NodeId::from("node"), + hosted_control_plane: false, + has_command_capability: false, + }; + let error = runtime + .run_i32_export_with_command_import( + r#" + (module + (import "disasmer" "cmd_run" (func $cmd_run (result i32))) + (func (export "task_calls_command") (result i32) + call $cmd_run)) + "#, + "task_calls_command", + executor, + VirtualThreadCommand { + virtual_thread: TaskId::from("compile-linux"), + invocation: CommandInvocation { + program: "sh".to_owned(), + args: vec!["-c".to_owned(), "printf denied".to_owned()], + env: None, + }, + stage_stdout_as: None, + }, + ) + .unwrap_err(); + + assert!(error + .to_string() + .contains("lacks native command capability")); + } + + #[cfg(unix)] + #[test] + fn native_command_output_is_associated_with_virtual_thread_and_staged_to_vfs() { + let executor = LocalCommandExecutor { + node: disasmer_core::NodeId::from("node"), + hosted_control_plane: false, + has_command_capability: true, + }; + let mut overlay = VfsOverlay::new(TaskId::from("compile-linux"), NodeId::from("node")); + let output = executor + .run( + VirtualThreadCommand { + virtual_thread: TaskId::from("compile-linux"), + invocation: CommandInvocation { + program: "sh".to_owned(), + args: vec![ + "-c".to_owned(), + "printf artifact; printf log >&2".to_owned(), + ], + env: None, + }, + stage_stdout_as: Some(VfsPath::new("/vfs/artifacts/app.txt").unwrap()), + }, + &mut overlay, + ) + .unwrap(); + let manifest = overlay.flush(); + + assert_eq!(output.virtual_thread, TaskId::from("compile-linux")); + assert_eq!(output.stdout, "artifact"); + assert_eq!(output.stderr, "log"); + assert!(output.staged_artifact.is_some()); + assert!(!manifest.large_bytes_uploaded); + assert!(manifest + .objects + .contains_key(&VfsPath::new("/vfs/artifacts/app.txt").unwrap())); + } + + #[cfg(unix)] + #[test] + fn local_command_executor_caps_logs_and_reports_backpressure_by_virtual_thread() { + let executor = LocalCommandExecutor { + node: disasmer_core::NodeId::from("node"), + hosted_control_plane: false, + has_command_capability: true, + }; + let mut overlay = VfsOverlay::new(TaskId::from("compile-linux"), NodeId::from("node")); + let output = executor + .run_with_log_limit( + VirtualThreadCommand { + virtual_thread: TaskId::from("compile-linux"), + invocation: CommandInvocation { + program: "sh".to_owned(), + args: vec!["-c".to_owned(), "printf abcdef; printf err >&2".to_owned()], + env: None, + }, + stage_stdout_as: None, + }, + &mut overlay, + 4, + ) + .unwrap(); + + assert_eq!(output.virtual_thread, TaskId::from("compile-linux")); + assert_eq!(output.stdout, "abcd"); + assert_eq!(output.stderr, ""); + assert!(output.stdout_truncated); + assert!(output.stderr_truncated); + assert!(output.log_backpressured); + } + + #[test] + fn public_node_crate_does_not_require_hosted_private_types() { + let _tenant = TenantId::from("tenant"); + let _project = ProjectId::from("project"); + let _backend = LinuxRootlessPodmanBackend; + } +} diff --git a/crates/disasmer-node/src/main.rs b/crates/disasmer-node/src/main.rs new file mode 100644 index 0000000..ee3549a --- /dev/null +++ b/crates/disasmer-node/src/main.rs @@ -0,0 +1,637 @@ +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpStream; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +use disasmer_core::{ + Capability, CommandInvocation, NodeCapabilities, NodeId, TaskId, VfsOverlay, VfsPath, +}; +use disasmer_node::{CommandOutput, LocalCommandExecutor, VirtualThreadCommand}; +use serde_json::{json, Value}; + +#[derive(Debug)] +struct Args { + coordinator: String, + tenant: String, + project: String, + node: String, + process: String, + task: String, + command: String, + command_args: Vec, + artifact: String, + enrollment_grant: Option, + public_key: Option, + control_poll_ms: u64, + assignment_poll_ms: u64, + emit_ready: bool, + worker: bool, +} + +#[derive(Clone, Debug)] +struct RuntimeTask { + process: String, + task: String, + command: String, + command_args: Vec, + artifact: String, + epoch: Option, + task_assignment_response: Value, +} + +fn main() -> Result<(), Box> { + let args = parse_args()?; + + let mut session = CoordinatorSession::connect(&args.coordinator)?; + + let registration = register_node(&mut session, &args)?; + let heartbeat = session.request(json!({ + "type": "node_heartbeat", + "node": &args.node, + }))?; + let capability_report = session.request(json!({ + "type": "report_node_capabilities", + "tenant": &args.tenant, + "project": &args.project, + "node": &args.node, + "capabilities": NodeCapabilities::detect_current(), + "cached_environment_digests": [], + "dependency_cache_digests": [], + "source_snapshots": [], + "artifact_locations": [], + "direct_connectivity": true, + "online": true, + }))?; + + if args.worker { + return worker_loop( + &args, + &mut session, + registration, + heartbeat, + capability_report, + ); + } + + let task_assignment = session.request(json!({ + "type": "schedule_task", + "tenant": &args.tenant, + "project": &args.project, + "environment": null, + "environment_digest": null, + "required_capabilities": [Capability::Command], + "dependency_cache": null, + "source_snapshot": null, + "required_artifacts": [], + "quota_available": true, + "policy_allowed": true, + "prefer_node": &args.node, + }))?; + let runtime_task = RuntimeTask { + process: args.process.clone(), + task: args.task.clone(), + command: args.command.clone(), + command_args: args.command_args.clone(), + artifact: args.artifact.clone(), + epoch: None, + task_assignment_response: task_assignment, + }; + let report = run_runtime_task( + &args, + &mut session, + runtime_task, + registration, + heartbeat, + capability_report, + )?; + println!("{}", serde_json::to_string(&report)?); + Ok(()) +} + +fn worker_loop( + args: &Args, + session: &mut CoordinatorSession, + registration: Value, + heartbeat: Value, + capability_report: Value, +) -> Result<(), Box> { + if args.emit_ready { + println!( + "{}", + serde_json::to_string(&json!({ + "node_status": "ready", + "mode": "worker", + "node": &args.node, + }))? + ); + std::io::stdout().flush()?; + } + + loop { + let response = session.request(json!({ + "type": "poll_task_assignment", + "tenant": &args.tenant, + "project": &args.project, + "node": &args.node, + }))?; + let Some(assignment) = response.get("assignment").filter(|value| !value.is_null()) else { + std::thread::sleep(Duration::from_millis(args.assignment_poll_ms)); + continue; + }; + let runtime_task = runtime_task_from_assignment(assignment)?; + let report = run_runtime_task( + args, + session, + runtime_task, + registration.clone(), + heartbeat.clone(), + capability_report.clone(), + )?; + println!("{}", serde_json::to_string(&report)?); + std::io::stdout().flush()?; + } +} + +fn runtime_task_from_assignment(value: &Value) -> Result> { + Ok(RuntimeTask { + process: required_string(value, "process")?, + task: required_string(value, "task")?, + command: required_string(value, "command")?, + command_args: value + .get("command_args") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect::>() + }) + .unwrap_or_default(), + artifact: required_string(value, "artifact_path")?, + epoch: value.get("epoch").and_then(Value::as_u64), + task_assignment_response: value.clone(), + }) +} + +fn required_string(value: &Value, field: &str) -> Result> { + value + .get(field) + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| format!("task assignment missing string field `{field}`").into()) +} + +fn run_runtime_task( + args: &Args, + session: &mut CoordinatorSession, + task: RuntimeTask, + registration: Value, + heartbeat: Value, + capability_report: Value, +) -> Result> { + let epoch = match task.epoch { + Some(epoch) => epoch, + None => { + let started = session.request(json!({ + "type": "start_process", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + }))?; + started + .get("epoch") + .and_then(Value::as_u64) + .ok_or("coordinator start_process response missing epoch")? + } + }; + session.request(json!({ + "type": "reconnect_node", + "node": &args.node, + "process": &task.process, + "epoch": epoch, + }))?; + let debug_command = session.request(json!({ + "type": "poll_debug_command", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + }))?; + if args.emit_ready && !args.worker { + println!( + "{}", + serde_json::to_string(&json!({ + "node_status": "ready", + "node": &args.node, + "process": &task.process, + "task": &task.task, + }))? + ); + std::io::stdout().flush()?; + } + if args.control_poll_ms > 0 && wait_for_cancellation(session, args, &task)? { + let recorded = session.request(json!({ + "type": "task_completed", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "terminal_state": "cancelled", + "status_code": null, + "stdout_bytes": 0, + "stderr_bytes": 0, + "artifact_path": null, + "artifact_digest": null, + "artifact_size_bytes": null, + }))?; + return Ok(cancelled_node_report( + args, + &task, + registration, + heartbeat, + capability_report, + task.task_assignment_response.clone(), + debug_command, + recorded, + session.requests(), + )); + } + + let executor = LocalCommandExecutor { + node: NodeId::new(args.node.clone()), + hosted_control_plane: false, + has_command_capability: true, + }; + let task_id = TaskId::new(task.task.clone()); + let mut overlay = VfsOverlay::new(task_id.clone(), NodeId::new(args.node.clone())); + let output = executor.run( + VirtualThreadCommand { + virtual_thread: task_id, + invocation: CommandInvocation { + program: task.command.clone(), + args: task.command_args.clone(), + env: None, + }, + stage_stdout_as: Some(VfsPath::new(task.artifact.clone())?), + }, + &mut overlay, + )?; + let manifest = overlay.flush(); + let staged = output.staged_artifact.as_ref(); + let artifact_digest = staged.map(|artifact| artifact.digest.clone()); + let artifact_path = staged.map(|artifact| artifact.path.as_str().to_owned()); + let artifact_size_bytes = staged.map(|artifact| artifact.size); + + let log_event = session.request(json!({ + "type": "report_task_log", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "stdout_bytes": output.stdout.len(), + "stderr_bytes": output.stderr.len(), + "stdout_tail": &output.stdout, + "stderr_tail": &output.stderr, + "stdout_truncated": output.stdout_truncated, + "stderr_truncated": output.stderr_truncated, + "backpressured": output.log_backpressured, + }))?; + let vfs_metadata = session.request(json!({ + "type": "report_vfs_metadata", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "artifact_path": artifact_path, + "artifact_digest": artifact_digest, + "artifact_size_bytes": artifact_size_bytes, + "large_bytes_uploaded": manifest.large_bytes_uploaded, + }))?; + let recorded = session.request(json!({ + "type": "task_completed", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + "status_code": output.status_code, + "stdout_bytes": output.stdout.len(), + "stderr_bytes": output.stderr.len(), + "stdout_tail": &output.stdout, + "stderr_tail": &output.stderr, + "stdout_truncated": output.stdout_truncated, + "stderr_truncated": output.stderr_truncated, + "artifact_path": staged.map(|artifact| artifact.path.as_str().to_owned()), + "artifact_digest": staged.map(|artifact| artifact.digest.clone()), + "artifact_size_bytes": artifact_size_bytes, + }))?; + + Ok(node_report( + output, + manifest.large_bytes_uploaded, + registration, + heartbeat, + capability_report, + task.task_assignment_response, + debug_command, + log_event, + vfs_metadata, + recorded, + session.requests(), + )) +} + +fn node_report( + output: CommandOutput, + large_bytes_uploaded: bool, + registration_response: Value, + heartbeat_response: Value, + capability_response: Value, + task_assignment_response: Value, + debug_command_response: Value, + log_event_response: Value, + vfs_metadata_response: Value, + coordinator_response: Value, + session_requests: usize, +) -> Value { + json!({ + "node_status": "completed", + "virtual_thread": output.virtual_thread, + "terminal_state": if output.status_code == Some(0) { "completed" } else { "failed" }, + "status_code": output.status_code, + "stdout_bytes": output.stdout.len(), + "stderr_bytes": output.stderr.len(), + "stdout_tail": &output.stdout, + "stderr_tail": &output.stderr, + "stdout_truncated": output.stdout_truncated, + "stderr_truncated": output.stderr_truncated, + "log_backpressured": output.log_backpressured, + "staged_artifact": output.staged_artifact, + "large_bytes_uploaded": large_bytes_uploaded, + "registration_response": registration_response, + "heartbeat_response": heartbeat_response, + "capability_response": capability_response, + "task_assignment_response": task_assignment_response, + "debug_command_response": debug_command_response, + "log_event_response": log_event_response, + "vfs_metadata_response": vfs_metadata_response, + "session_requests": session_requests, + "coordinator_response": coordinator_response, + }) +} + +fn cancelled_node_report( + _args: &Args, + task: &RuntimeTask, + registration_response: Value, + heartbeat_response: Value, + capability_response: Value, + task_assignment_response: Value, + debug_command_response: Value, + coordinator_response: Value, + session_requests: usize, +) -> Value { + json!({ + "node_status": "cancelled", + "virtual_thread": &task.task, + "terminal_state": "cancelled", + "status_code": null, + "stdout_bytes": 0, + "stderr_bytes": 0, + "stdout_tail": "", + "stderr_tail": "", + "stdout_truncated": false, + "stderr_truncated": false, + "log_backpressured": false, + "staged_artifact": null, + "large_bytes_uploaded": false, + "registration_response": registration_response, + "heartbeat_response": heartbeat_response, + "capability_response": capability_response, + "task_assignment_response": task_assignment_response, + "debug_command_response": debug_command_response, + "log_event_response": null, + "vfs_metadata_response": null, + "session_requests": session_requests, + "coordinator_response": coordinator_response, + }) +} + +fn register_node( + session: &mut CoordinatorSession, + args: &Args, +) -> Result> { + let public_key = args + .public_key + .clone() + .unwrap_or_else(|| format!("{}-public-key", args.node)); + if let Some(grant) = &args.enrollment_grant { + session.request(json!({ + "type": "exchange_node_enrollment_grant", + "tenant": &args.tenant, + "project": &args.project, + "node": &args.node, + "public_key": public_key, + "enrollment_grant": grant, + "now_epoch_seconds": 0, + })) + } else { + session.request(json!({ + "type": "attach_node", + "tenant": &args.tenant, + "project": &args.project, + "node": &args.node, + "public_key": public_key, + })) + } +} + +fn wait_for_cancellation( + session: &mut CoordinatorSession, + args: &Args, + task: &RuntimeTask, +) -> Result> { + let deadline = Instant::now() + Duration::from_millis(args.control_poll_ms); + loop { + let control = session.request(json!({ + "type": "poll_task_control", + "tenant": &args.tenant, + "project": &args.project, + "process": &task.process, + "node": &args.node, + "task": &task.task, + }))?; + if control + .get("cancel_requested") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return Ok(true); + } + let now = Instant::now(); + if now >= deadline { + return Ok(false); + } + std::thread::sleep((deadline - now).min(Duration::from_millis(100))); + } +} + +struct CoordinatorSession { + writer: TcpStream, + reader: BufReader, + requests: usize, +} + +impl CoordinatorSession { + fn connect(addr: &str) -> Result> { + let transport_addr = json_line_transport_addr(addr); + let writer = TcpStream::connect(&transport_addr)?; + let reader = BufReader::new(writer.try_clone()?); + Ok(Self { + writer, + reader, + requests: 0, + }) + } + + fn request(&mut self, value: Value) -> Result> { + serde_json::to_writer(&mut self.writer, &value)?; + self.writer.write_all(b"\n")?; + self.writer.flush()?; + + let mut line = String::new(); + if self.reader.read_line(&mut line)? == 0 { + return Err("coordinator closed session without a response".into()); + } + let response: Value = serde_json::from_str(&line)?; + self.requests += 1; + if response.get("type").and_then(Value::as_str) == Some("error") { + return Err(format!("coordinator error: {response}").into()); + } + Ok(response) + } + + fn requests(&self) -> usize { + self.requests + } +} + +fn json_line_transport_addr(endpoint: &str) -> String { + let endpoint = endpoint.trim(); + for (scheme, default_port) in [("https://", 443), ("http://", 80)] { + if let Some(rest) = endpoint.strip_prefix(scheme) { + let authority = rest.split('/').next().unwrap_or(rest); + if authority.contains(':') { + return authority.to_owned(); + } + return format!("{authority}:{default_port}"); + } + } + endpoint.to_owned() +} + +fn parse_args() -> Result> { + let mut coordinator = None; + let mut tenant = "tenant".to_owned(); + let mut project = "project".to_owned(); + let mut node = "node".to_owned(); + let mut process = "process".to_owned(); + let mut task = "compile-linux".to_owned(); + let mut command = None; + let mut command_args = Vec::new(); + let mut artifact = "/vfs/artifacts/node-output.txt".to_owned(); + let mut enrollment_grant = None; + let mut public_key = None; + let mut control_poll_ms = 0; + let mut assignment_poll_ms = 500; + let mut emit_ready = false; + let mut worker = false; + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--coordinator" => coordinator = args.next(), + "--tenant" => tenant = args.next().ok_or("--tenant requires a value")?, + "--project-id" => project = args.next().ok_or("--project-id requires a value")?, + "--node" => node = args.next().ok_or("--node requires a value")?, + "--process" => process = args.next().ok_or("--process requires a value")?, + "--task" => task = args.next().ok_or("--task requires a value")?, + "--command" => command = args.next(), + "--arg" => command_args.push(args.next().ok_or("--arg requires a value")?), + "--artifact" => artifact = args.next().ok_or("--artifact requires a value")?, + "--enrollment-grant" => enrollment_grant = args.next(), + "--public-key" => public_key = args.next(), + "--control-poll-ms" => { + control_poll_ms = args + .next() + .ok_or("--control-poll-ms requires a value")? + .parse()? + } + "--assignment-poll-ms" => { + assignment_poll_ms = args + .next() + .ok_or("--assignment-poll-ms requires a value")? + .parse()? + } + "--emit-ready" => emit_ready = true, + "--worker" => worker = true, + "--project" => { + let project_path = PathBuf::from(args.next().ok_or("--project requires a path")?); + command_args.extend([ + "test".to_owned(), + "--manifest-path".to_owned(), + project_path + .join("Cargo.toml") + .to_string_lossy() + .into_owned(), + ]); + } + other => return Err(format!("unknown argument: {other}").into()), + } + } + + Ok(Args { + coordinator: coordinator.ok_or("--coordinator is required")?, + tenant, + project, + node, + process, + task, + command: command.unwrap_or_else(|| "cargo".to_owned()), + command_args, + artifact, + enrollment_grant, + public_key, + control_poll_ms, + assignment_poll_ms, + emit_ready, + worker, + }) +} + +#[cfg(test)] +mod tests { + use super::json_line_transport_addr; + + #[test] + fn hosted_operator_url_maps_to_json_line_transport_address() { + assert_eq!( + json_line_transport_addr("https://disasmer.michelpaulissen.com:9443"), + "disasmer.michelpaulissen.com:9443" + ); + assert_eq!( + json_line_transport_addr("https://disasmer.michelpaulissen.com:9443/auth/device"), + "disasmer.michelpaulissen.com:9443" + ); + assert_eq!( + json_line_transport_addr("http://operator.example.test"), + "operator.example.test:80" + ); + assert_eq!(json_line_transport_addr("127.0.0.1:7999"), "127.0.0.1:7999"); + } +} diff --git a/crates/disasmer-sdk/Cargo.toml b/crates/disasmer-sdk/Cargo.toml new file mode 100644 index 0000000..313bf5b --- /dev/null +++ b/crates/disasmer-sdk/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "disasmer-sdk" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "disasmer" + +[dependencies] +disasmer-macros = { path = "../disasmer-macros" } +serde.workspace = true +serde_json.workspace = true + +[dev-dependencies] +futures-executor.workspace = true diff --git a/crates/disasmer-sdk/src/lib.rs b/crates/disasmer-sdk/src/lib.rs new file mode 100644 index 0000000..4be996d --- /dev/null +++ b/crates/disasmer-sdk/src/lib.rs @@ -0,0 +1,521 @@ +use serde::{Deserialize, Serialize}; + +pub use disasmer_macros::{main, task}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EntrypointDescriptor { + pub name: &'static str, + pub function: &'static str, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TaskDescriptor { + pub name: &'static str, + pub function: &'static str, + pub remotely_startable: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RegisteredProgram { + pub entrypoints: &'static [EntrypointDescriptor], + pub tasks: &'static [TaskDescriptor], +} + +impl RegisteredProgram { + pub fn select_entrypoint(&self, name: &str) -> Option { + self.entrypoints + .iter() + .copied() + .find(|entrypoint| entrypoint.name == name) + } + + pub fn task(&self, name: &str) -> Option { + self.tasks.iter().copied().find(|task| task.name == name) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceSnapshot { + pub digest: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Blob { + pub digest: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Artifact { + pub id: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EnvRef { + pub name: &'static str, +} + +impl EnvRef { + pub const fn new_static(name: &'static str) -> Self { + Self { name } + } +} + +#[macro_export] +macro_rules! env { + ($name:literal) => { + $crate::EnvRef::new_static($name) + }; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TaskArgKind { + SmallSerialized, + Handle, +} + +pub trait TaskArg: Serialize { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::SmallSerialized + } +} + +macro_rules! small_task_arg { + ($($ty:ty),+ $(,)?) => { + $( + impl TaskArg for $ty {} + )+ + }; +} + +small_task_arg!( + (), + bool, + char, + String, + i8, + i16, + i32, + i64, + isize, + u8, + u16, + u32, + u64, + usize, + f32, + f64, +); + +impl TaskArg for SourceSnapshot { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Handle + } +} + +impl TaskArg for Blob { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Handle + } +} + +impl TaskArg for Artifact { + fn task_arg_kind(&self) -> TaskArgKind { + TaskArgKind::Handle + } +} + +impl TaskArg for Option where T: TaskArg {} + +impl TaskArg for Vec where T: TaskArg {} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct TaskArgBudget { + pub max_inline_bytes: usize, +} + +impl Default for TaskArgBudget { + fn default() -> Self { + Self { + max_inline_bytes: 64 * 1024, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TaskArgValidation { + pub inline_bytes: usize, + pub kind: TaskArgKind, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TaskArgError { + Serialization(String), + TooLarge { size: usize, limit: usize }, + HostOnly { type_name: &'static str }, +} + +impl std::fmt::Display for TaskArgError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Serialization(error) => write!(f, "task argument could not serialize: {error}"), + Self::TooLarge { size, limit } => write!( + f, + "task argument is {size} bytes; inline task arguments are limited to {limit} bytes, use SourceSnapshot, Blob, Artifact, or VFS handles" + ), + Self::HostOnly { type_name } => write!( + f, + "task boundary value `{type_name}` is host-only; use small serialized data or handles" + ), + } + } +} + +impl std::error::Error for TaskArgError {} + +pub fn validate_task_arg( + value: &T, + budget: TaskArgBudget, +) -> Result +where + T: TaskArg + ?Sized, +{ + let bytes = serde_json::to_vec(value) + .map_err(|error| TaskArgError::Serialization(error.to_string()))?; + let kind = value.task_arg_kind(); + if kind == TaskArgKind::SmallSerialized && bytes.len() > budget.max_inline_bytes { + return Err(TaskArgError::TooLarge { + size: bytes.len(), + limit: budget.max_inline_bytes, + }); + } + Ok(TaskArgValidation { + inline_bytes: bytes.len(), + kind, + }) +} + +pub fn reject_host_only_task_arg() -> TaskArgError { + TaskArgError::HostOnly { + type_name: std::any::type_name::(), + } +} + +pub mod spawn { + use std::sync::atomic::{AtomicU64, Ordering}; + use std::sync::{Mutex, OnceLock}; + + use crate::{validate_task_arg, EnvRef, TaskArg, TaskArgBudget, TaskArgError}; + + static NEXT_THREAD_ID: AtomicU64 = AtomicU64::new(1); + static RUNTIME_THREADS: OnceLock>> = OnceLock::new(); + + #[derive(Clone, Debug, PartialEq, Eq)] + pub struct RuntimeSpawnEvent { + pub virtual_thread_id: u64, + pub name: &'static str, + pub env: Option, + pub debugger_visible: bool, + } + + fn runtime_threads() -> &'static Mutex> { + RUNTIME_THREADS.get_or_init(|| Mutex::new(Vec::new())) + } + + fn register_runtime_thread( + virtual_thread_id: u64, + name: &'static str, + env: Option, + ) -> RuntimeSpawnEvent { + let event = RuntimeSpawnEvent { + virtual_thread_id, + name, + env, + debugger_visible: true, + }; + runtime_threads().lock().unwrap().push(event.clone()); + event + } + + pub fn drain_runtime_spawn_events() -> Vec { + runtime_threads().lock().unwrap().drain(..).collect() + } + + pub fn runtime_spawn_events() -> Vec { + runtime_threads().lock().unwrap().clone() + } + + pub fn task(entry: F) -> TaskBuilder + where + F: FnOnce() -> R, + R: TaskArg, + { + TaskBuilder { + entry: Some(entry), + env: None, + name: "task", + } + } + + pub fn task_with_arg(arg: A, entry: F) -> TaskWithArgBuilder + where + A: TaskArg, + F: FnOnce(A) -> R, + R: TaskArg, + { + TaskWithArgBuilder { + arg: Some(arg), + entry: Some(entry), + env: None, + name: "task", + arg_budget: TaskArgBudget::default(), + } + } + + pub struct TaskBuilder + where + F: FnOnce() -> R, + R: TaskArg, + { + entry: Option, + env: Option, + name: &'static str, + } + + impl TaskBuilder + where + F: FnOnce() -> R, + R: TaskArg, + { + pub fn env(mut self, env: EnvRef) -> Self { + self.env = Some(env); + self + } + + pub fn name(mut self, name: &'static str) -> Self { + self.name = name; + self + } + + pub async fn start(mut self) -> TaskHandle { + let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst); + let runtime_event = register_runtime_thread(id, self.name, self.env); + let entry = self.entry.take().expect("task entry used once"); + TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: Some(entry()), + } + } + } + + pub struct TaskWithArgBuilder + where + A: TaskArg, + F: FnOnce(A) -> R, + R: TaskArg, + { + arg: Option, + entry: Option, + env: Option, + name: &'static str, + arg_budget: TaskArgBudget, + } + + impl TaskWithArgBuilder + where + A: TaskArg, + F: FnOnce(A) -> R, + R: TaskArg, + { + pub fn env(mut self, env: EnvRef) -> Self { + self.env = Some(env); + self + } + + pub fn name(mut self, name: &'static str) -> Self { + self.name = name; + self + } + + pub fn arg_budget(mut self, arg_budget: TaskArgBudget) -> Self { + self.arg_budget = arg_budget; + self + } + + pub async fn start(mut self) -> Result, TaskArgError> { + let arg = self.arg.take().expect("task argument used once"); + validate_task_arg(&arg, self.arg_budget)?; + let id = NEXT_THREAD_ID.fetch_add(1, Ordering::SeqCst); + let runtime_event = register_runtime_thread(id, self.name, self.env); + let entry = self.entry.take().expect("task entry used once"); + Ok(TaskHandle { + virtual_thread_id: id, + name: self.name, + env: self.env, + debugger_visible: runtime_event.debugger_visible, + result: Some(entry(arg)), + }) + } + } + + pub struct TaskHandle { + virtual_thread_id: u64, + name: &'static str, + env: Option, + debugger_visible: bool, + result: Option, + } + + impl TaskHandle + where + R: TaskArg, + { + pub fn virtual_thread_id(&self) -> u64 { + self.virtual_thread_id + } + + pub fn name(&self) -> &'static str { + self.name + } + + pub fn env(&self) -> Option { + self.env + } + + pub fn debugger_visible(&self) -> bool { + self.debugger_visible + } + + pub async fn join(mut self) -> R { + self.result.take().expect("task joined once") + } + } +} + +#[cfg(test)] +mod tests { + use futures_executor::block_on; + + #[test] + fn env_macro_creates_logical_environment_reference() { + let env = crate::env!("linux"); + + assert_eq!(env.name, "linux"); + } + + #[test] + fn spawn_task_start_join_returns_small_result() { + let result = block_on(async { + let handle = crate::spawn::task(|| 42) + .name("compile linux") + .env(crate::env!("linux")) + .start() + .await; + assert_eq!(handle.name(), "compile linux"); + assert_eq!(handle.env().unwrap().name, "linux"); + assert!(handle.debugger_visible()); + handle.join().await + }); + + assert_eq!(result, 42); + } + + #[test] + fn spawn_task_start_registers_debugger_visible_runtime_thread() { + let handle = block_on(async { + crate::spawn::task(|| 7_u32) + .name("sdk-runtime-thread-test") + .env(crate::env!("linux")) + .start() + .await + }); + let events = crate::spawn::runtime_spawn_events(); + let event = events + .iter() + .find(|event| event.virtual_thread_id == handle.virtual_thread_id()) + .expect("spawn runtime event should exist for task handle"); + + assert!(handle.debugger_visible()); + assert!(event.debugger_visible); + assert_eq!(event.name, "sdk-runtime-thread-test"); + assert_eq!(event.env.unwrap().name, "linux"); + assert_eq!(block_on(handle.join()), 7); + } + + #[test] + fn task_arg_validation_allows_handles_and_rejects_oversized_inline_values() { + let artifact = crate::Artifact { + id: "artifact://build/app".to_owned(), + }; + let artifact_validation = crate::validate_task_arg( + &artifact, + crate::TaskArgBudget { + max_inline_bytes: 4, + }, + ) + .unwrap(); + assert_eq!(artifact_validation.kind, crate::TaskArgKind::Handle); + + let bytes = vec![1_u8, 2, 3, 4, 5]; + let error = crate::validate_task_arg( + &bytes, + crate::TaskArgBudget { + max_inline_bytes: 4, + }, + ) + .unwrap_err(); + assert!(matches!(error, crate::TaskArgError::TooLarge { .. })); + } + + #[test] + fn spawn_task_with_arg_rejects_large_inline_argument_before_dispatch() { + let dispatched = std::cell::Cell::new(false); + let result = block_on(async { + crate::spawn::task_with_arg(vec![1_u8, 2, 3, 4, 5], |_| { + dispatched.set(true); + 42_u32 + }) + .arg_budget(crate::TaskArgBudget { + max_inline_bytes: 4, + }) + .start() + .await + }); + + assert!(matches!(result, Err(crate::TaskArgError::TooLarge { .. }))); + assert!(!dispatched.get()); + } + + #[test] + fn spawn_task_with_arg_allows_runtime_handles_under_inline_budget() { + let artifact = crate::Artifact { + id: "artifact://build/app".to_owned(), + }; + let result = block_on(async { + let handle = crate::spawn::task_with_arg(artifact, |artifact| artifact.id) + .name("publish artifact") + .env(crate::env!("linux")) + .arg_budget(crate::TaskArgBudget { + max_inline_bytes: 4, + }) + .start() + .await + .unwrap(); + + assert_eq!(handle.name(), "publish artifact"); + handle.join().await + }); + + assert_eq!(result, "artifact://build/app"); + } + + #[test] + fn host_only_task_arg_error_names_rejected_type() { + let error = crate::reject_host_only_task_arg::<*const u8>(); + + assert!(error.to_string().contains("*const u8")); + assert!(error.to_string().contains("host-only")); + } +} diff --git a/crates/disasmer-sdk/tests/macros.rs b/crates/disasmer-sdk/tests/macros.rs new file mode 100644 index 0000000..0927179 --- /dev/null +++ b/crates/disasmer-sdk/tests/macros.rs @@ -0,0 +1,80 @@ +use futures_executor::block_on; + +#[disasmer::main] +fn build_main() -> u32 { + 1 +} + +#[disasmer::main(name = "release")] +fn release_main() -> u32 { + 2 +} + +#[disasmer::task(name = "compile-linux")] +fn compile_linux() -> u32 { + 41 +} + +#[disasmer::task] +fn package_release() -> disasmer::Artifact { + disasmer::Artifact { + id: "artifact://package/release.tar.zst".to_owned(), + } +} + +#[test] +fn disasmer_attributes_and_spawn_api_compile_for_rust_build_workflow() { + let program = disasmer::RegisteredProgram { + entrypoints: &[ + __DISASMER_ENTRYPOINT_BUILD_MAIN, + __DISASMER_ENTRYPOINT_RELEASE_MAIN, + ], + tasks: &[ + __DISASMER_TASK_COMPILE_LINUX, + __DISASMER_TASK_PACKAGE_RELEASE, + ], + }; + assert_eq!( + program.select_entrypoint("build").unwrap().function, + "build_main" + ); + assert_eq!( + program.select_entrypoint("release").unwrap().function, + "release_main" + ); + assert_eq!( + program.task("compile-linux").unwrap().function, + "compile_linux" + ); + assert!(program.task("compile-linux").unwrap().remotely_startable); + + let result = block_on(async { + let handle = disasmer::spawn::task(|| compile_linux() + build_main()) + .name("compile linux") + .env(disasmer::env!("linux")) + .start() + .await; + + assert!(handle.virtual_thread_id() > 0); + handle.join().await + }); + + assert_eq!(result, 42); + assert_eq!(release_main(), 2); +} + +#[test] +fn task_boundaries_accept_small_values_and_runtime_handles() { + let small = disasmer::validate_task_arg(&7_u32, disasmer::TaskArgBudget::default()).unwrap(); + assert_eq!(small.kind, disasmer::TaskArgKind::SmallSerialized); + + let artifact = package_release(); + let artifact = disasmer::validate_task_arg( + &artifact, + disasmer::TaskArgBudget { + max_inline_bytes: 4, + }, + ) + .unwrap(); + assert_eq!(artifact.kind, disasmer::TaskArgKind::Handle); +} diff --git a/disasmer_spec.md b/disasmer_spec.md new file mode 100644 index 0000000..7205dbe --- /dev/null +++ b/disasmer_spec.md @@ -0,0 +1,1437 @@ +# Disasmer Specification + +**Status:** draft design spec +**Name:** Disasmer, short for **distributed Wasm**. The name intentionally sounds a bit like Wasmer and a bit like “disaster.” +**Primary goal:** make distributed execution feel like normal multithreaded programming and normal source-level debugging. + +Disasmer is a distributed WebAssembly runtime that lets multiple real machines behave like one logical machine. A program runs as one **Virtual Process** with many **Virtual Threads**. Each virtual thread runs on exactly one real machine at a time, but different virtual threads, memory spaces, filesystems, and build tasks may live on different machines. + +The core promise: + +```text +Developers debug Disasmer programs like ordinary multithreaded programs. +The runtime handles the distributed reality underneath. +``` + +Disasmer is also intended to support a build-system use case: one build is a virtual process, and build steps are virtual threads/tasks that may run in containers or Nix-defined environments on different operating systems and architectures. + +--- + +## 1. Goals + +Disasmer should provide: + +```text +distributed Wasm execution +normal VS Code debugging +virtual threads across machines +distributed/sharded memory spaces +cluster-wide suspend/resume +per-thread virtual filesystems +container/Nix-backed execution environments +single-source-file distributed builds +explicit artifact publication +low-latency reliable control-plane coordination +``` + +The debugger should see: + +```text +one process +many threads +normal stacks +normal locals +normal source files +normal breakpoints +normal memory inspection +``` + +The optional Disasmer inspector may expose distributed internals, but the default debugging experience should remain normal. + +--- + +## 2. Non-goals for the MVP + +The MVP should not attempt: + +```text +transparent raw remote pointers +live hot-swapping arbitrary Wasm/Rust stack frames +checkpointing live sockets +checkpointing arbitrary external OS state +consensus on every memory access +eagerly copying every byte of cluster memory on each breakpoint +transparent survival of lost non-replicated nodes +full Kubernetes/CI-platform replacement semantics +``` + +The MVP should favor simple, explicit, debuggable semantics over maximum transparency. + +--- + +## 3. Core terminology + +```text +Cluster + A set of real (or virtual) machines participating in one Disasmer runtime. + +Coordinator + The control-plane entry point for the cluster. + Usually reachable through DNS, e.g. cluster.example.com. + +Node + A real machine participating in the cluster. + +Node Runtime + The Disasmer runtime process running on a node. + It hosts virtual threads/tasks, memory shards, filesystem data, debug services, + environment caches, and node-to-node communication. + +Virtual Process + The logical process seen by the program and debugger. + Internally, it may span many nodes. + +Virtual Thread + A debugger-visible logical execution unit. + For ordinary Disasmer programs, this is usually a Wasm execution context. + For build-system use, it may be backed by a containerized process, sandboxed task, + or platform-specific build command. + +Virtual Task + Synonym for Virtual Thread when the unit of work is task-like rather than + thread-like. The debugger still sees it as a thread. + +Memory Space + A logical addressable memory region. + It may be owned by any node and accessed remotely by other threads. + +Filesystem Overlay + A per-thread writable virtual filesystem layer. + +Environment Resource + A Containerfile, Dockerfile, Nix flake, or equivalent project resource that + defines where and how a virtual thread/task should run. + +Disasmer Bundle / Virtual Binary + The packaged executable artifact containing Wasm modules, manifests, debug + metadata, environment definitions, and selected input files. + +Checkpoint + A saved execution image containing enough state to resume a virtual thread. + +Debug Epoch + A globally coordinated stopped state used for debugging. +``` + +--- + +## 4. High-level architecture + +```text +VS Code / Debugger + -> DAP / LLDB / custom Disasmer debug adapter + -> Disasmer debug endpoint + -> Coordinator + -> Node Runtimes +``` + +Cluster connectivity: + +```text +new node starts + -> connects to coordinator through DNS + -> authenticates + -> receives membership/configuration + -> establishes peer links to other nodes +``` + +Node-to-node communication should prefer: + +```text +QUIC direct link + -> NAT punching where possible + -> relay fallback if direct connectivity fails +``` + +The coordinator is not a hypervisor. It is the **cluster control plane**. It manages membership, identity, placement, debug epochs, metadata, and failure decisions. The data plane should be mostly direct node-to-node traffic. + +--- + +## 5. Project layout and resource discovery + +Disasmer should make environment resources and input files first-class project resources. + +Example project: + +```text +my-project/ + src/ + main.rs + Cargo.toml + Cargo.lock + + envs/ + linux/Containerfile + windows/Containerfile + macos/flake.nix + macos/flake.lock + + inputs/ + config.json + schema.sql + test-data/ + example-a.txt + example-b.txt + + disasmer.toml # optional; used only for overrides/disambiguation +``` + +Default discovery rules: + +```text +envs//Containerfile + -> defines environment of kind container + +envs//Dockerfile + -> defines environment of kind container + +envs//flake.nix + -> defines environment of kind nix-flake + +inputs/** + -> included in the bundle's default VFS seed + +src/**, Cargo.toml, Cargo.lock + -> included for reproducible debugging/building when configured +``` + +The source code should be able to reference discovered environments by name: + +```rust +env!("linux") +env!("windows") +env!("macos") +``` + +Adding this file: + +```text +envs/linux/Containerfile +``` + +should make this possible in source: + +```rust +spawn::task(compile).env(env!("linux")) +``` + +No image name, host VM, registry, CI runner, or YAML platform configuration should be required in the source code. + +--- + +## 6. Environment resources + +An **Environment Resource** defines the execution environment for a virtual thread/task. + +Supported MVP environment kinds: + +```text +container + Built from Containerfile or Dockerfile. + Produces or references an OCI-compatible image or equivalent runtime image. + +nix-flake + Resolved from flake.nix and flake.lock. + Produces a reproducible command environment, dev shell, package closure, or + runtime root depending on project configuration. +``` + +Containerfiles and Nix flakes are not identical concepts. Disasmer treats both as ways to produce a normalized **Execution Environment**: + +```text +Execution Environment + name + kind + digest + platform constraints + runtime capabilities + build/resolution recipe + cached materialized form on capable nodes +``` + +Example normalized environments: + +```text +environment linux + kind: container + recipe: envs/linux/Containerfile + context: envs/linux + digest: sha256:... + requires: os=linux, arch=x86_64 + +environment macos + kind: nix-flake + recipe: envs/macos/flake.nix + lock: envs/macos/flake.lock + digest: sha256:... + requires: os=macos, arch=arm64 +``` + +Environment materialization should be cached on nodes: + +```text +environment digest -> local image / Nix store path / sandbox root +``` + +The virtual binary should not embed full container images by default. It should embed recipes, manifests, hashes, lock files, and selected context files. Full vendoring should be an explicit mode: + +```text +disasmer build --vendor-envs +``` + +Possible vendored data: + +```text +OCI layers +Nix closure +prebuilt environment archive +``` + +--- + +## 7. Disasmer bundle / virtual binary + +A Disasmer bundle is the packaged executable artifact. + +It contains: + +```text +Wasm module(s) +Wasm component metadata +source maps / DWARF / debug metadata +Disasmer manifest +environment resource manifests +selected environment recipe files +selected environment context files +selected VFS seed files +source snapshot when enabled +content hashes +capability declarations +``` + +Conceptual bundle layout: + +```text +app.dis + /modules/main.wasm + /debug/main.dwarf + /manifest/disasmer.json + /envs/linux/Containerfile + /envs/windows/Containerfile + /envs/macos/flake.nix + /envs/macos/flake.lock + /inputs/config.json + /inputs/schema.sql + /inputs/test-data/** + /source/src/main.rs + /source/Cargo.toml + /source/Cargo.lock +``` + +The bundle is content-addressed where possible. Every resource that affects execution should contribute to the bundle digest. + +The coordinator schedules bundles, not loose files. + +--- + +## 8. Execution model + +A Disasmer application runs as one **Virtual Process**. + +A virtual process contains: + +```text +virtual threads/tasks +memory spaces +filesystem overlays +runtime metadata +debug metadata +checkpoint metadata +environment bindings +``` + +Each virtual thread runs on exactly one node at a time: + +```text +thread T1 -> node A +thread T2 -> node B +thread T3 -> node C +``` + +A virtual thread may be implemented as: + +```text +Wasm execution context +containerized process +sandboxed native process +platform-specific build command +``` + +The debugger sees all of these as threads in one virtual process. + +Thread placement is based on environment requirements: + +```text +spawn task with env!("linux") + -> coordinator finds a node that can materialize/run environment linux + -> node runtime starts the task +``` + +The source code selects logical environments, not specific machines: + +```rust +env!("linux") // logical environment +``` + +Not: + +```rust +node("builder-17") // real machine, generally not wanted in portable source +``` + +--- + +## 9. Virtual filesystem model + +Each virtual thread/task receives a virtual filesystem. + +Default mount model: + +```text +/vfs/project + Read-only source/input snapshot from the bundle. + +/vfs/work + Per-thread writable copy-on-write overlay. + +/vfs/artifacts + Published artifact namespace. + +/vfs/shared + Optional shared namespace with explicit synchronization rules. +``` + +A thread can create and modify files in its writable overlay. + +File creation: + +```text +Thread T creates file F + -> T owns the writable version of F + -> bytes are physically stored on T's current node by default +``` + +Flush: + +```text +fs::flush() + -> closes the current write epoch + -> publishes filesystem changes + -> makes changes visible according to VFS consistency rules +``` + +Sync: + +```text +fs::sync() + -> makes selected published changes durable/replicated +``` + +Important distinction: + +```text +flush = visibility / synchronization point +sync = durability / replication point +``` + +Suspend implicitly performs: + +```text +flush() +checkpoint_thread() +optionally sync() +``` + +Git-like analogy: + +```text +working tree = thread-local filesystem overlay +commit = flush point +object store = chunk/blob storage +branch/head = latest visible version +``` + +Disasmer does not need to literally use Git. A content-addressed chunk store is a better internal primitive. + +--- + +## 10. Memory model + +Disasmer memory is divided into **Memory Spaces**. + +A memory space has: + +```text +stable virtual identity +address range or handle +current owner node +version/epoch metadata +optional cached pages +optional replicas +``` + +Allocation policy: + +```text +Memory allocated by a virtual thread is placed on that thread's current node by default. +``` + +Example: + +```text +thread T1 running on node A allocates memory M1 + -> M1 initially owned by node A + +thread T2 running on node B allocates memory M2 + -> M2 initially owned by node B +``` + +Remote access is allowed, but mediated by Disasmer. + +The MVP should not make ordinary raw pointers transparently remote. Instead, distributed state should use explicit Disasmer handles/types: + +```rust +DBox +DArc +DMutex +DVec +DAtomicU64 +``` + +Local state remains normal language state: + +```rust +Vec // local to one Wasm context/thread +DVec // distributed memory object + +std::sync::Mutex // local/node/thread concept +disasmer::sync::DMutex // distributed synchronization primitive +``` + +The debugger sees a coherent virtual memory model. Internally, Disasmer maps: + +```text +virtual memory reference + -> memory space + -> owner node + -> page/chunk + -> epoch +``` + +--- + +## 11. Synchronization and consensus + +The programming model should feel close to normal thread safety: + +```text +locks +atomics +happens-before +shared mutable memory +race conditions +linearizable operations +``` + +The distributed runtime still needs distributed-systems machinery underneath. + +Consensus/control-plane coordination is used for: + +```text +cluster membership +node identity +debug epochs +memory ownership metadata +thread placement metadata +checkpoint ownership +filesystem version metadata +failure decisions +``` + +Consensus should not be on the hot path for every load/store. + +Preferred split: + +```text +Control plane: + consensus-backed metadata + +Data plane: + direct QUIC/RPC between node runtimes + owner-based memory protocol + leases + fencing tokens + copy-on-write pages/chunks +``` + +Every sensitive operation should carry an epoch or fencing token: + +```text +remote_load(addr, epoch) +remote_store(addr, epoch) +transfer_memory_owner(space, node, epoch) +migrate_thread(thread, node, epoch) +debug_freeze(epoch) +resume(epoch) +``` + +--- + +## 12. Suspend / resume / checkpoint + +Disasmer supports suspending a virtual thread. + +User-facing operation: + +```rust +suspend().await? +``` + +Semantics: + +```text +1. Reach a safepoint. +2. Stop the virtual thread. +3. Flush the thread filesystem overlay. +4. Snapshot Wasm execution state. +5. Snapshot or reference stack/heap/memory pages. +6. Store checkpoint metadata. +7. Mark thread as PARKED. +``` + +Resume: + +```rust +thread.resume().await? +``` + +Semantics: + +```text +1. Select a target node. +2. Fetch checkpoint metadata. +3. Restore Wasm execution state. +4. Restore/mount filesystem overlay. +5. Reconnect memory spaces. +6. Continue execution from a safepoint. +``` + +The coordinator should store checkpoint metadata, not necessarily all bytes. + +Actual checkpoint data should live in: + +```text +node-local storage +replicated storage +content-addressed blob storage +object storage +peer-to-peer transferred chunks +``` + +This enables: + +```text +thread migration +hibernation +debug pause/resume +load balancing +manual relocation +fault recovery when state is replicated +``` + +--- + +## 13. Debugging model + +Debuggability is a primary design requirement. + +Recommended external interface: + +```text +VS Code + -> DAP + -> LLDB / lldb-dap or custom Disasmer adapter + -> Disasmer debug endpoint + -> Coordinator + -> Node Runtimes +``` + +Disasmer should present the cluster as one virtual debug target. + +When a breakpoint is hit: + +```text +1. A virtual thread hits a breakpoint or trap. +2. Coordinator creates a new Debug Epoch. +3. All node runtimes stop guest execution at safepoints. +4. Memory ownership/migration is frozen. +5. Thread states are collected. +6. Memory pages are pinned copy-on-write. +7. Filesystem overlay versions are pinned. +8. Debugger is told that all threads are stopped. +``` + +Debugger memory access should be served from a coherent snapshot. + +Collect eagerly: + +```text +thread states +stacks +locals +globals +memory maps +filesystem manifests +``` + +Collect lazily: + +```text +heap pages +large memory regions +filesystem chunks +container logs beyond recent tail +large artifact data +``` + +The debugger illusion: + +```text +Debugger thinks: + process P is stopped + +Reality: + cluster is stopped at Debug Epoch E +``` + +Optional Disasmer inspector: + +```text +thread -> node +thread -> environment +memory space -> owner node +file -> overlay version +artifact -> producer task +environment -> digest/cache state +remote calls +page migrations +lock wait graph +checkpoint state +consensus/control-plane state +``` + +The default debugger view should remain normal. + +--- + +## 14. VS Code UX + +Disasmer should provide a VS Code extension. + +The extension should: + +```text +watch envs/**/Containerfile +watch envs/**/Dockerfile +watch envs/**/flake.nix +watch envs/**/flake.lock +watch inputs/** +watch src/** + +run disasmer build before debugging +start or attach to a virtual process +bind breakpoints to Wasm/source debug info +show virtual threads in the normal debugger thread list +show stdout/stderr per virtual thread/task +show VFS/artifact state in an optional panel +show environment resources as first-class project items +provide autocomplete/diagnostics for env!("...") references +``` + +The intended UX: + +```text +1. Add envs/linux/Containerfile. +2. VS Code detects environment "linux". +3. Source autocomplete now accepts env!("linux"). +4. Press F5. +5. Disasmer builds the virtual binary. +6. Disasmer starts the virtual process. +7. VS Code attaches to it as one normal debug target. +8. Breakpoints work across virtual threads/tasks. +``` + +The extension may generate editor metadata: + +```text +target/disasmer/generated/envs.rs +target/disasmer/generated/resources.json +``` + +But this generated metadata should not be hand-edited. + +The project should not require YAML for ordinary usage. + +Optional `disasmer.toml` may exist for overrides: + +```toml +[envs.linux] +path = "envs/linux/Containerfile" +requires = { os = "linux", arch = "x86_64" } + +[vfs] +include = ["inputs/**", "src/**", "Cargo.toml", "Cargo.lock"] +exclude = ["target/**", ".git/**"] +``` + +--- + +## 15. Editing source while debugging + +Disasmer should support a fast edit/restart/debug loop. + +MVP behavior: + +```text +source edit + -> rebuild bundle + -> restart selected virtual task/thread from a clean boundary + -> reattach/rebind breakpoints +``` + +The MVP should not hot-swap arbitrary live code into existing stack frames. + +Supported restart boundaries: + +```text +task entrypoint +last explicit task checkpoint +last published filesystem state +last clean build input snapshot +``` + +Useful VS Code commands: + +```text +Disasmer: Restart Selected Virtual Thread +Disasmer: Restart Failed Task +Disasmer: Restart From Last Flush +Disasmer: Restart Whole Virtual Process +Disasmer: Rebuild Bundle And Rebind Breakpoints +``` + +Semantics for restarting a selected task: + +```text +1. Save source changes. +2. Rebuild the Disasmer bundle. +3. Stop the selected virtual thread/task. +4. Discard unflushed task-local filesystem changes unless configured otherwise. +5. Start the task again using the new bundle version. +6. Mount the same declared environment. +7. Mount the same input/VFS seed or chosen checkpoint boundary. +8. Rebind breakpoints using new debug metadata. +``` + +The runtime may keep the rest of the virtual process alive only if compatibility checks pass: + +```text +same public task signatures +same serialized argument schema +compatible resource manifest +compatible memory/handle ABI +``` + +If compatibility checks fail, the extension should restart the whole virtual process. + +Design rule: + +```text +Task restart is an MVP feature. +Live code hot-swap is not. +``` + +--- + +## 16. Rust as the primary source language + +Rust should be the primary guest/source language for Disasmer. + +Reasons: + +```text +explicit async boundaries map well to suspend/checkpoint +strong types map well to distributed handles +macros can register tasks and environments +source-level debugging is good +one source file can express real build logic +``` + +C/C++ should be supported later or in parallel as lower-level guest languages, but Rust should define the first-class ergonomic API. + +Design rule: + +```text +Local things use normal Rust. +Distributed things use Disasmer types. +``` + +Examples: + +```rust +Vec // local +DVec // distributed + +std::sync::Mutex // local +disasmer::sync::DMutex // distributed + +std::fs // local/WASI-style filesystem, where supported +disasmer::fs // virtual filesystem with flush/sync semantics +``` + +--- + +## 17. Rust API shape + +The MVP Rust API should be explicit. + +Example concepts: + +```rust +#[disasmer::main] +async fn main() -> Result<()> { ... } + +#[disasmer::task] +async fn compile(target: Target) -> Result { ... } + +spawn::task(compile) + .arg(Target::LinuxX64) + .env(env!("linux")) + .start() + .await?; +``` + +The `env!("linux")` macro validates at build time that an environment resource named `linux` exists. + +The `#[disasmer::task]` macro registers a task entrypoint so it can be started remotely by function identity plus serialized arguments. + +Task arguments and results should be serializable: + +```text +arguments -> serialized into task start message +results -> serialized into join result +handles -> serialized as Disasmer object references +``` + +--- + +## 18. Rust example: multiplatform build in one source file + +Project layout: + +```text +example-build/ + src/main.rs + Cargo.toml + Cargo.lock + + envs/ + linux/Containerfile + windows/Containerfile + macos/flake.nix + macos/flake.lock + + inputs/ + build-config.json +``` + +Source file: + +```rust +use disasmer::prelude::*; + +#[derive(Serialize, Deserialize, Clone, Debug)] +enum Target { + LinuxX64, + WindowsX64, + MacosArm64, +} + +impl Target { + fn name(&self) -> &'static str { + match self { + Target::LinuxX64 => "linux-x64", + Target::WindowsX64 => "windows-x64", + Target::MacosArm64 => "macos-arm64", + } + } + + fn triple(&self) -> &'static str { + match self { + Target::LinuxX64 => "x86_64-unknown-linux-gnu", + Target::WindowsX64 => "x86_64-pc-windows-msvc", + Target::MacosArm64 => "aarch64-apple-darwin", + } + } + + fn output_name(&self) -> &'static str { + match self { + Target::WindowsX64 => "app.exe", + _ => "app", + } + } +} + +#[disasmer::main] +async fn main() -> Result<()> { + let completed = DAtomicU32::new(0).await?; + + let linux = spawn::task(compile) + .arg(Target::LinuxX64) + .arg(completed.clone()) + .env(env!("linux")) + .start() + .await?; + + let windows = spawn::task(compile) + .arg(Target::WindowsX64) + .arg(completed.clone()) + .env(env!("windows")) + .start() + .await?; + + let macos = spawn::task(compile) + .arg(Target::MacosArm64) + .arg(completed.clone()) + .env(env!("macos")) + .start() + .await?; + + let linux_artifact = linux.join().await?; + let windows_artifact = windows.join().await?; + let macos_artifact = macos.join().await?; + + package(vec![linux_artifact, windows_artifact, macos_artifact]).await?; + + assert_eq!(completed.load(Ordering::SeqCst).await?, 3); + + fs::sync().await?; + Ok(()) +} + +#[disasmer::task] +async fn compile(target: Target, completed: DAtomicU32) -> Result { + let target_name = target.name(); + let triple = target.triple(); + let output_name = target.output_name(); + + cmd!("cargo", "build", "--release", "--target", triple) + .cwd("/vfs/project") + .run() + .await?; + + let built_file = format!( + "/vfs/project/target/{triple}/release/{output_name}" + ); + + let artifact_file = format!( + "/vfs/artifacts/{target_name}/{output_name}" + ); + + fs::copy(&built_file, &artifact_file).await?; + + // Publish this task's filesystem changes so other tasks can see the artifact. + fs::flush().await?; + + completed.fetch_add(1, Ordering::SeqCst).await?; + + Ok(Artifact::from_path(artifact_file).await?) +} + +#[disasmer::task] +async fn package(outputs: Vec) -> Result<()> { + fs::create_dir_all("/vfs/artifacts/dist").await?; + + for output in outputs { + fs::copy(output.path(), "/vfs/artifacts/dist/").await?; + } + + fs::flush().await?; + Ok(()) +} +``` + +--- + +## 19. What the Rust example does + +The example defines a complete multiplatform build as one Rust program. + +It does this: + +```text +1. Starts one Disasmer Virtual Process. +2. Creates one distributed atomic counter. +3. Spawns three virtual tasks: + - Linux x64 build task + - Windows x64 build task + - macOS ARM64 build task +4. Places each task into an environment discovered from envs//. +5. Runs each build command in that environment. +6. Copies each produced binary into /vfs/artifacts. +7. Calls fs::flush() to publish each task's artifact. +8. Joins all tasks. +9. Packages all artifacts into /vfs/artifacts/dist. +10. Calls fs::sync() to make final outputs durable. +``` + +Runtime behavior: + +```text +env!("linux") + -> resolved from envs/linux/Containerfile + +env!("windows") + -> resolved from envs/windows/Containerfile + +env!("macos") + -> resolved from envs/macos/flake.nix + flake.lock + +spawn compile Linux + -> coordinator finds a node capable of running the linux environment + -> node runtime materializes/caches the environment + -> task runs with its own VFS overlay + +spawn compile Windows + -> coordinator finds a node capable of running the windows environment + -> task runs separately but in the same virtual process + +spawn compile macOS + -> coordinator finds a node capable of running the macos environment + -> task runs separately but in the same virtual process +``` + +Debugger behavior: + +```text +VS Code sees one process: + Thread 1: main + Thread 2: compile LinuxX64 + Thread 3: compile WindowsX64 + Thread 4: compile MacosArm64 + Thread 5: package +``` + +The user can set breakpoints in: + +```text +main() +compile() +package() +``` + +When a breakpoint hits, Disasmer enters a Debug Epoch and stops the whole virtual process. + +--- + +## 20. What the Rust example proves + +The example proves the core Disasmer thesis: + +```text +A distributed, multiplatform build can be expressed as normal source code, +run across different machines/environments, +and debugged as one normal multithreaded program. +``` + +It proves these specific capabilities: + +```text +one source file can define the build graph +Containerfiles/Nix flakes can be project resources, not CI YAML +source code can reference environments by logical name +virtual tasks can run on different operating systems/architectures +artifacts can be published through flush() +final outputs can be made durable through sync() +shared distributed state can use explicit Disasmer types +the debugger can show all tasks as threads of one process +``` + +It also demonstrates the intended mental model: + +```text +Build = Virtual Process +Build Step = Virtual Thread / Virtual Task +Containerfile/Nix flake = Environment Resource +Build output = VFS artifact +flush() = publish/synchronization point +sync() = durability point +``` + +--- + +## 21. What this makes possible + +Disasmer makes these workflows possible: + +```text +multiplatform builds without proprietary CI YAML +source-level debugging of build logic +breakpoints inside distributed build orchestration +restart one failed build task after editing source +inspect all build tasks in one debugger session +share artifacts through a versioned virtual filesystem +run Linux/Windows/macOS tasks from one source file +mix Wasm orchestration with containerized native commands +make distributed execution feel like threaded programming +``` + +Example VS Code loop: + +```text +1. Press F5. +2. Build starts as one virtual process. +3. Windows task fails. +4. Breakpoint/trap stops the virtual process. +5. User edits src/main.rs or project source. +6. User runs "Disasmer: Restart Failed Task". +7. Disasmer rebuilds the bundle. +8. Only the failed task restarts if compatible. +9. Breakpoints are rebound. +10. User continues debugging. +``` + +This is intentionally different from typical CI systems: + +```text +not a pile of YAML +not tied to one proprietary platform +not opaque remote job logs only +not separate debuggers per machine +``` + +--- + +## 22. C/C++ support + +C/C++ should be possible but should not define the primary user experience. + +C-style API shape: + +```c +dis_task_t linux = dis_spawn_env("linux", compile_task, &args, sizeof(args)); +dis_join(linux); +dis_fs_flush(); +dis_fs_sync(); +``` + +Distributed memory in C should use handles: + +```c +dis_ref_t buffer = dis_mem_alloc(1024); +dis_mem_read(buffer, 0, tmp, sizeof(tmp)); +dis_mem_write(buffer, 0, data, len); +``` + +The MVP should not make raw C pointers transparently remote. + +Rust remains the preferred source language because it gives better type safety, async ergonomics, task registration, and IDE UX. + +--- + +## 23. Failure semantics + +MVP failure behavior should be simple. + +If a node with non-replicated guest state is lost: + +```text +the virtual process is considered crashed +``` + +If a task is checkpointed and its checkpoint data is durable: + +```text +the task may be resumed elsewhere +``` + +If a task has only unflushed local filesystem changes and its node is lost: + +```text +those unflushed changes are lost +``` + +If artifacts have been flushed but not synced: + +```text +they may be visible but not durable +``` + +If artifacts have been synced: + +```text +they should survive according to the configured replication policy +``` + +Do not pretend missing non-replicated state can be debugged. + +--- + +## 24. Security and identity + +Disasmer should treat cluster identity and bundle identity as core primitives. + +MVP requirements: + +```text +nodes authenticate to the coordinator +coordinator authorizes nodes for environments/capabilities +bundles are content-addressed +bundle manifests are signed or integrity-checked +environment resources are resolved by digest +tasks run with explicit capabilities +filesystem access is scoped to the virtual filesystem unless granted +network access is explicit per environment/task +``` + +The source should not casually select real machines by name. It should select logical environments and capabilities. + +--- + +## 25. MVP feature set + +The MVP should include: + +```text +Rust SDK +#[disasmer::main] +#[disasmer::task] +env!("name") environment references +auto-discovery of envs//Containerfile +auto-discovery of envs//Dockerfile +auto-discovery of envs//flake.nix +bundle manifest generation +selected input file bundling +per-task VFS overlay +fs::flush() +fs::sync() +spawn task into environment +join task +basic distributed handles +all-stop debugging +Debug Epochs +lazy memory/filesystem snapshotting +VS Code launch/debug extension +restart selected task from clean boundary after rebuild +QUIC node-to-node transport +coordinator-based node discovery +``` + +MVP restrictions: + +```text +all-stop debugging only +cooperative safepoints only +task restart, not live hot-swap +explicit distributed types only +no transparent raw remote pointers +no checkpointing live sockets +no automatic survival of non-replicated node loss +no eager full-cluster memory copy by default +no global consensus on every data operation +``` + +--- + +## 26. Later extensions + +Possible later extensions: + +```text +non-stop debugging +live code replacement for compatible functions +state-schema-aware checkpoint migration +replicated memory spaces +recoverable distributed locks +watchpoints across distributed memory +record/replay debugging +artifact cache sharing across clusters +remote execution marketplace +Kubernetes backend +cloud worker backend +local laptop cluster mode +full offline vendored bundles +language SDKs for C/C++, Go, Zig, Python/Wasm +``` + +These are not MVP requirements. + +--- + +## 27. Design summary + +Disasmer is: + +```text +a distributed Wasm runtime +that presents a cluster as one virtual process +with virtual threads/tasks, sharded memory, +per-thread virtual filesystems, checkpoint/suspend/resume, +container/Nix-backed execution environments, +and normal debugger support. +``` + +For build systems, Disasmer means: + +```text +Build = Virtual Process +Build Step = Virtual Thread / Virtual Task +Environment = Containerfile / Dockerfile / Nix flake resource +Artifact = VFS-published file +Debugger = normal VS Code session +``` + +The core UX promise: + +```text +Add a Containerfile or flake.nix to the project. +Reference it from Rust with env!("name"). +Press F5. +Debug the distributed build/program like one normal process. +``` + +The internal truth: + +```text +normal debugger illusion + backed by +cluster-wide debug epochs +copy-on-write snapshots +node runtimes +QUIC peer links +distributed memory spaces +filesystem overlays +checkpoint manifests +environment caches +and a consensus-backed control plane +``` diff --git a/examples/launch-build-demo/Cargo.toml b/examples/launch-build-demo/Cargo.toml new file mode 100644 index 0000000..5cdd7dd --- /dev/null +++ b/examples/launch-build-demo/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "launch-build-demo" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +path = "src/build.rs" +crate-type = ["rlib", "cdylib"] + +[dependencies] +disasmer = { package = "disasmer-sdk", path = "../../crates/disasmer-sdk" } +serde.workspace = true + +[dev-dependencies] +futures-executor.workspace = true diff --git a/examples/launch-build-demo/README.md b/examples/launch-build-demo/README.md new file mode 100644 index 0000000..499e611 --- /dev/null +++ b/examples/launch-build-demo/README.md @@ -0,0 +1,11 @@ +# Launch Build Demo + +This demo is the MVP build workflow expressed as Rust source code. It uses `env!("linux")`, recognizes `env!("windows")` when a Windows development node is attached, spawns debugger-visible virtual tasks, and returns artifact/source handles instead of moving large bytes through task arguments. + +The Linux environment is defined by `envs/linux/Containerfile`. The Windows environment is a user-attached development contract and does not claim secure managed Windows sandboxing. + +Inspect the bundle metadata: + +```bash +disasmer bundle inspect --project examples/launch-build-demo +``` diff --git a/examples/launch-build-demo/envs/linux/Containerfile b/examples/launch-build-demo/envs/linux/Containerfile new file mode 100644 index 0000000..ba87ba3 --- /dev/null +++ b/examples/launch-build-demo/envs/linux/Containerfile @@ -0,0 +1,3 @@ +FROM docker.io/library/alpine:3.20 +RUN apk add --no-cache build-base tar zstd +WORKDIR /workspace diff --git a/examples/launch-build-demo/envs/windows/Dockerfile b/examples/launch-build-demo/envs/windows/Dockerfile new file mode 100644 index 0000000..e3cf468 --- /dev/null +++ b/examples/launch-build-demo/envs/windows/Dockerfile @@ -0,0 +1,2 @@ +# User-attached Windows development execution contract for the MVP. +# This is not a managed untrusted Windows sandbox. diff --git a/examples/launch-build-demo/src/build.rs b/examples/launch-build-demo/src/build.rs new file mode 100644 index 0000000..e49ee9f --- /dev/null +++ b/examples/launch-build-demo/src/build.rs @@ -0,0 +1,97 @@ +use disasmer::{Artifact, EnvRef, SourceSnapshot}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BuildReport { + pub linux_thread: u64, + pub package_thread: u64, + pub linux_artifact: Artifact, + pub package_artifact: Artifact, + pub source: SourceSnapshot, +} + +pub fn linux_env() -> EnvRef { + disasmer::env!("linux") +} + +pub fn windows_env() -> EnvRef { + disasmer::env!("windows") +} + +#[disasmer::task] +pub fn compile_linux() -> Artifact { + Artifact { + id: "artifact://linux/app.tar.zst".to_owned(), + } +} + +#[disasmer::task] +#[unsafe(no_mangle)] +pub extern "C" fn task_add_one(input: i32) -> i32 { + input + 1 +} + +#[disasmer::task] +pub fn package_release() -> Artifact { + Artifact { + id: "artifact://package/release.tar.zst".to_owned(), + } +} + +#[disasmer::main] +pub fn build_main() -> &'static str { + "launch-build-demo" +} + +pub async fn run_build_workflow() -> BuildReport { + let linux = disasmer::spawn::task(compile_linux) + .name("compile linux") + .env(linux_env()) + .start() + .await; + let package = disasmer::spawn::task(package_release) + .name("package artifacts") + .env(linux_env()) + .start() + .await; + + let linux_thread = linux.virtual_thread_id(); + let package_thread = package.virtual_thread_id(); + let linux_artifact = linux.join().await; + let package_artifact = package.join().await; + + BuildReport { + linux_thread, + package_thread, + linux_artifact, + package_artifact, + source: SourceSnapshot { + digest: "source://local-checkout".to_owned(), + }, + } +} + +#[cfg(test)] +mod tests { + use futures_executor::block_on; + + use super::*; + + #[test] + fn flagship_workflow_is_rust_source_with_spawned_virtual_tasks() { + assert_eq!(build_main(), "launch-build-demo"); + assert_eq!(task_add_one(41), 42); + assert_eq!(linux_env().name, "linux"); + assert_eq!(windows_env().name, "windows"); + + let report = block_on(run_build_workflow()); + + assert_ne!(report.linux_thread, report.package_thread); + assert_eq!(report.linux_artifact.id, "artifact://linux/app.tar.zst"); + assert_eq!( + report.package_artifact.id, + "artifact://package/release.tar.zst" + ); + assert_eq!(report.source.digest, "source://local-checkout"); + } +} diff --git a/latency_notes.md b/latency_notes.md new file mode 100644 index 0000000..a67c0a7 --- /dev/null +++ b/latency_notes.md @@ -0,0 +1,145 @@ +# Disasmer Latency Notes + +Low latency is a core design goal. Disasmer should avoid copying or sending data unless it is required. + +## Core Rule + +```text +Move metadata eagerly. +Move bytes lazily. +Run work where the bytes already are. +``` + +The coordinator should track locations, hashes, ownership, and versions. It should not sit in the middle of large file transfers. + +## Local-First Execution + +For a simple build, such as building a Linux binary from a Git repo that already exists locally, Disasmer should behave almost like a local build: + +```text +/src -> bind mount existing checkout +/work -> local writable scratch space +/cache -> local persistent dependency cache +/out -> local artifact/output directory +``` + +No repo upload, no tarballing, no coordinator-routed file transfer, and no automatic artifact upload. + +## Placement Policy + +Task placement should prioritize data locality: + +```text +prefer node with source checkout +prefer node with container/Nix environment cached +prefer node with dependency/build cache +prefer node with previous outputs needed by this task +avoid expected network transfer +``` + +If the local machine can run the task and already has the repo, it should usually win placement. + +## VFS Semantics + +The virtual filesystem should be copy-on-write and metadata-driven. + +```text +flush() + Publishes filesystem metadata and artifact references. + Makes outputs visible to Disasmer. + Does not necessarily upload bytes. + +sync() + Makes selected outputs durable or replicated. + May upload or transfer bytes. +``` + +This distinction is important: `flush()` should be cheap; `sync()` may be expensive. + +## Git Inputs + +A Git repo should be treated as a source provider, not as a directory to blindly copy. + +A source snapshot is: + +```text +commit hash +submodule state +dirty-file overlay +included input files +ignored-file policy +``` + +If the task runs locally, `/src` can be a direct bind mount. If it runs remotely, only missing or changed content should be sent. + +## Artifacts + +Publishing a large output should register metadata first: + +```text +artifact id +path +node +size +digest/version +``` + +The actual bytes move only when needed: + +```text +another node consumes the artifact +user exports/downloads it +sync() requires durability +replication policy requires it +``` + +Same-node reuse should use bind mounts, hardlinks, reflinks, or copy-on-write where possible. + +## Environments + +Containerfiles, Dockerfiles, and Nix flakes should be part of the Disasmer bundle as environment definitions. + +Resolved container layers, built images, Nix store paths, and dependency caches should live in node-local caches and be reused by digest. + +## Coordinator Responsibility + +The coordinator should answer questions like: + +```text +which node has source snapshot X? +which node has artifact Y? +which node has environment digest Z cached? +where should this task run? +``` + +It should not handle normal compiler file reads, linker writes, cache reads, or large artifact transfers. + +## MVP Defaults + +Disasmer should default to: + +```text +local-first task placement +lazy byte transfer +metadata-only flush +explicit sync for durability +node-to-node bulk transfer +content-addressed storage +copy-on-write VFS overlays +no coordinator-routed bulk data +``` + +## Summary + +For low latency, Disasmer should make the common local case stay local. + +A local Linux build from a local Git repo should add only small runtime overhead: + +```text +placement decision +debug/session metadata +VFS epoch metadata +artifact registration +``` + +It should not add unnecessary source copying, artifact uploading, image rebuilding, or network filesystem traffic. diff --git a/market_plan.md b/market_plan.md new file mode 100644 index 0000000..be820dc --- /dev/null +++ b/market_plan.md @@ -0,0 +1,661 @@ +# Disasmer Market Plan and Abuse Notes + +## Positioning + +Disasmer is an open-source distributed Wasm runtime. +Its initial primary use case is as a build system. + +The product promise: + +```text +real source file +normal debugging +local-first execution +distributed builds when needed +operator UI instead of CI YAML +self-hosted nodes when users want control +managed coordination when users want convenience +``` + +The hosted service is primarily the **control plane**: + +```text +identity +coordinator state +virtual process registry +node discovery +debug sessions +operator panels +artifact metadata +optional managed nodes later +``` + +The coordinator should not be a free general-purpose compute platform. + +--- + +## Open-source strategy + +Disasmer should be open source by default: + +```text +runtime +SDK +CLI +VS Code extension +node runtime +local coordinator +protocol specs +example build programs +``` + +The hosted product should monetize convenience, scale, reliability, and managed infrastructure rather than hiding the core runtime. + +Recommended split: + +```text +Open source: + run Disasmer locally + run your own coordinator + attach your own nodes + debug programs + use the build-system features + +Hosted service: + managed coordinator + identity/login + public endpoint + node rendezvous + team management + hosted operator panels + artifact metadata/storage + managed build nodes later + uptime/support/compliance later +``` + +--- + +## Community hosted tier + +Community tier users get: + +```text +1 active virtual process +zero-capability hosted Wasm control loop +tiny memory/state limit +tiny CPU/fuel limit +tiny log limit +tiny artifact/state retention +operator panel access +ability to attach their own node(s) so these limits don't matter +``` + +The hosted free Wasm process has **zero capabilities**: + +```text +no network +no host filesystem +no secrets +no containers +no native commands +no outbound HTTP +no inbound ports +no arbitrary syscalls +no access to other users +``` + +It exists only to let users try the system and orchestrate their own attached nodes. + +Real work in the community tier should run on user-provided nodes. + +--- + +## Paid products + +Likely paid tiers: + +```text +Pro coordinator: + more virtual processes + more state/log retention + private projects + longer-running processes + more operator panels + artifact storage + +Team coordinator: + shared nodes + roles/permissions + audit logs + team secrets + organization billing + +Managed nodes: + Linux builders + Windows builders + macOS builders if feasible + GPU/specialized nodes later + warm caches + reserved capacity + +Enterprise: + private coordinator deployment + SSO/SAML/OIDC + compliance controls + support/SLA + custom retention + on-prem/hybrid support + +Marketplace later: + resold node capacity + verified builder pools + specialized environments +``` + +Primary revenue should come from: + +```text +managed coordinators +managed nodes +team features +artifact/log retention +support +enterprise deployments +``` + +Avoid depending on community tier compute economics. + +--- + +## Main abuse principle + +Assume every public input is hostile: + +```text +users +programs +nodes +operator panels +logs +artifacts +OAuth identities +build files +container images +Git repos +``` + +The coordinator must coordinate untrusted systems without trusting them. + +--- + +## Abuse paths to derisk + +### 1. Community tier compute abuse + +Risks: + +```text +crypto mining +brute force jobs +scraping loops +infinite loops +account farming to multiply community tier resources +``` + +Controls: + +```text +fuel metering +wall-clock limits +memory limits +wake-up limits +per-account quotas +per-IP signup/rate limits +no hosted containers on community tier +``` + +--- + +### 2. Coordinator denial of service + +Risks: + +```text +too many API calls +too many virtual process events +too many logs +too many UI updates +too many node heartbeats +too many task spawn attempts +huge metadata objects +expensive queries +``` + +Controls: + +```text +quota every API +rate-limit event streams +cap log size +cap UI update frequency +cap metadata size +cap process lifetime +use backpressure everywhere +admin kill switch +``` + +OWASP API Security lists unrestricted resource consumption as a major API risk, including CPU, memory, storage, bandwidth, and paid provider resources. + +--- + +### 3. Network abuse + +Risks: + +```text +port scanning +DDoS coordination +spam/proxy behavior +credential stuffing +webhook abuse +using Disasmer as rendezvous/C2 infrastructure +``` + +Controls: + +```text +free hosted tasks have no network +node traffic requires authenticated sessions +rate-limit rendezvous APIs +no public inbound ports by default +restrict relays +monitor suspicious fan-out +block known-abusive behavior +``` + +--- + +### 4. Artifact and log abuse + +Risks: + +```text +malware hosting +phishing pages +illegal content +oversized binaries +secret leakage in logs +using logs as data exfiltration +using artifacts as free storage/CDN +``` + +Controls: + +```text +tiny free retention +size limits +download limits +content reporting/removal +malware scanning where practical +secret redaction tools +private-by-default artifacts +no public hosting by default +``` + +--- + +### 5. Secret theft + +Risks: + +```text +malicious build scripts stealing tokens +operator panel phishing +exfiltration through logs/artifacts +self-hosted node compromise +repo credentials leaking into tasks +``` + +Controls: + +```text +no secrets in free hosted tasks +scoped short-lived task tokens +explicit secret grants +secrets never shown in UI/logs +audit access to secrets +separate coordinator identity from node identity +``` + +--- + +### 6. OAuth and account abuse + +Risks: + +```text +fake account farms +stolen OAuth sessions +bad redirect URI handling +token leakage +OAuth app phishing +provider token overreach +``` + +Controls: + +```text +OIDC/OAuth with PKCE +strict redirect URI matching +verified email where possible +short-lived sessions +minimal scopes +anti-abuse signup checks +manual suspension tools +``` + +OAuth security best current practice is covered by RFC 9700. + +--- + +### 7. Malicious user-provided nodes + +Risks: + +```text +lying about results +faking logs +returning malicious artifacts +claiming cache hits incorrectly +exfiltrating inputs/secrets +attacking other nodes +using coordinator for discovery of targets +``` + +Controls: + +```text +never mix tenants by default +explicit node sharing only +signed node identity +per-node capability policy +artifact provenance +optional reproducible/repeated builds +trust labels for nodes +``` + +Default rule: + +```text +A user's work runs only on that user's nodes, managed paid nodes, or explicitly shared team nodes. +``` + +--- + +### 8. Cross-tenant isolation bugs + +Risks: + +```text +user sees another user's process +user sees another user's logs +user accesses another user's node +artifact metadata leak +operator panel leak +bad authorization check on debug endpoint +``` + +Controls: + +```text +tenant ID on every object +authorization checks on every API +negative tests for cross-tenant access +separate storage namespaces +least-privilege service tokens +security review for debug APIs +``` + +--- + +### 9. Debugger abuse + +Risks: + +```text +reading secrets through debugger +modifying task state maliciously +attaching to another user's process +debug endpoint used as data exfiltration path +huge memory reads causing DoS +``` + +Controls: + +```text +attach requires owner/team permission +debug memory reads are quota-limited +debug sessions are audited +no cross-tenant debug access +hosted free tasks have no secrets +``` + +--- + +### 10. Operator panel phishing + +Risks: + +```text +fake login forms +fake GitHub authorization prompts +malicious links +misleading buttons +HTML/script injection +``` + +Controls: + +```text +built-in widgets only +no custom HTML/JS in MVP +escape all text +label panels as user-provided +no password fields initially +restricted external links +no OAuth flows inside user panels +``` + +--- + +### 11. Container/runtime abuse + +Risks: + +```text +container escape +privileged container misuse +Docker socket exposure +host filesystem mount abuse +kernel attack surface +supply-chain malware +``` + +Controls: + +```text +no free hosted containers +managed containers only on hardened paid nodes +rootless where practical +seccomp/AppArmor/SELinux +no privileged containers by default +no Docker socket mounts +read-only mounts where possible +network egress policy +``` + +NIST SP 800-190 covers container security concerns; Docker documents rootless mode and seccomp profiles as important hardening tools. + +--- + +### 12. Supply-chain abuse + +Risks: + +```text +malicious Disasmer examples +malicious Containerfiles +malicious flakes +dependency confusion +poisoned build caches +untrusted public templates +``` + +Controls: + +```text +signed official examples +template review +cache namespace separation +provenance metadata +dependency lockfiles +clear trust warnings before running third-party projects +``` + +--- + +### 13. Cost amplification + +Risks: + +```text +API calls that trigger expensive work +artifact sync causing storage costs +log spam causing storage costs +OAuth/webhook/email costs +relay bandwidth costs +``` + +Controls: + +```text +hard spend caps +per-user cost budgets +quota before work starts +paid-only expensive features +separate flush from sync +bulk data never routed through coordinator by default +``` + +--- + +## Free-tier product rule + +Free hosted mode should prove the product, not subsidize heavy builds. + +It should support: + +```text +creating a virtual process +opening an operator panel +testing the SDK +attaching a user's own node +running tiny control logic +spawning tasks onto the user's node +using the debugger flow +``` + +It should not support: + +```text +hosted CI workloads +hosted arbitrary containers +large artifact storage +public file hosting +network scanning +long-running compute +``` + +--- + +## MVP commercial rollout + +Phase 1: + +```text +open-source local runtime +free hosted coordinator +one zero-capability hosted process +attach-your-own-node flow +VS Code extension +operator panel +basic abuse controls +``` + +Phase 2: + +```text +paid coordinator limits +team projects +more processes +longer retention +private artifacts +better logs +``` + +Phase 3: + +```text +managed Linux nodes +warm build caches +paid artifact storage +organization billing +``` + +Phase 4: + +```text +Windows/macOS builders +enterprise/private coordinators +SSO/audit/SLA +node resale or marketplace +``` + +--- + +## Success metric + +The free product should let someone do this quickly: + +```text +sign in +create one Disasmer program (through the website, terminal or vscode for example) +open its operator panel +attach their own machine as a node and verify this in the coordinator UI +spawn a build task onto that node +debug it from VS Code +see artifacts/logs in the coordinator UI +``` + +That proves the platform without giving away expensive compute. + +--- + +## References + +```text +OWASP API Security Top 10 2023: Unrestricted Resource Consumption +https://owasp.org/API-Security/editions/2023/en/0xa4-unrestricted-resource-consumption/ + +OAuth 2.0 Security Best Current Practice, RFC 9700 +https://datatracker.ietf.org/doc/rfc9700/ + +NIST SP 800-190: Application Container Security Guide +https://csrc.nist.gov/pubs/sp/800/190/final + +Docker Rootless Mode +https://docs.docker.com/engine/security/rootless/ + +Docker Seccomp Security Profiles +https://docs.docker.com/engine/security/seccomp/ +``` diff --git a/operator_panel_notes.md b/operator_panel_notes.md new file mode 100644 index 0000000..477354a --- /dev/null +++ b/operator_panel_notes.md @@ -0,0 +1,208 @@ +# Disasmer Operator Panel Notes + +Disasmer programs should be able to expose a small human-facing UI through the coordinator web page. + +This is useful for long-running build systems, repo watchers, task dashboards, and manual controls. + +## Core idea + +```text +Disasmer program renders UI state +Coordinator displays it in a webpage +Human clicks/types/selects +Coordinator sends typed events back to the program +Program reacts by spawning, stopping, restarting, or debugging tasks +``` + +The browser is only a view. The Disasmer program owns the state and logic. + +## Terminology + +```text +Operator Panel + A small immediate-mode UI exposed by a virtual process. + +Widget + A built-in UI element such as a button, textbox, dropdown, progress bar, or task table. + +UI Event + A typed event sent from the coordinator webpage back into the virtual process. +``` + +## Example use case + +A long-running build program can expose: + +```text +repo picker +branch dropdown +parallelism input +start/stop buttons +running task table +progress bars +log tail +artifact links +debug/restart/cancel buttons per task +``` + +This makes the build system controllable without external YAML, dashboards, or proprietary CI UI. + +## Rust sketch + +```rust +use disasmer::prelude::*; + +#[disasmer::main] +async fn main() -> Result<()> { + let panel = ui::panel("Repo builder").await?; + + let mut cfg = BuildConfig { + repo: None, + branch: "main".into(), + parallelism: 4, + }; + + let mut builds = BuildState::load().await?; + + loop { + let action = panel.draw(|ui| { + ui.heading("Repo builder"); + + cfg.repo = ui.git_repo_picker("Repository", cfg.repo.clone()); + cfg.branch = ui.git_branch_dropdown("Branch", cfg.repo.as_ref(), &cfg.branch); + cfg.parallelism = ui.number("Parallel builds", cfg.parallelism); + + if ui.button("Start watcher").clicked() { + ui.emit(Action::Start(cfg.clone())); + } + + if ui.button("Stop").clicked() { + ui.emit(Action::Stop); + } + + ui.progress("Current build", builds.current_progress()); + ui.task_table("Running tasks", builds.running_tasks()); + ui.log_tail("Recent output", builds.log_stream()); + }).await?; + + match action { + Some(Action::Start(cfg)) => builds.start_watcher(cfg).await?, + Some(Action::Stop) => builds.stop_watcher().await?, + Some(Action::RestartTask(id)) => builds.restart_task(id).await?, + Some(Action::CancelTask(id)) => builds.cancel_task(id).await?, + None => {} + } + + builds.poll().await?; + } +} +``` + +## What this proves + +A Disasmer build system can be expressed as one normal program containing: + +```text +build logic +repo watching +branch selection +parallel task spawning +human controls +progress reporting +logs +artifact links +debug hooks +``` + +The build loop is just normal program logic. Build tasks are virtual threads. + +## Task controls + +A task table should support simple per-task actions: + +```text +Debug +Restart +Cancel +View logs +Open artifacts +``` + +Example row: + +```text +commit abc123 | linux-x64 | running | 72% | Debug | Restart | Cancel +``` + +## Debugging behavior + +When the virtual process is running: + +```text +operator panel is interactive +button clicks become UI events +program handles events normally +``` + +When the virtual process is stopped in a debugger: + +```text +panel shows last rendered state +program-level UI events are disabled or queued +control-plane actions may remain available +``` + +For the MVP, prefer: + +```text +running process -> interactive panel +stopped process -> read-only panel plus control-plane actions +``` + +This avoids executing program code while the debugger says the process is stopped. + +## MVP widget set + +Start with: + +```text +text +heading +button +textbox +number input +checkbox +dropdown +progress bar +task table +log tail +artifact link +git repo picker +git branch picker +``` + +Avoid initially: + +```text +custom HTML +custom JavaScript +complex layout +live charts +drag/drop workflow editors +browser-side business logic +``` + +## Design rule + +The operator panel should stay boring, typed, and immediate-mode. + +```text +One source file can define: + distributed execution + build orchestration + environment selection + filesystem/artifact behavior + debugging behavior + human operator UI +``` + diff --git a/scripts/acceptance-doc-contract-smoke.js b/scripts/acceptance-doc-contract-smoke.js new file mode 100755 index 0000000..5e23ff3 --- /dev/null +++ b/scripts/acceptance-doc-contract-smoke.js @@ -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"); diff --git a/scripts/acceptance-environment-contract-smoke.js b/scripts/acceptance-environment-contract-smoke.js new file mode 100755 index 0000000..61ace35 --- /dev/null +++ b/scripts/acceptance-environment-contract-smoke.js @@ -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, / 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::/], + ["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"); diff --git a/scripts/acceptance-evidence-contract-smoke.js b/scripts/acceptance-evidence-contract-smoke.js new file mode 100755 index 0000000..d36908d --- /dev/null +++ b/scripts/acceptance-evidence-contract-smoke.js @@ -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"); diff --git a/scripts/acceptance-private.sh b/scripts/acceptance-private.sh new file mode 100755 index 0000000..2f7316c --- /dev/null +++ b/scripts/acceptance-private.sh @@ -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 diff --git a/scripts/acceptance-public.sh b/scripts/acceptance-public.sh new file mode 100755 index 0000000..2b0016b --- /dev/null +++ b/scripts/acceptance-public.sh @@ -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 diff --git a/scripts/acceptance-report-smoke.js b/scripts/acceptance-report-smoke.js new file mode 100644 index 0000000..2220639 --- /dev/null +++ b/scripts/acceptance-report-smoke.js @@ -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"); diff --git a/scripts/acceptance-report.js b/scripts/acceptance-report.js new file mode 100755 index 0000000..dfe6b8d --- /dev/null +++ b/scripts/acceptance-report.js @@ -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)); diff --git a/scripts/artifact-download-smoke.js b/scripts/artifact-download-smoke.js new file mode 100755 index 0000000..0b1ad3f --- /dev/null +++ b/scripts/artifact-download-smoke.js @@ -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); +}); diff --git a/scripts/artifact-export-smoke.js b/scripts/artifact-export-smoke.js new file mode 100644 index 0000000..64809c8 --- /dev/null +++ b/scripts/artifact-export-smoke.js @@ -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); +}); diff --git a/scripts/cancellation-smoke.js b/scripts/cancellation-smoke.js new file mode 100644 index 0000000..1a4935d --- /dev/null +++ b/scripts/cancellation-smoke.js @@ -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); +}); diff --git a/scripts/cli-browser-login-flow-smoke.js b/scripts/cli-browser-login-flow-smoke.js new file mode 100644 index 0000000..88f1e5c --- /dev/null +++ b/scripts/cli-browser-login-flow-smoke.js @@ -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); +}); diff --git a/scripts/cli-install-smoke.js b/scripts/cli-install-smoke.js new file mode 100755 index 0000000..9037a08 --- /dev/null +++ b/scripts/cli-install-smoke.js @@ -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"); diff --git a/scripts/cli-local-run-smoke.js b/scripts/cli-local-run-smoke.js new file mode 100755 index 0000000..f973037 --- /dev/null +++ b/scripts/cli-local-run-smoke.js @@ -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); +}); diff --git a/scripts/cli-login-smoke.js b/scripts/cli-login-smoke.js new file mode 100644 index 0000000..26bc78b --- /dev/null +++ b/scripts/cli-login-smoke.js @@ -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"); diff --git a/scripts/dap-smoke.js b/scripts/dap-smoke.js new file mode 100755 index 0000000..e38eef0 --- /dev/null +++ b/scripts/dap-smoke.js @@ -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); +}); diff --git a/scripts/docs-smoke.js b/scripts/docs-smoke.js new file mode 100644 index 0000000..487df65 --- /dev/null +++ b/scripts/docs-smoke.js @@ -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= disasmer run build/], + ["agent key lifecycle", /register, list, rotate, and revoke an agent key/], + ["capability auto-detect", /auto-detects OS, architecture/], + ["capability override", /--cap /], + ["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=/], + ["public dry-run fallback hosts entry env", /DISASMER_PUBLIC_DRYRUN_HOSTS_ENTRY=" disasmer\.michelpaulissen\.com"/], + ["public dry-run deployment IP env", /DISASMER_PUBLIC_RELEASE_DRYRUN_IP=/], + ["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=/], + ["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=/], + ["public dry-run Forgejo repo name override", /DISASMER_PUBLIC_REPO_NAME=/], + ["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"); diff --git a/scripts/flagship-demo-smoke.js b/scripts/flagship-demo-smoke.js new file mode 100644 index 0000000..2361425 --- /dev/null +++ b/scripts/flagship-demo-smoke.js @@ -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"); diff --git a/scripts/hostile-input-contract-smoke.js b/scripts/hostile-input-contract-smoke.js new file mode 100644 index 0000000..2be67ad --- /dev/null +++ b/scripts/hostile-input-contract-smoke.js @@ -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\("[\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"); diff --git a/scripts/local-services-smoke.js b/scripts/local-services-smoke.js new file mode 100644 index 0000000..e44c183 --- /dev/null +++ b/scripts/local-services-smoke.js @@ -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); +}); diff --git a/scripts/node-attach-smoke.js b/scripts/node-attach-smoke.js new file mode 100755 index 0000000..ba5328e --- /dev/null +++ b/scripts/node-attach-smoke.js @@ -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); +}); diff --git a/scripts/node-lifecycle-contract-smoke.js b/scripts/node-lifecycle-contract-smoke.js new file mode 100755 index 0000000..c5b2243 --- /dev/null +++ b/scripts/node-lifecycle-contract-smoke.js @@ -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"); diff --git a/scripts/operator-panel-smoke.js b/scripts/operator-panel-smoke.js new file mode 100755 index 0000000..90d0767 --- /dev/null +++ b/scripts/operator-panel-smoke.js @@ -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(" 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); +}); diff --git a/scripts/podman-backend-smoke.js b/scripts/podman-backend-smoke.js new file mode 100755 index 0000000..00809d9 --- /dev/null +++ b/scripts/podman-backend-smoke.js @@ -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; +} diff --git a/scripts/prepare-public-release-dryrun.js b/scripts/prepare-public-release-dryrun.js new file mode 100755 index 0000000..029b7e4 --- /dev/null +++ b/scripts/prepare-public-release-dryrun.js @@ -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 --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 --project-id --node --public-key --enrollment-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//"; + 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 --project-id --node --public-key --enrollment-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(); diff --git a/scripts/public-browser-login-contract-smoke.js b/scripts/public-browser-login-contract-smoke.js new file mode 100644 index 0000000..9ce5a84 --- /dev/null +++ b/scripts/public-browser-login-contract-smoke.js @@ -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, / 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"); diff --git a/scripts/public-release-dryrun-contract-smoke.js b/scripts/public-release-dryrun-contract-smoke.js new file mode 100644 index 0000000..ea193a1 --- /dev/null +++ b/scripts/public-release-dryrun-contract-smoke.js @@ -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"); diff --git a/scripts/public-release-dryrun-e2e.js b/scripts/public-release-dryrun-e2e.js new file mode 100755 index 0000000..8c6a32a --- /dev/null +++ b/scripts/public-release-dryrun-e2e.js @@ -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); +}); diff --git a/scripts/public-release-dryrun-final-evidence.js b/scripts/public-release-dryrun-final-evidence.js new file mode 100755 index 0000000..7a1b204 --- /dev/null +++ b/scripts/public-release-dryrun-final-evidence.js @@ -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}`); diff --git a/scripts/public-release-dryrun-preflight.js b/scripts/public-release-dryrun-preflight.js new file mode 100755 index 0000000..a52ec4c --- /dev/null +++ b/scripts/public-release-dryrun-preflight.js @@ -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)); diff --git a/scripts/public-story-contract-smoke.js b/scripts/public-story-contract-smoke.js new file mode 100644 index 0000000..e1d23f3 --- /dev/null +++ b/scripts/public-story-contract-smoke.js @@ -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"); diff --git a/scripts/publish-public-release-dryrun.js b/scripts/publish-public-release-dryrun.js new file mode 100755 index 0000000..a219add --- /dev/null +++ b/scripts/publish-public-release-dryrun.js @@ -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); +}); diff --git a/scripts/quic-smoke.js b/scripts/quic-smoke.js new file mode 100755 index 0000000..cf415b8 --- /dev/null +++ b/scripts/quic-smoke.js @@ -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); +}); diff --git a/scripts/release-blocker-smoke.js b/scripts/release-blocker-smoke.js new file mode 100755 index 0000000..dbed312 --- /dev/null +++ b/scripts/release-blocker-smoke.js @@ -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"); diff --git a/scripts/release-source-scan.sh b/scripts/release-source-scan.sh new file mode 100755 index 0000000..4f2db0c --- /dev/null +++ b/scripts/release-source-scan.sh @@ -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" diff --git a/scripts/resource-metering-contract-smoke.js b/scripts/resource-metering-contract-smoke.js new file mode 100644 index 0000000..bcb50f5 --- /dev/null +++ b/scripts/resource-metering-contract-smoke.js @@ -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"); diff --git a/scripts/scheduler-placement-smoke.js b/scripts/scheduler-placement-smoke.js new file mode 100755 index 0000000..556025d --- /dev/null +++ b/scripts/scheduler-placement-smoke.js @@ -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); +}); diff --git a/scripts/sdk-spawn-runtime-smoke.js b/scripts/sdk-spawn-runtime-smoke.js new file mode 100755 index 0000000..a7096c1 --- /dev/null +++ b/scripts/sdk-spawn-runtime-smoke.js @@ -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"); diff --git a/scripts/self-hosted-coordinator-smoke.js b/scripts/self-hosted-coordinator-smoke.js new file mode 100644 index 0000000..0bfa2bd --- /dev/null +++ b/scripts/self-hosted-coordinator-smoke.js @@ -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); +}); diff --git a/scripts/source-preparation-smoke.js b/scripts/source-preparation-smoke.js new file mode 100755 index 0000000..44346cc --- /dev/null +++ b/scripts/source-preparation-smoke.js @@ -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); +}); diff --git a/scripts/tenant-isolation-contract-smoke.js b/scripts/tenant-isolation-contract-smoke.js new file mode 100644 index 0000000..edd3b1e --- /dev/null +++ b/scripts/tenant-isolation-contract-smoke.js @@ -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"); diff --git a/scripts/user-session-token-boundary-smoke.js b/scripts/user-session-token-boundary-smoke.js new file mode 100755 index 0000000..c1e652d --- /dev/null +++ b/scripts/user-session-token-boundary-smoke.js @@ -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"); diff --git a/scripts/verify-public-split.sh b/scripts/verify-public-split.sh new file mode 100755 index 0000000..a2bae52 --- /dev/null +++ b/scripts/verify-public-split.sh @@ -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) diff --git a/scripts/vscode-extension-smoke.js b/scripts/vscode-extension-smoke.js new file mode 100755 index 0000000..e2268e8 --- /dev/null +++ b/scripts/vscode-extension-smoke.js @@ -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"); diff --git a/scripts/vscode-f5-smoke.js b/scripts/vscode-f5-smoke.js new file mode 100755 index 0000000..f257f62 --- /dev/null +++ b/scripts/vscode-f5-smoke.js @@ -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); +}); diff --git a/scripts/wasmtime-node-smoke.js b/scripts/wasmtime-node-smoke.js new file mode 100644 index 0000000..1c1fc1b --- /dev/null +++ b/scripts/wasmtime-node-smoke.js @@ -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"); diff --git a/scripts/windows-best-effort-smoke.js b/scripts/windows-best-effort-smoke.js new file mode 100755 index 0000000..80fdc9b --- /dev/null +++ b/scripts/windows-best-effort-smoke.js @@ -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); +}); diff --git a/scripts/windows-runner-smoke.js b/scripts/windows-runner-smoke.js new file mode 100755 index 0000000..230565f --- /dev/null +++ b/scripts/windows-runner-smoke.js @@ -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); +}); diff --git a/scripts/windows-validation-contract-smoke.js b/scripts/windows-validation-contract-smoke.js new file mode 100755 index 0000000..61f84f6 --- /dev/null +++ b/scripts/windows-validation-contract-smoke.js @@ -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"); diff --git a/thread_args_wasmtime_notes.md b/thread_args_wasmtime_notes.md new file mode 100644 index 0000000..21d4159 --- /dev/null +++ b/thread_args_wasmtime_notes.md @@ -0,0 +1,398 @@ +# Disasmer Thread Arguments with Wasmtime + +## Goal + +Disasmer should make spawning a virtual thread feel similar to Rust's `thread::spawn`, but with stricter rules because the thread may run on another machine, in another container, or after a restart. + +The core rule: + +```text +Anything passed to a virtual thread must be either: + - a small serializable value + - an immutable content-addressed object + - a Disasmer runtime handle +``` + +Normal raw pointers, borrowed references, local file handles, sockets, and host-only resources are not valid virtual-thread arguments. + +--- + +## Recommended MVP Model + +Use Wasmtime as the embedded Wasm engine. + +Do not try to migrate arbitrary Rust closures or live native stacks. + +Instead, compile Disasmer programs into Wasm modules/components with named task entrypoints. + +```rust +#[derive(DisasmerArg)] +struct BuildArgs { + commit: git::Commit, + profile: String, + src: SourceSnapshot, +} + +#[disasmer::task] +async fn linux_build(args: BuildArgs) -> Result { + fs::mount("/src", args.src).await?; + + cmd!("cargo build --profile {}", args.profile) + .cwd("/src") + .run() + .await?; + + let artifact = artifact::publish( + format!("linux-app-{}", args.commit), + "/src/target/release/app", + ).await?; + + fs::flush().await?; + Ok(artifact) +} + +#[disasmer::main] +async fn main() -> Result<()> { + let commit = git::Commit::current().await?; + let src = git::Repo::open("/repo").snapshot(commit).await?; + + let args = BuildArgs { + commit, + profile: "release".into(), + src, + }; + + let task = spawn(env!("linux"), linux_build(args)).await?; + let artifact = task.join().await?; + + ui::log(format!("built {artifact:?}")).await?; + Ok(()) +} +``` + +--- + +## What This Means + +The source looks like ordinary Rust, but spawning is not the same as `std::thread::spawn`. + +```text +std::thread::spawn(move || ...) + moves values into an OS thread in the same process + +Disasmer spawn(...) + creates a TaskSpec + serializes small arguments + copies handles for large/distributed objects + asks the coordinator to place a virtual thread + starts the task in a Wasmtime instance on the selected node +``` + +`src` is not a copied Git repo. It is a handle to an immutable source snapshot. + +`env!("linux")` is not a container image. It is a handle/digest for an environment discovered from the project. + +`artifact` is not a copied binary. It is a handle to a published output. + +--- + +## Runtime Flow + +When a Disasmer program calls `spawn(env!("linux"), linux_build(args))`: + +```text +1. Guest code calls a Disasmer host import. +2. The host import receives: + - task name / task id + - environment id + - serialized argument bytes + - referenced runtime handles +3. The coordinator creates a Virtual Thread record. +4. Scheduler chooses a node based on environment and data locality. +5. The node runtime instantiates the Wasm module with Wasmtime. +6. The node runtime links Disasmer host functions. +7. The task entrypoint is called with decoded arguments. +8. The task runs until completion, suspend, trap, or cancellation. +``` + +The virtual thread is debugger-visible as a normal thread of the virtual process. + +--- + +## Minimal Wasmtime Shape + +Each node runtime embeds Wasmtime. + +```text +Node Runtime + Engine + compiled module/component cache + Store + Linker with Disasmer imports + task entrypoint dispatcher +``` + +`ThreadHostState` contains the host-side context for the running virtual thread: + +```text +thread id +virtual process id +debug epoch +filesystem overlay +handle table +resource limits +coordinator connection +``` + +Disasmer host imports are implemented through Wasmtime's linker/host-function mechanism. + +Examples: + +```text +disasmer.spawn(...) +disasmer.fs.flush(...) +disasmer.artifact.publish(...) +disasmer.channel.send(...) +disasmer.suspend(...) +``` + +If a host import needs to read serialized arguments from guest memory, it uses the caller context to access the guest's exported memory. + +--- + +## Argument Categories + +Default behavior: + +```text +u32, bool, String, small structs + serialized and copied into the TaskSpec + +SourceSnapshot + handle only + +Artifact + handle only + +Env + handle/digest only + +DArc, DMutex, DChannel + distributed runtime handle only + +Vec + copied only if small + rejected or warned if large +``` + +Large data should be passed as explicit runtime objects: + +```rust +let blob = Blob::from_file("large-input.dat").await?; +spawn(env!("linux"), process_blob(blob)).await?; +``` + +Not: + +```rust +let bytes = fs::read("large-input.dat").await?; +spawn(env!("linux"), process_bytes(bytes)).await?; +``` + +--- + +## Preventing Confusing Situations + +Disasmer should fail early and clearly. + +### 1. Require `DisasmerArg` + +Every task argument type must implement `DisasmerArg`. + +Usually this is derived: + +```rust +#[derive(DisasmerArg)] +struct Args { + src: SourceSnapshot, + profile: String, +} +``` + +If a field cannot cross a virtual-thread boundary, compilation should fail with a helpful message. + +Bad: + +```rust +#[derive(DisasmerArg)] +struct Args<'a> { + path: &'a str, +} +``` + +Suggested error: + +```text +Args cannot be passed to a Disasmer virtual thread because it contains a borrowed reference. +Use String, Arc-like Disasmer handles, Blob, SourceSnapshot, or Artifact instead. +``` + +### 2. Prefer Named Tasks + +For the MVP, prefer: + +```rust +#[disasmer::task] +async fn linux_build(args: BuildArgs) -> Result { ... } +``` + +over arbitrary async closures. + +Closure syntax can come later, but should lower to: + +```text +named generated task +captured argument struct +derived DisasmerArg +``` + +This makes spawning, restarting, debugging, and serialization much simpler. + +### 3. Ban Host-Only Types + +These should not be valid task arguments: + +```text +&T / &mut T +raw pointers +Rc +RefCell +std::fs::File +TcpStream +MutexGuard +local process handles +thread handles +unbounded Vec +``` + +Use explicit Disasmer types instead: + +```text +DArc +DMutex +DChannel +Blob +Artifact +SourceSnapshot +VirtualFile +``` + +### 4. Make Latency Visible in the Type System + +Operations that may cross machines should usually be async: + +```rust +progress.lock().await?; +fs::flush().await?; +suspend().await?; +artifact::publish(...).await?; +``` + +This makes distributed boundaries visible without making the code noisy. + +### 5. Add Size Limits and Warnings + +If a user accidentally captures or passes a huge value, fail or warn: + +```text +Task argument is 82 MB. +This would be copied to the spawned virtual thread. +Use Blob::from_file, SourceSnapshot, or Artifact instead. +``` + +### 6. Make Environment References Compile-Time Checked + +`env!("linux")` should be generated from project resources such as: + +```text +envs/linux/Containerfile +envs/linux/Dockerfile +envs/linux/flake.nix +``` + +If the environment does not exist, the user should get a compile-time/editor error. + +### 7. Do Not Pretend `std::thread::spawn` Is Distributed + +Disasmer should provide its own spawn API. + +```rust +disasmer::spawn(...) +``` + +`std::thread::spawn` should either be unsupported in the Disasmer guest target or clearly documented as local-only/not part of the distributed runtime model. + +--- + +## Restarting After Source Changes + +For the MVP, do not hot-swap arbitrary live Wasm code. + +Use task restart instead: + +```text +edit source in VS Code +rebuild Wasm module +stop selected virtual thread +recreate task from: + - task name + - new module hash + - environment handle + - original arguments + - latest allowed VFS snapshot/checkpoint +start again +``` + +This is simple, understandable, and debuggable. + +Do not resume an old Wasm stack into a new compiled module unless Disasmer later grows explicit continuation/checkpoint support for that case. + +--- + +## What This Proves + +This model proves that Disasmer can express build-system tasks as normal-looking Rust code while keeping the runtime implementation realistic. + +It enables: + +```text +one source file for build logic +typed task arguments +container/Nix environment selection +source snapshots without copying repos +artifact handles without copying binaries +normal debugger thread views +safe task restart after edits +simple Wasmtime embedding +``` + +The key design choice is honesty: + +```text +local Rust values are local +Disasmer handles are distributed +small arguments are copied +large data is passed by content-addressed handle +``` + +That avoids surprising network copies and prevents users from thinking arbitrary local process state can magically move across machines. + +--- + +## References + +- Wasmtime Rust embedding API: https://docs.wasmtime.dev/api/wasmtime/ +- Wasmtime `Linker`: https://docs.wasmtime.dev/api/wasmtime/struct.Linker.html +- Wasmtime `Store`: https://docs.wasmtime.dev/api/wasmtime/struct.Store.html +- Rust `std::thread::spawn`: https://doc.rust-lang.org/std/thread/fn.spawn.html +- Serde derive: https://serde.rs/derive.html diff --git a/vscode-extension/extension.js b/vscode-extension/extension.js new file mode 100644 index 0000000..3947607 --- /dev/null +++ b/vscode-extension/extension.js @@ -0,0 +1,414 @@ +const fs = require("fs"); +const path = require("path"); +const childProcess = require("child_process"); + +const DEFAULT_OPERATOR_ENDPOINT = "https://disasmer.michelpaulissen.com:9443"; + +let vscode = null; +try { + vscode = require("vscode"); +} catch (_) { + vscode = null; +} + +function discoverEnvironmentNames(projectRoot) { + const envsDir = path.join(projectRoot, "envs"); + if (!fs.existsSync(envsDir)) return []; + + return fs + .readdirSync(envsDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => { + const dir = path.join(envsDir, name); + return ( + fs.existsSync(path.join(dir, "Containerfile")) || + fs.existsSync(path.join(dir, "Dockerfile")) + ); + }) + .sort(); +} + +function findEnvReferences(text) { + const references = []; + const pattern = /env!\(\s*"([^"]+)"\s*\)/g; + let match; + while ((match = pattern.exec(text)) !== null) { + references.push({ + name: match[1], + start: match.index, + end: pattern.lastIndex + }); + } + return references; +} + +function diagnoseEnvReferences(text, environmentNames) { + const known = new Set(environmentNames); + return findEnvReferences(text) + .filter((reference) => !known.has(reference.name)) + .map((reference) => ({ + ...reference, + message: `Missing Disasmer environment "${reference.name}". Add envs/${reference.name}/Containerfile or envs/${reference.name}/Dockerfile.` + })); +} + +function binaryCandidates(projectRoot, name) { + const names = process.platform === "win32" ? [name, `${name}.exe`] : [name]; + return names.map((candidate) => path.join(projectRoot, candidate)); +} + +function executableWorkspaceBinary(projectRoot, name) { + if (!projectRoot) return null; + return binaryCandidates(projectRoot, name).find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch (_) { + return false; + } + }) || null; +} + +function hasCargoWorkspace(root) { + return fs.existsSync(path.join(root, "Cargo.toml")); +} + +function resolveProjectPath(folder, value) { + const workspaceRoot = folder && folder.uri && folder.uri.fsPath; + if (!value || value === "${workspaceFolder}") return workspaceRoot; + if (workspaceRoot && typeof value === "string") { + return value.replace(/\$\{workspaceFolder\}/g, workspaceRoot); + } + return value; +} + +function bundleInspectCommand(projectRoot, adapterWorkspace = path.resolve(__dirname, "..")) { + const workspaceCli = executableWorkspaceBinary(projectRoot, "disasmer"); + if (workspaceCli) { + return { + command: workspaceCli, + args: ["bundle", "inspect", "--project", projectRoot], + options: { + cwd: projectRoot, + encoding: "utf8" + } + }; + } + + return { + command: "cargo", + args: [ + "run", + "-q", + "-p", + "disasmer-cli", + "--bin", + "disasmer", + "--", + "bundle", + "inspect", + "--project", + projectRoot + ], + options: { + cwd: adapterWorkspace, + encoding: "utf8" + } + }; +} + +function refreshBundleBeforeLaunch( + projectRoot, + adapterWorkspace = path.resolve(__dirname, ".."), + runner = childProcess.spawnSync +) { + const command = bundleInspectCommand(projectRoot, adapterWorkspace); + const result = runner(command.command, command.args, command.options); + if (result.error) { + throw new Error(`Disasmer bundle refresh failed: ${result.error.message}`); + } + if (result.status !== 0) { + const detail = (result.stderr || result.stdout || "").trim(); + throw new Error( + `Disasmer bundle refresh failed before debug launch${detail ? `: ${detail}` : "."}` + ); + } + + let inspection; + try { + inspection = JSON.parse(result.stdout); + } catch (error) { + throw new Error(`Disasmer bundle refresh returned invalid metadata: ${error.message}`); + } + if (!inspection.metadata || !inspection.metadata.identity) { + throw new Error("Disasmer bundle refresh did not return a bundle identity."); + } + return inspection; +} + +function disasmerViewDescriptors() { + return [ + { id: "disasmer.nodes", emptyLabel: "No attached nodes" }, + { id: "disasmer.processes", emptyLabel: "No virtual processes" }, + { id: "disasmer.logs", emptyLabel: "No log streams" }, + { id: "disasmer.artifacts", emptyLabel: "No artifacts" }, + { id: "disasmer.inspector", emptyLabel: "No inspector state" } + ]; +} + +function disasmerViewStatePath(projectRoot) { + return path.join(projectRoot, ".disasmer", "views.json"); +} + +function emptyDisasmerViewState() { + return { + nodes: [], + processes: [], + logs: [], + artifacts: [], + inspector: [] + }; +} + +function asArray(value) { + return Array.isArray(value) ? value : []; +} + +function loadDisasmerViewState(projectRoot, reader = fs.readFileSync) { + const statePath = disasmerViewStatePath(projectRoot); + if (!fs.existsSync(statePath)) { + return emptyDisasmerViewState(); + } + + let parsed; + try { + parsed = JSON.parse(reader(statePath, "utf8")); + } catch (error) { + return { + ...emptyDisasmerViewState(), + inspector: [ + { + label: "View state error", + value: error.message + } + ] + }; + } + + return { + nodes: asArray(parsed.nodes), + processes: asArray(parsed.processes), + logs: asArray(parsed.logs), + artifacts: asArray(parsed.artifacts), + inspector: asArray(parsed.inspector) + }; +} + +function item(label, description = "") { + return { + label: String(label || ""), + description: String(description || "") + }; +} + +function disasmerViewItems(state, viewId) { + switch (viewId) { + case "disasmer.nodes": + return state.nodes.map((node) => + item(node.id || node.name || "node", [node.status, node.capabilities].filter(Boolean).join(" ")) + ); + case "disasmer.processes": + return state.processes.map((process) => + item(process.id || process.process || "process", [process.status, process.entry].filter(Boolean).join(" ")) + ); + case "disasmer.logs": + return state.logs.map((log) => + item(log.task || log.stream || "log", [log.message, log.bytes].filter(Boolean).join(" ")) + ); + case "disasmer.artifacts": + return state.artifacts.map((artifact) => + item(artifact.path || artifact.id || "artifact", [artifact.status, artifact.size].filter(Boolean).join(" ")) + ); + case "disasmer.inspector": + return state.inspector.map((entry) => + item(entry.label || entry.name || "inspector", entry.value || entry.detail || "") + ); + default: + return []; + } +} + +function treeItemsForView(projectRoot, descriptor) { + const state = projectRoot ? loadDisasmerViewState(projectRoot) : emptyDisasmerViewState(); + const items = disasmerViewItems(state, descriptor.id); + return items.length > 0 ? items : [item(descriptor.emptyLabel)]; +} + +function toVsCodeTreeItem(viewItem) { + const treeItem = new vscode.TreeItem( + viewItem.label, + vscode.TreeItemCollapsibleState.None + ); + treeItem.description = viewItem.description; + return treeItem; +} + +function resolveDisasmerDebugConfiguration(folder, config = {}) { + const project = resolveProjectPath(folder, config.project); + return { + ...config, + name: config.name || "Disasmer: Launch Virtual Process", + type: "disasmer", + request: "launch", + entry: config.entry || "build", + project, + runtimeBackend: config.runtimeBackend || "local-services", + operatorEndpoint: config.operatorEndpoint || DEFAULT_OPERATOR_ENDPOINT + }; +} + +function defaultOperatorEndpoint() { + return DEFAULT_OPERATOR_ENDPOINT; +} + +function debugAdapterExecutableSpec( + projectRoot, + adapterWorkspace = path.resolve(__dirname, "..") +) { + const workspaceAdapter = executableWorkspaceBinary(projectRoot, "disasmer-debug-dap"); + if (workspaceAdapter) { + return { + command: workspaceAdapter, + args: [], + options: { cwd: projectRoot } + }; + } + + if (!hasCargoWorkspace(adapterWorkspace)) { + return { + command: "disasmer-debug-dap", + args: [], + options: { cwd: projectRoot || process.cwd() } + }; + } + + return { + command: "cargo", + args: ["run", "-q", "-p", "disasmer-dap", "--bin", "disasmer-debug-dap"], + options: { cwd: adapterWorkspace } + }; +} + +function registerDisasmerViews(context) { + if (!vscode) return; + const workspaceRoot = + vscode.workspace.workspaceFolders && + vscode.workspace.workspaceFolders[0] && + vscode.workspace.workspaceFolders[0].uri.fsPath; + const emitters = []; + for (const descriptor of disasmerViewDescriptors()) { + const emitter = new vscode.EventEmitter(); + emitters.push(emitter); + context.subscriptions.push( + vscode.window.registerTreeDataProvider(descriptor.id, { + onDidChangeTreeData: emitter.event, + getTreeItem(item) { + return item; + }, + getChildren() { + return treeItemsForView(workspaceRoot, descriptor).map(toVsCodeTreeItem); + } + }), + emitter + ); + } + if (workspaceRoot && vscode.workspace.createFileSystemWatcher) { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(workspaceRoot, ".disasmer/views.json") + ); + const refresh = () => emitters.forEach((emitter) => emitter.fire()); + context.subscriptions.push( + watcher, + watcher.onDidChange(refresh), + watcher.onDidCreate(refresh), + watcher.onDidDelete(refresh) + ); + } +} + +function activate(context) { + if (!vscode) return; + + const diagnostics = vscode.languages.createDiagnosticCollection("disasmer"); + context.subscriptions.push(diagnostics); + registerDisasmerViews(context); + + const refresh = (document) => { + if (document.languageId !== "rust") return; + const folder = vscode.workspace.getWorkspaceFolder(document.uri); + if (!folder) return; + const environmentNames = discoverEnvironmentNames(folder.uri.fsPath); + const items = diagnoseEnvReferences(document.getText(), environmentNames).map((diagnostic) => { + const start = document.positionAt(diagnostic.start); + const end = document.positionAt(diagnostic.end); + return new vscode.Diagnostic( + new vscode.Range(start, end), + diagnostic.message, + vscode.DiagnosticSeverity.Error + ); + }); + diagnostics.set(document.uri, items); + }; + + context.subscriptions.push( + vscode.workspace.onDidOpenTextDocument(refresh), + vscode.workspace.onDidChangeTextDocument((event) => refresh(event.document)), + vscode.workspace.onDidSaveTextDocument(refresh), + vscode.debug.registerDebugAdapterDescriptorFactory("disasmer", { + createDebugAdapterDescriptor(session) { + const folder = session.workspaceFolder; + const project = session.configuration.project || (folder && folder.uri.fsPath); + if (!project) { + throw new Error("Disasmer debug launch requires a workspace folder or project path."); + } + const adapterWorkspace = path.resolve(__dirname, ".."); + refreshBundleBeforeLaunch(project, adapterWorkspace); + const spec = debugAdapterExecutableSpec(project, adapterWorkspace); + return new vscode.DebugAdapterExecutable( + spec.command, + spec.args, + spec.options + ); + } + }), + vscode.debug.registerDebugConfigurationProvider("disasmer", { + resolveDebugConfiguration(folder, config) { + return resolveDisasmerDebugConfiguration(folder, config); + } + }) + ); + + for (const document of vscode.workspace.textDocuments) { + refresh(document); + } +} + +function deactivate() {} + +module.exports = { + activate, + deactivate, + bundleInspectCommand, + debugAdapterExecutableSpec, + defaultOperatorEndpoint, + diagnoseEnvReferences, + disasmerViewDescriptors, + disasmerViewItems, + disasmerViewStatePath, + discoverEnvironmentNames, + findEnvReferences, + loadDisasmerViewState, + registerDisasmerViews, + refreshBundleBeforeLaunch, + resolveDisasmerDebugConfiguration +}; diff --git a/vscode-extension/package.json b/vscode-extension/package.json new file mode 100644 index 0000000..00dee21 --- /dev/null +++ b/vscode-extension/package.json @@ -0,0 +1,136 @@ +{ + "name": "disasmer-vscode", + "displayName": "Disasmer", + "description": "VS Code support for Disasmer build programs, environments, and debugging.", + "version": "0.1.0", + "publisher": "disasmer", + "engines": { + "vscode": "^1.80.0" + }, + "categories": [ + "Debuggers", + "Other" + ], + "activationEvents": [ + "onLanguage:rust", + "onDebugResolve:disasmer", + "onView:disasmer.nodes", + "onView:disasmer.processes", + "onView:disasmer.logs", + "onView:disasmer.artifacts", + "onView:disasmer.inspector", + "workspaceContains:envs/*/Containerfile", + "workspaceContains:envs/*/Dockerfile" + ], + "main": "./extension.js", + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "disasmer", + "title": "Disasmer", + "icon": "resources/disasmer.svg" + } + ] + }, + "debuggers": [ + { + "type": "disasmer", + "label": "Disasmer", + "languages": [ + "rust" + ], + "configurationAttributes": { + "launch": { + "type": "object", + "required": [ + "request", + "type", + "name" + ], + "properties": { + "name": { + "type": "string", + "default": "Disasmer: Launch Virtual Process" + }, + "type": { + "type": "string", + "default": "disasmer" + }, + "request": { + "type": "string", + "enum": [ + "launch" + ], + "default": "launch" + }, + "entry": { + "type": "string", + "description": "Disasmer entrypoint such as build, test, package, or release." + }, + "project": { + "type": "string", + "description": "Project directory. Defaults to the current workspace folder." + }, + "sourcePath": { + "type": "string", + "description": "Source file shown in the Disasmer virtual stack. Defaults to src/build.rs or src/main.rs." + }, + "runtimeBackend": { + "type": "string", + "enum": [ + "local-services", + "live-services", + "simulated" + ], + "default": "local-services", + "description": "Runtime backend used by the debug adapter. F5 defaults to local coordinator/node services; live-services uses the configured operator and an attached node." + }, + "operatorEndpoint": { + "type": "string", + "default": "https://disasmer.michelpaulissen.com:9443", + "description": "Default hosted operator endpoint used when a dry-run or hosted launch selects the public operator." + }, + "tenant": { + "type": "string", + "description": "Tenant identity used when the DAP talks to a live operator." + }, + "projectId": { + "type": "string", + "description": "Coordinator project identity used when the DAP talks to a live operator." + }, + "actorUser": { + "type": "string", + "description": "User identity used for coordinator inspection requests." + } + } + } + } + } + ], + "views": { + "disasmer": [ + { + "id": "disasmer.nodes", + "name": "Disasmer Nodes" + }, + { + "id": "disasmer.processes", + "name": "Disasmer Processes" + }, + { + "id": "disasmer.logs", + "name": "Disasmer Logs" + }, + { + "id": "disasmer.artifacts", + "name": "Disasmer Artifacts" + }, + { + "id": "disasmer.inspector", + "name": "Disasmer Inspector" + } + ] + } + } +} diff --git a/vscode-extension/resources/disasmer.svg b/vscode-extension/resources/disasmer.svg new file mode 100644 index 0000000..79ac9b6 --- /dev/null +++ b/vscode-extension/resources/disasmer.svg @@ -0,0 +1,3 @@ + + +