commit 20c72e6066fa52b926929548dd70b6a0b5e13b7f Author: Disasmer release dry run Date: Fri Jul 3 17:31:04 2026 +0200 Public dry run dryrun-1714a9eedd5b Source commit: 1714a9eedd5b1e30c6c9aceb7a999f2f695ba83c Public tree identity: sha256:e5daffad23fa7c7f1822adccd3c3b4ee0bc7f1a45e0d321eb9a6fb8282b269db 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..90d2ac7 --- /dev/null +++ b/DISASMER_PUBLIC_TREE.json @@ -0,0 +1,20 @@ +{ + "kind": "disasmer-filtered-public-tree", + "source_commit": "1714a9eedd5b1e30c6c9aceb7a999f2f695ba83c", + "release_name": "dryrun-1714a9eedd5b", + "filtered_out": [ + "private/**", + "experiments/**", + ".git", + "target", + "/*.md", + "**/.disasmer/**", + ".forgejo/**" + ], + "public_export": { + "host_neutral": true, + "include_forgejo_workflows": false + }, + "forgejo_host": "git.michelpaulissen.com", + "default_operator_endpoint": "https://disasmer.michelpaulissen.com:9443" +} 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/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/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..5eb0328 --- /dev/null +++ b/scripts/acceptance-environment-contract-smoke.js @@ -0,0 +1,248 @@ +#!/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 current public operator source", /compat\.source_commit[\s\S]*manifest\.source_commit/], + ["final verifier requires current public operator release", /compat\.release_name[\s\S]*manifest\.release_name/], + ["final verifier requires public coordinator compatibility evidence", /public-coordinator-compat\.json/], + ["final verifier requires current public coordinator source", /publicCoordinator\.source_commit[\s\S]*manifest\.source_commit/], + ["final verifier requires current public coordinator release", /publicCoordinator\.release_name[\s\S]*manifest\.release_name/], + ["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..eaa6a93 --- /dev/null +++ b/scripts/docs-smoke.js @@ -0,0 +1,336 @@ +#!/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 filters root markdown", /root `\*\.md` files/], + ["public dry-run filters Forgejo workflows by default", /\.forgejo\/\*\*` by\s+default/], + ["public dry-run Forgejo workflow opt in", /--include-forgejo-workflows/], + ["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..5532cb2 --- /dev/null +++ b/scripts/prepare-public-release-dryrun.js @@ -0,0 +1,617 @@ +#!/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 args = new Set(process.argv.slice(2)); +const includeForgejoWorkflows = + args.has("--include-forgejo-workflows") || + /^(1|true|yes)$/i.test(process.env.DISASMER_INCLUDE_FORGEJO_WORKFLOWS || ""); +const filteredTopLevel = ["private", "experiments", ".git", "target"]; +const filteredDirectoryNames = [".disasmer"]; +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 filteredOutPatterns() { + return [ + "private/**", + "experiments/**", + ".git", + "target", + "/*.md", + "**/.disasmer/**", + ...(includeForgejoWorkflows ? [] : [".forgejo/**"]), + ]; +} + +function isRootMarkdown(relativePath, entry) { + return ( + !relativePath.includes(path.sep) && + (entry.isFile() || entry.isSymbolicLink()) && + path.extname(entry.name).toLowerCase() === ".md" + ); +} + +function shouldFilter(entry, relativePath) { + const parts = relativePath.split(path.sep).filter(Boolean); + const topLevel = parts[0]; + if (filteredTopLevel.includes(topLevel)) return true; + if (parts.some((part) => filteredDirectoryNames.includes(part))) return true; + if (topLevel === ".forgejo" && !includeForgejoWorkflows) return true; + if (isRootMarkdown(relativePath, entry)) return true; + return false; +} + +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; + if (shouldFilter(entry, childRelative)) { + 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`); + } + } + for (const file of walkFiles(publicTree)) { + if (file.split(path.sep).includes(".disasmer")) { + throw new Error(`generated .disasmer view state leaked into the dry-run public tree: ${file}`); + } + } + if (!includeForgejoWorkflows && fs.existsSync(path.join(publicTree, ".forgejo"))) { + throw new Error(".forgejo/ leaked into the host-neutral dry-run public tree"); + } + for (const entry of fs.readdirSync(publicTree, { withFileTypes: true })) { + if (isRootMarkdown(entry.name, entry)) { + throw new Error(`root Markdown file leaked into the dry-run public tree: ${entry.name}`); + } + } +} + +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: filteredOutPatterns(), + public_export: { + host_neutral: !includeForgejoWorkflows, + include_forgejo_workflows: includeForgejoWorkflows, + }, + 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: filteredOutPatterns(), + public_export: { + host_neutral: !includeForgejoWorkflows, + include_forgejo_workflows: includeForgejoWorkflows, + }, + 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..c72b672 --- /dev/null +++ b/scripts/public-release-dryrun-contract-smoke.js @@ -0,0 +1,283 @@ +#!/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 maybeRead(relativePath) { + const file = path.join(repo, relativePath); + return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : null; +} + +function expect(source, name, pattern) { + assert(source !== null && source !== undefined, `missing source for ${name}`); + assert.match(source, pattern, `missing public release dry-run evidence: ${name}`); +} + +const phase2 = maybeRead("acceptance_criteria_phase2.md"); +const readme = maybeRead("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 = maybeRead(".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], +]) { + if (source) { + 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} root Markdown export filter`, /root `\*\.md`|root Markdown/); + expect(source, `${name} host-neutral workflow filter`, /\.forgejo[\s\S]*--include-forgejo-workflows/); + 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", /filteredTopLevel = \["private", "experiments", "\.git", "target"\]/], + ["release prep filters generated view state", /filteredDirectoryNames = \["\.disasmer"\]/], + ["release prep filters root Markdown", /isRootMarkdown[\s\S]*path\.extname\(entry\.name\)\.toLowerCase\(\) === "\.md"/], + ["release prep filters .forgejo by default", /topLevel === "\.forgejo" && !includeForgejoWorkflows/], + ["release prep has Forgejo workflow opt-in argument", /--include-forgejo-workflows/], + ["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 rejects root Markdown leaks", /root Markdown file leaked into public repo/], + ["e2e runner rejects .forgejo leaks by default", /\.forgejo\/ leaked into public repo/], + ["e2e runner rejects generated view state", /generated \.disasmer view state leaked into public repo/], + ["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 validates released live DAP", /validateReleasedLiveDap/], + ["e2e runner starts live DAP worker after coordinator-side breakpoint", /worker_started_after_coordinator_side_breakpoint/], + ["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 verifies filtered export", /function assertFilteredOut\(manifest\)/], + ["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 current public operator source", /compat\.source_commit[\s\S]*manifest\.source_commit/], + ["final evidence requires current public operator release", /compat\.release_name[\s\S]*manifest\.release_name/], + ["final evidence requires current public coordinator source", /publicCoordinator\.source_commit[\s\S]*manifest\.source_commit/], + ["final evidence requires current public coordinator release", /publicCoordinator\.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 released live DAP", /released_live_dap_verified/], + ["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); +} + +if (workflow) { + 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..1e3bf69 --- /dev/null +++ b/scripts/public-release-dryrun-e2e.js @@ -0,0 +1,1189 @@ +#!/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"); + }); +} + +class DapClient { + constructor(command, options = {}) { + this.child = cp.spawn(command, [], { + cwd: options.cwd, + detached: process.platform !== "win32", + }); + this.seq = 1; + this.buffer = Buffer.alloc(0); + this.messages = []; + this.waiters = []; + this.stderr = ""; + + this.child.stdout.on("data", (chunk) => { + this.buffer = Buffer.concat([this.buffer, chunk]); + this.parse(); + }); + this.child.stderr.on("data", (chunk) => { + this.stderr += chunk.toString(); + }); + this.child.on("exit", () => this.flushWaiters()); + } + + send(command, args = {}) { + const seq = this.seq++; + const message = { seq, type: "request", command, arguments: args }; + const payload = Buffer.from(JSON.stringify(message)); + this.child.stdin.write(`Content-Length: ${payload.length}\r\n\r\n`); + this.child.stdin.write(payload); + return seq; + } + + async response(seq, command) { + const message = await this.waitFor( + (item) => + item.type === "response" && + item.request_seq === seq && + item.command === command + ); + if (!message.success) { + throw new Error(`DAP ${command} failed: ${message.message || JSON.stringify(message)}`); + } + return message; + } + + terminate() { + if (this.child.exitCode !== null) return; + if (process.platform === "win32") { + this.child.kill("SIGKILL"); + return; + } + try { + process.kill(-this.child.pid, "SIGKILL"); + } catch (_) { + this.child.kill("SIGKILL"); + } + } + + waitFor(predicate, timeoutMs = 240000) { + const existing = this.messages.find(predicate); + if (existing) return Promise.resolve(existing); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.terminate(); + reject(new Error(`timed out waiting for DAP message\n${this.stderr}`)); + }, timeoutMs); + this.waiters.push({ predicate, resolve, timer }); + }); + } + + parse() { + while (true) { + const headerEnd = this.buffer.indexOf("\r\n\r\n"); + if (headerEnd < 0) return; + const header = this.buffer.slice(0, headerEnd).toString(); + const match = header.match(/Content-Length: (\d+)/i); + if (!match) throw new Error(`bad DAP header: ${header}`); + const length = Number(match[1]); + const start = headerEnd + 4; + const end = start + length; + if (this.buffer.length < end) return; + const payload = this.buffer.slice(start, end).toString(); + this.buffer = this.buffer.slice(end); + this.messages.push(JSON.parse(payload)); + this.flushWaiters(); + } + } + + flushWaiters() { + for (const waiter of [...this.waiters]) { + const message = this.messages.find(waiter.predicate); + if (!message) continue; + clearTimeout(waiter.timer); + this.waiters.splice(this.waiters.indexOf(waiter), 1); + waiter.resolve(message); + } + } + + async close() { + if (this.child.exitCode !== null) return; + const seq = this.send("disconnect"); + await this.response(seq, "disconnect"); + this.child.stdin.end(); + } +} + +function download(url, dest, redirects = 0) { + return new Promise((resolve, reject) => { + const client = url.startsWith("http://") ? http : https; + const request = client.get(url, (response) => { + if ( + [301, 302, 303, 307, 308].includes(response.statusCode) && + response.headers.location && + redirects < 5 + ) { + response.resume(); + download(new URL(response.headers.location, url).toString(), dest, redirects + 1) + .then(resolve) + .catch(reject); + return; + } + if (response.statusCode < 200 || response.statusCode >= 300) { + response.resume(); + reject(new Error(`download ${url} failed with ${response.statusCode}`)); + return; + } + ensureDir(path.dirname(dest)); + const file = fs.createWriteStream(dest); + response.pipe(file); + file.on("finish", () => file.close(resolve)); + file.on("error", reject); + }); + request.on("error", reject); + }); +} + +function releaseAssetDownloads(forgejoReport) { + const assets = [ + ...(forgejoReport.uploaded_assets || []), + ...(forgejoReport.reused_assets || []), + ]; + const byName = new Map(); + for (const asset of assets) { + if (asset.name && asset.browser_download_url) { + byName.set(asset.name, asset.browser_download_url); + } + } + return byName; +} + +async function downloadReleaseAssets(manifest, forgejoReport, assetsDir) { + const downloads = releaseAssetDownloads(forgejoReport); + const downloaded = []; + for (const asset of manifest.assets) { + const dest = path.join(assetsDir, asset.name); + const url = downloads.get(asset.name); + if (url) { + await download(url, dest); + } else if (process.env.DISASMER_PUBLIC_RELEASE_USE_LOCAL_ASSETS === "1") { + fs.copyFileSync(asset.file, dest); + } else { + throw new Error(`Forgejo Release report has no download URL for ${asset.name}`); + } + downloaded.push(dest); + } + return downloaded; +} + +function verifyChecksums(assetsDir) { + const sumsPath = path.join(assetsDir, "SHA256SUMS"); + assert(fs.existsSync(sumsPath), "SHA256SUMS must be downloaded from the release"); + for (const line of fs.readFileSync(sumsPath, "utf8").split("\n")) { + if (!line.trim()) continue; + const match = /^([a-f0-9]{64})\s+(.+)$/.exec(line.trim()); + assert(match, `invalid SHA256SUMS line: ${line}`); + const [, expected, name] = match; + const file = path.join(assetsDir, name); + assert(fs.existsSync(file), `checksum references missing asset ${name}`); + assert.strictEqual(sha256File(file), expected, `checksum mismatch for ${name}`); + } +} + +function binaryArchive(manifest) { + const platform = `${os.platform()}-${os.arch()}`; + const asset = manifest.assets.find( + (candidate) => + candidate.name.startsWith("disasmer-public-binaries-") && + candidate.name.includes(platform) + ); + if (!asset) { + throw new Error(`release manifest has no binary archive for ${platform}`); + } + return asset.name; +} + +function walkFiles(root, relative = "") { + const dir = path.join(root, relative); + const entries = fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => relative || entry.name !== ".git") + .sort((left, right) => left.name.localeCompare(right.name)); + const files = []; + for (const entry of entries) { + const childRelative = relative ? path.join(relative, entry.name) : entry.name; + if (entry.isDirectory()) { + files.push(...walkFiles(root, childRelative)); + } else if (entry.isFile() || entry.isSymbolicLink()) { + files.push(childRelative); + } + } + return files; +} + +function hashTree(root) { + const hash = crypto.createHash("sha256"); + for (const file of walkFiles(root)) { + const absolute = path.join(root, file); + const stat = fs.lstatSync(absolute); + hash.update(file.replaceAll(path.sep, "/")); + hash.update("\0"); + hash.update(String(stat.mode & 0o777)); + hash.update("\0"); + if (stat.isSymbolicLink()) { + hash.update("symlink"); + hash.update("\0"); + hash.update(fs.readlinkSync(absolute)); + } else { + hash.update(fs.readFileSync(absolute)); + } + hash.update("\0"); + } + return `sha256:${hash.digest("hex")}`; +} + +function assertFilteredPublicCheckout(checkout, manifest) { + for (const forbidden of ["private", "experiments", "target"]) { + assert(!fs.existsSync(path.join(checkout, forbidden)), `${forbidden}/ leaked into public repo`); + } + for (const file of walkFiles(checkout)) { + assert( + !file.split(path.sep).includes(".disasmer"), + `generated .disasmer view state leaked into public repo: ${file}` + ); + } + for (const entry of fs.readdirSync(checkout, { withFileTypes: true })) { + assert( + !(entry.isFile() && path.extname(entry.name).toLowerCase() === ".md"), + `root Markdown file leaked into public repo: ${entry.name}` + ); + } + const includeForgejoWorkflows = + manifest.public_export && manifest.public_export.include_forgejo_workflows; + if (!includeForgejoWorkflows) { + assert(!fs.existsSync(path.join(checkout, ".forgejo")), ".forgejo/ leaked into public repo"); + } + const provenancePath = path.join(checkout, "DISASMER_PUBLIC_TREE.json"); + assert(fs.existsSync(provenancePath), "public checkout must include DISASMER_PUBLIC_TREE.json"); + const provenance = readJson(provenancePath); + assert.strictEqual(provenance.source_commit, manifest.source_commit); + assert.strictEqual(provenance.release_name, manifest.release_name); + for (const expected of [ + "private/**", + "experiments/**", + ".git", + "target", + "/*.md", + "**/.disasmer/**", + ]) { + assert(provenance.filtered_out.includes(expected), `public provenance missing ${expected}`); + } + if (!includeForgejoWorkflows) { + assert( + provenance.filtered_out.includes(".forgejo/**"), + "public provenance must record .forgejo filtering" + ); + } + assert.strictEqual(hashTree(checkout), manifest.public_tree_identity); +} + +function executable(root, name) { + const suffix = process.platform === "win32" ? ".exe" : ""; + const file = path.join(root, "bin", `${name}${suffix}`); + assert(fs.existsSync(file), `missing release binary ${file}`); + return file; +} + +const commands = []; + +async function 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 variables(client, variablesReference) { + const request = client.send("variables", { variablesReference }); + return (await client.response(request, "variables")).body.variables; +} + +function scopeReference(scopes, name) { + const scope = scopes.find((candidate) => candidate.name === name); + assert(scope, `missing DAP scope ${name}`); + return scope.variablesReference; +} + +async function validateReleasedLiveDap(disasmerDap, disasmerNode, checkout, addr) { + const suffix = String(Date.now()); + const tenant = `dap-${suffix}`; + const projectId = `dap-project-${suffix}`; + const user = "dap-user"; + const node = `dap-worker-${suffix}`; + const projectRoot = path.join(checkout, "examples/launch-build-demo"); + const sourcePath = path.join(projectRoot, "src/build.rs"); + + const created = await send(addr, { + type: "create_project", + tenant, + project: projectId, + user, + name: "Public Release Live DAP", + }); + assert.strictEqual(created.type, "project"); + + const workerGrant = await send(addr, { + type: "create_node_enrollment_token", + tenant, + project: projectId, + user, + grant_id: `dap-worker-grant-${suffix}`, + scope: "node:attach", + expires_at_epoch_seconds: 4102444800, + }); + assert.strictEqual(workerGrant.type, "enrollment_grant"); + + const client = new DapClient(disasmerDap, { cwd: checkout }); + let worker; + 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: projectRoot, + runtimeBackend: "live-services", + operatorEndpoint: serviceEndpoint, + tenant, + projectId, + actorUser: user, + }); + await client.response(launch, "launch"); + await client.waitFor( + (message) => message.type === "event" && message.event === "initialized" + ); + + const breakpoints = client.send("setBreakpoints", { + source: { path: sourcePath }, + breakpoints: [{ line: 12 }, { line: 22 }], + }); + const breakpointResponse = await client.response(breakpoints, "setBreakpoints"); + assert.deepStrictEqual( + breakpointResponse.body.breakpoints.map((breakpoint) => breakpoint.verified), + [true, true] + ); + + const configurationDone = client.send("configurationDone"); + await client.response(configurationDone, "configurationDone"); + const stopped = await client.waitFor( + (message) => message.type === "event" && message.event === "stopped" + ); + assert.strictEqual(stopped.body.reason, "breakpoint"); + assert.strictEqual(stopped.body.threadId, 1); + assert.strictEqual(stopped.body.allThreadsStopped, true); + + const stackRequest = client.send("stackTrace", { + threadId: 1, + startFrame: 0, + levels: 1, + }); + const stack = (await client.response(stackRequest, "stackTrace")).body.stackFrames; + assert.strictEqual(stack[0].line, 12); + assert.strictEqual(stack[0].source.path, sourcePath); + + const scopesRequest = client.send("scopes", { frameId: stack[0].id }); + const scopes = (await client.response(scopesRequest, "scopes")).body.scopes; + const runtime = await variables(client, scopeReference(scopes, "Disasmer Runtime")); + const output = await variables(client, scopeReference(scopes, "Recent Output")); + assert( + runtime.some( + (variable) => variable.name === "runtime_backend" && variable.value === "LiveServices" + ) + ); + assert( + runtime.some( + (variable) => variable.name === "coordinator_task_events" && variable.value === 0 + ) + ); + assert( + runtime.some( + (variable) => + variable.name === "command_status" && + String(variable.value).includes("coordinator-side virtual process started") + ) + ); + assert( + output.some((variable) => + String(variable.value).includes("Command-capability virtual task") + ) + ); + + const workerArgs = [ + "--coordinator", + serviceEndpoint, + "--tenant", + tenant, + "--project-id", + projectId, + "--node", + node, + "--public-key", + `${node}-public-key`, + "--enrollment-grant", + workerGrant.grant.grant_id, + "--worker", + "--assignment-poll-ms", + "250", + "--emit-ready", + ]; + commands.push([disasmerNode, ...workerArgs].join(" ")); + worker = cp.spawn(disasmerNode, workerArgs, { cwd: checkout }); + const workerReady = await waitForJsonLine(worker, "public release live DAP worker"); + assert.strictEqual(workerReady.node_status, "ready"); + assert.strictEqual(workerReady.mode, "worker"); + assert.strictEqual(workerReady.node, node); + + const continueRequest = client.send("continue", { threadId: 1 }); + await client.response(continueRequest, "continue"); + await client.waitFor( + (message) => message.type === "event" && message.event === "continued" + ); + const taskStopped = await client.waitFor( + (message) => + message.type === "event" && + message.event === "stopped" && + message.body.reason === "breakpoint" && + message.body.threadId === 2 + ); + assert.strictEqual(taskStopped.body.allThreadsStopped, true); + + const taskStackRequest = client.send("stackTrace", { + threadId: 2, + startFrame: 0, + levels: 1, + }); + const taskStack = (await client.response(taskStackRequest, "stackTrace")).body.stackFrames; + assert.strictEqual(taskStack[0].line, 22); + + const taskScopesRequest = client.send("scopes", { frameId: taskStack[0].id }); + const taskScopes = (await client.response(taskScopesRequest, "scopes")).body.scopes; + const taskRuntime = await variables(client, scopeReference(taskScopes, "Disasmer Runtime")); + const taskOutput = await variables(client, scopeReference(taskScopes, "Recent Output")); + assert( + taskRuntime.some( + (variable) => variable.name === "coordinator_task_events" && variable.value === 1 + ) + ); + assert( + taskRuntime.some( + (variable) => + variable.name === "command_status" && + String(variable.value).includes("completed through live services") + ) + ); + assert(taskRuntime.some((variable) => variable.name === "stdout_tail")); + assert(taskRuntime.some((variable) => variable.name === "stderr_tail")); + assert(taskOutput.some((variable) => variable.name === "stdout_tail")); + assert(taskOutput.some((variable) => variable.name === "stderr_tail")); + assert( + taskOutput.some((variable) => + String(variable.value).includes("attached node completed task") + ) + ); + + return { + operator_implementation: "private-hosted-coordinator", + runtime_backend: "live-services", + coordinator_side_started_without_worker: true, + worker_started_after_coordinator_side_breakpoint: true, + worker_node: node, + main_thread_breakpoint: 12, + task_thread_breakpoint: 22, + task_events: 1, + task_runtime_status: "completed through live services", + variables_verified: true, + }; + } finally { + await client.close().catch(() => {}); + await stopChild(worker); + } +} + +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 disasmerDap = executable(installDir, "disasmer-debug-dap"); + + 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 + ); + const releasedLiveDap = await validateReleasedLiveDap( + disasmerDap, + disasmerNode, + checkout, + addr + ); + + 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, + released_live_dap_verified: true, + released_live_dap_operator_implementation: releasedLiveDap.operator_implementation, + released_live_dap_worker_after_launch: + releasedLiveDap.worker_started_after_coordinator_side_breakpoint, + released_live_dap_task_events: releasedLiveDap.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, + released_live_dap: releasedLiveDap, + }, + acceptance_result: "passed", + }; + for (const [key, value] of Object.entries(report)) { + if (key.endsWith("_verified") || key.endsWith("_validated") || key.endsWith("_selected") || key.endsWith("_checkout") || key.endsWith("_assets") || key === "public_repo_build_or_install" || key === "browser_or_cli_login" || key === "attached_user_node" || key === "ran_flagship_workflow" || key === "verified_checksums") { + assert.strictEqual(value, true, `${key} must be true`); + } + } + + ensureDir(path.dirname(reportPath)); + fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`); + console.log(`Public release dry-run e2e passed: ${reportPath}`); +} + +main().catch((error) => { + console.error(error.stack || error.message); + process.exit(1); +}); diff --git a/scripts/public-release-dryrun-final-evidence.js b/scripts/public-release-dryrun-final-evidence.js new file mode 100755 index 0000000..9d87a4c --- /dev/null +++ b/scripts/public-release-dryrun-final-evidence.js @@ -0,0 +1,440 @@ +#!/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 assertFilteredOut(manifest) { + assert(Array.isArray(manifest.filtered_out), "manifest filtered_out must be an array"); + for (const expected of [ + "private/**", + "experiments/**", + ".git", + "target", + "/*.md", + "**/.disasmer/**", + ]) { + assert( + manifest.filtered_out.includes(expected), + `manifest filtered_out must include ${expected}` + ); + } + const includeForgejoWorkflows = + manifest.public_export && manifest.public_export.include_forgejo_workflows; + if (!includeForgejoWorkflows) { + assert( + manifest.filtered_out.includes(".forgejo/**"), + "manifest filtered_out must include .forgejo/** for the host-neutral export" + ); + } +} + +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"); +assertFilteredOut(manifest); +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.source_commit, manifest.source_commit); +assert.strictEqual(compat.release_name, manifest.release_name); +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.source_commit, manifest.source_commit); +assert.strictEqual(publicCoordinator.release_name, manifest.release_name); +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"); +assert.strictEqual(e2e.released_live_dap_verified, true); +assert.strictEqual( + e2e.released_live_dap_operator_implementation, + "private-hosted-coordinator" +); +assert.strictEqual(e2e.released_live_dap_worker_after_launch, true); +assert(e2e.released_live_dap_task_events >= 1, "e2e must record released live-DAP task events"); +assert( + e2e.evidence && + e2e.evidence.released_live_dap && + e2e.evidence.released_live_dap.coordinator_side_started_without_worker === true, + "e2e must prove released DAP starts the coordinator-side process before a worker is attached" +); +assert( + e2e.evidence && + e2e.evidence.released_live_dap && + e2e.evidence.released_live_dap.variables_verified === true, + "e2e must prove released live-DAP variables are inspectable" +); +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", + "released_live_dap_verified", + "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, + released_live_dap: e2e.evidence.released_live_dap, + }, + 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..73f8601 --- /dev/null +++ b/scripts/public-release-dryrun-preflight.js @@ -0,0 +1,247 @@ +#!/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-operator-compat.json"), + currentSourceCommit + ), + staleEvidence( + path.join(acceptanceRoot, "public-coordinator-compat.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..f4a7b5b --- /dev/null +++ b/scripts/self-hosted-coordinator-smoke.js @@ -0,0 +1,299 @@ +#!/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)}`; +const releaseManifestPath = + process.env.DISASMER_PUBLIC_RELEASE_MANIFEST || + path.join(repo, "target/public-release-dryrun/public-release-manifest.json"); + +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"] + }; +} + +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"]) + ); +} + +function readReleaseManifest() { + if (!fs.existsSync(releaseManifestPath)) return null; + const manifest = JSON.parse(fs.readFileSync(releaseManifestPath, "utf8")); + assert.strictEqual(manifest.kind, "disasmer-public-release-dryrun"); + const expectedCommit = expectedSourceCommit(); + if (expectedCommit) { + assert.strictEqual( + manifest.source_commit, + expectedCommit, + "self-hosted coordinator smoke must use the manifest for the current acceptance commit" + ); + } + return manifest; +} + +function releaseIdentity() { + const manifest = readReleaseManifest(); + return { + sourceCommit: manifest ? manifest.source_commit : expectedSourceCommit(), + releaseName: + (manifest && manifest.release_name) || + process.env.DISASMER_PUBLIC_RELEASE_NAME || + null, + }; +} + +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 release = releaseIdentity(); + 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", + source_commit: release.sourceCommit, + release_name: release.releaseName, + 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/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 @@ + + +